Meilisearch Search, Ranking and Result Mixing
This is the read path of YouTeacher's job service: how a search query becomes a ranked, de-duplicated, platform-diverse page of jobs. It sits behind the job search endpoint and leans on Meilisearch for the index, Redis for a short-lived cache, and a small amount of application logic for ranking and mixing.
Request flow
A search request enters through the job search controller. The controller resolves optional user identity, validates the query with a zod schema (free-text q and location capped at 240 characters, pageSize capped at 100, numeric salary bounds, comma-split languages, date bounds, and platform/recruiter/employer filters), and applies a rate limit keyed on the caller. It then hands a flat filter object to the search handler and, after responding, fires a best-effort cleanup trigger.
The handler normalizes page and pageSize against defaults and a maximum, then checks the cache first. On a hit it returns the cached page unchanged. On a miss it runs the Meilisearch query, stamps a fresh lastUpdatedAt, optionally diversifies platforms, and writes the enriched result back to the cache before returning it.
Querying Meilisearch
The Meilisearch adapter builds a filter list from the request: job type, recruiter, employer, subjects, one clause per language, source platform, salary floor and ceiling, a posted-date window (compared against a stored millisecond timestamp), and a location clause that matches city or province or country. A status = "active" filter is always appended, so only live jobs come back. The free-text search string is the query and location joined together.
Salary floor filtering is currency-aware when an exchange-rate service is available: the adapter refreshes rates and builds the minimum-salary clause against them, rather than comparing raw numbers across currencies.
Ranking
Ranking is expressed as a Meilisearch sort, applied in order:
locationCountryRankascending — a per-country rank that pushes preferred markets to the top;postedAtdescending — recency;iddescending — a stable, deterministic tie-break so equal-rank, equal-recency jobs keep a fixed order across pages.
After Meilisearch returns hits, the adapter applies one runtime visibility check that the index can't express: for direct-origin postings it drops any whose expiry has passed. Aggregated postings skip that check (their lifecycle is enforced elsewhere). Totals come from the response's estimated total hits.
Facets and the index contract
Each search also asks Meilisearch for facet distributions across country, subjects, job type, languages, recruiter name, and source platform; the adapter turns those into buckets sorted by count. The searchable, filterable, and sortable attribute sets are declared once and pushed to the index by an idempotent settings step that only rewrites settings when they differ from what the index already holds, and creates the index with id as its primary key if it is missing.
Platform-diversifying result mixing
Aggregated jobs come from several source platforms, and a naive recency sort can let one platform dominate a page. An optional diversifier addresses this — off by default. When enabled, it only acts when a page carries fewer distinct platforms than a desired count (default 3, floored at 2). It reads the platform facet, picks a few platforms absent from the current page, runs small supplementary searches scoped to each, de-duplicates by job id, and then round-robins the combined set: jobs are bucketed by platform in first-seen order and drawn one platform at a time until exhausted, then the page is trimmed back to its size. The effect is a page that interleaves platforms instead of stacking them.
Caching
The search cache is a thin Redis layer with a short default time-to-live (tens of seconds). Its key is prefix : version : sha1(serialized-input), so any change to the normalized query produces a distinct key. The version segment is the invalidation lever: incrementing a single version counter makes every previously cached key unreachable at once, which lets a write elsewhere in the service invalidate all cached searches without enumerating keys.
Related
- aggregated-job-ingestion-pipeline — how documents get into the index the search reads
- job-lifecycle-validity-and-cleanup — the expiry and reachability rules behind the visibility check
- service-architecture-and-request-flow — the layering this read path lives in