What this module covers: Why LIKE '%term%' is not slow search but the absence of search, and what an inverted index actually stores. The analysis pipeline — tokenisation, stemming, stopwords, synonyms — and why index-time and query-time analysis must agree or you get zero results with no error. Relevance as arithmetic: TF-IDF, why BM25 replaced it, and the distributed-IDF problem that makes scores shard-dependent. Postgres full-text search taken seriously, including where its ceiling actually is and the one limit that surprises people on managed hosting. The checklist for when a dedicated engine is earned. Then the part that is genuinely hard and is not a search problem at all: keeping the index consistent with the database, why dual writes are broken, and the read-your-own-writes complaint that lands the day you ship. Plus deep pagination, autocomplete latency budgets, per-tenant index strategy, and why the search engine must never be your source of truth.
LIKE '%term%' Is Not Slow Search, It Is the Absence of Search
The first version of every search feature is this:
sql
It works on 5,000 rows and fails on 5 million, and the two failures are different in kind.
It cannot use an index. A B-tree indexes values by their leading bytes (Module F-13), so it can answer LIKE 'data%' — a prefix — and cannot answer LIKE '%data%', because the search term may begin anywhere. Every query is a sequential scan reading and pattern-matching every row. At 5 million rows that is seconds of CPU per query, and Module P-8's noisy-neighbour arithmetic follows: one search page becomes the most expensive endpoint you own.
It has no notion of relevance, and no notion of language. A row either matches or does not, so results come back in whatever order the storage engine produced them. ILIKE '%database%' misses "databases" only in the plural-suffix case you happened to type, misses "DB", matches "databases are" inside an unrelated sentence with the same weight as a title match, and cannot tell you that a document mentioning the term nine times is more likely what the user wanted than one mentioning it once. Users experience this as "the search is broken," which is accurate — they were promised search and given substring matching.
Search is a different data structure and a different scoring problem, and both halves matter. Making substring matching fast (a trigram index will) does not make it search; it makes it fast substring matching, which still ranks nothing.
Analogy: the index at the back of a textbook. It is not a faster way of reading the book cover to cover — it is a different artefact, built in advance, that maps terms to page numbers. Nobody builds one by scanning pages at question time. And a good index does more than list pages: it merges "database" and "databases" into one entry, omits "the" entirely, cross-references "DB, see database," and puts the primary discussion first. Every one of those editorial decisions has a counterpart in the analysis pipeline below.
An Inverted Index Maps Terms to Documents, Built by an Analysis Pipeline
A forward index is what your database has: document → its contents. An inverted index is the transpose: term → the list of documents containing it, with positions and frequencies.
text
A query for "postgres database" now intersects two short posting lists instead of scanning three documents — and instead of scanning five million. That is the entire performance story, and it is the same trade as any index: you pay at write time and at storage to make reads cheap.
The interesting part is how text becomes those terms. The analysis pipeline runs in stages:
Stage
Does
Example
Character filter
strips or rewrites characters
HTML tags removed, & → &
Tokeniser
splits into tokens
"don't stop" → ["don't", "stop"]
Lowercase
case folding
Postgres → postgres
Stopwords
drops high-frequency, low-information words
the, a, is removed
Stemming
reduces to a root form
indexing, indexes, indexed → index
Synonyms
expands equivalents
db → db, database
N-grams (optional)
substrings for prefix or fuzzy matching
post, postg, postgr …
Every stage is a decision with a cost:
Stemming is lossy and algorithmic, not semantic. Porter stemming maps universe and university to the same stem, and business to busi. It also cannot know that SAP is a company rather than tree fluid. Lemmatisation is the more accurate, more expensive alternative that uses a dictionary. Aggressive stemming raises recall and lowers precision; the choice is a product decision about which complaint you would rather field.
Stopwords save space and destroy phrases. Drop to and be and the query "to be or not to be" is empty. Modern engines mostly stop dropping stopwords for this reason, relying on BM25's saturation to handle common terms instead.
Synonyms are asymmetric. Expanding at index time bloats the index and requires reindexing to change the list; expanding at query time keeps the list editable but makes every query wider and slower. Query time is the usual right answer precisely because synonym lists are edited weekly by people who are not engineers.
The rule that causes the most confusion: index-time and query-time analysis must agree. If you index with stemming and query without it, searching indexing looks for the literal token indexing, the index contains only index, and you get zero results — with no error, no warning, and a perfectly healthy system. Teams debug this for a long time because nothing is broken; the two ends simply disagree about what a word is. This is the single most common self-inflicted search bug, and it appears in Postgres as a mismatched to_tsvector/to_tsquery configuration and in Elasticsearch as an analyzer that differs from the search_analyzer.
Relevance Is Arithmetic: TF-IDF, Then BM25
Matching gives you a set. Ranking gives you an order, and the order is what users judge. The classical intuition has two components:
Term frequency (TF): a document mentioning the term more is more about it.
Inverse document frequency (IDF): a term appearing in few documents is more discriminating. the is worthless; mitochondrial is decisive.
TF-IDF multiplies them, and it has two flaws that matter in practice. Term frequency grows linearly, so a document repeating a term 200 times scores forty times higher than one repeating it five times — but it is not forty times more relevant, and this is exactly the shape keyword-stuffing exploits. And nothing accounts for document length, so a long document accumulates matches simply by being long.
BM25 fixes both, and is the default in every Lucene-based engine for that reason:
text
The two knobs are the whole story:
k₁ (≈1.2) saturates term frequency. The fifth occurrence of a term adds much less than the second, and the two-hundredth adds essentially nothing. Keyword stuffing stops paying.
b (≈0.75) normalises by document length relative to the corpus average, so a 50-word answer that is entirely about the topic can outrank a 5,000-word page that mentions it in passing.
Two production notes about scores that catch people out.
Scores are not comparable across queries. A BM25 score of 8.4 means nothing absolute; it is only meaningful as an ordering within one result set. So a "minimum relevance" threshold hard-coded as a score is not portable across queries or corpora, and it will silently start returning nothing when your corpus grows and IDF shifts.
IDF is computed per shard. In a distributed engine, each shard knows only its own document frequencies, so the same document can score differently depending on which shard it lives on. With even distribution and a decent corpus size the effect is negligible; with few documents, skewed routing, or a test index of fifty documents it is large enough to look like a bug — and it is the standard explanation for "relevance looks wrong in staging and fine in production." Engines offer a global-frequency search type to correct it, at the cost of an extra round of coordination per query.
Above BM25 sits the layer where real relevance work happens: field boosting (a title match is worth more than a body match), freshness decay, popularity signals, and business rules ("in-stock items first"). This is why a dedicated engine's value is often less about speed than about having a place to express those rules and iterate on them.
Postgres Full-Text Search Goes Further Than People Expect
Before reaching for a cluster, know the ceiling of what you already run. Postgres has real full-text search: tsvector is an inverted index of a document, tsquery is a parsed query, and GIN indexes the mapping.
sql
That gives you stemming, stopwords, phrase queries, field weights, and ranking, inside your existing transactional database — which means the index cannot be out of sync with the data, and the entire second half of this module (the consistency problem) simply does not apply to you. That property is worth more than most teams weigh it, and it is why "start with Postgres" is genuinely good advice rather than a hedge.
Add pg_trgm for what full-text cannot do — typo tolerance, substring matching, and matching short non-word strings like SKUs:
sql
Where the ceiling actually is:
Limit
Detail
Ranking cost
ts_rank is computed on candidate rows after matching, so a query matching 400,000 rows ranks 400,000 rows to return 20. Selective queries are fast; broad ones are not.
One language per column
The text-search configuration is fixed in the generated column. Multi-language corpora need a column or partial index per language.
Relevance tuning surface
Four weight classes (A–D), and that is it. No per-query boosts, no decay functions, no learning-to-rank.
Synonyms and dictionaries
Configured as files on the server's filesystem ($SHAREDIR/tsearch_data), which most managed Postgres services do not let you write to — so a synonym list you want product managers to edit weekly is often simply unavailable. This is the limit that surprises people, because it is an operational restriction rather than a database one.
Autocomplete
Prefix matching works (to_tsquery('data:*')), but there is no FST-backed suggester, so sub-20 ms autocomplete over millions of documents is not its strength.
Scale-out
Search shares your OLTP instance's CPU and buffer cache. A heavy search workload competes with your transactional workload for both — Module P-22's argument, arriving early.
The honest summary: Postgres full-text is the right answer for most applications up to a few million documents with moderate query volume and modest relevance requirements, and its unbeatable advantage is transactional consistency. It stops being the right answer when relevance itself becomes a product you iterate on.
When a Dedicated Engine Is Earned
Reach for Elasticsearch/OpenSearch, Typesense, Meilisearch, Vespa or Algolia when you can point at items on this list — not because search feels important:
Relevance is a product surface that non-engineers need to tune: boosts, synonyms, pinned results, business rules, A/B tests.
Faceted navigation over high-cardinality fields, with counts, computed per query.
Autocomplete under ~30 ms across millions of documents.
Typo tolerance as a default, not an opt-in fuzzy query.
Per-language analysis across a genuinely multilingual corpus.
Combined text, geo, filter and numeric scoring in one ranked query.
Aggregations over search results — histograms, top-N by facet.
Search volume large enough that it must not share the OLTP instance.
What you take on is worth stating plainly, because it is the part the evaluation skips. A Lucene-based engine is memory-bound: performance depends on the filesystem cache holding hot segments, so capacity planning is in RAM, not CPU. Segments are immutable, written once and merged in the background — the LSM-tree structure from Module P-2, with the same consequence that compaction (here, merging) is background I/O you must plan for. Because segments are immutable, a "partial update" is a delete plus a reinsert of the whole document, which makes a frequently-updated field on a large document surprisingly expensive. Indexing is near-real-time, not real-time: a document becomes searchable when a refresh occurs, one second by default, which is the direct cause of the read-your-own-writes complaint below. And a cluster is a distributed system with its own failure modes, its own upgrade path, and mapping changes that require a full reindex.
Two rules that save teams from the worst outcomes:
The search engine is not your source of truth. It must be rebuildable from the database at any time. Teams that store data only in Elasticsearch discover the reasons the hard way: no transactions, no foreign keys, no constraints, a mapping change that requires reindexing data you can no longer reconstruct, and a recovery story that depends on snapshots of a system that was never designed to be authoritative.
Do not evaluate on a fifty-document test index. Relevance behaviour is dominated by corpus statistics — IDF, average document length, the per-shard effect above — so a small index tells you almost nothing about how ranking will behave in production.
The Hard Part Is Not Search, It Is Staying in Sync
Once the index lives outside your database, you own a data consistency problem, and it is the same one Module P-16 posed: two systems, one non-transactional boundary.
The obvious implementation is a dual write, and it is broken:
ts
Every failure mode you would expect is real: the process dies between them and the index is permanently stale; Elasticsearch is briefly unavailable and the write is lost with the database already committed; the database transaction rolls back after the index write succeeds and you have indexed a document that does not exist; and two concurrent updates can reach the index in the opposite order to the database, so the index settles on the older value. That last one is the nastiest, because it is intermittent and leaves no trace.
The three sound designs, and their costs:
1. Outbox plus indexing worker. In the same transaction as the business write, insert a row saying "article 42 changed." A worker (Module P-19) reads pending rows, fetches the current state of article 42 from the database, and indexes it. Nothing is lost because nothing leaves the transaction, and the ordering problem disappears because the worker indexes current state rather than a captured diff — a late-arriving job simply re-indexes the same current value idempotently. Cost: a worker to operate, and a lag of seconds.
2. Change data capture. Read the database's write-ahead log and stream changes to the index (Module P-23). No application code touches the index, ordering follows the log, and it captures writes from migrations and manual UPDATEs that application-level hooks miss entirely. Cost: real infrastructure (Debezium or equivalent), and schema changes that ripple downstream.
3. Periodic full reindex as a backstop — not as the primary mechanism. Whatever you choose above, drift happens: a bug, an incident, a manual data fix, a mapping change. A scheduled job that reconciles or rebuilds is what turns drift from a permanent silent error into a bounded one. Do it with an alias swap, which is the standard zero-downtime pattern:
text
Application code queries the alias and never a concrete index name, which makes both reindexing and rollback a single metadata operation.
The complaint you will get on day one: read-your-own-writes
A user edits their document, clicks save, immediately searches for it, and it is not there. Nothing is broken — the refresh interval has not elapsed, and the outbox worker may be a second behind on top of that. But this is the exact user-visible symptom Module P-11 named, and "search is eventually consistent" is not an acceptable answer to the person who just typed the words.
The workable answers, in order of preference:
Do not use search for the view that must be fresh. "My documents" is a database query with a filter, not a search query. This resolves most of the complaint for free, and the mistake is routing every list through the search engine because it is convenient.
Return the just-written document from the database and merge it into the result set for that user, for a few seconds after their write.
Wait for a refresh on that one write (refresh: "wait_for"), which makes only the writing request slower rather than raising the global refresh rate. Forcing a global refresh per write is the tempting version and it destroys indexing throughput, because refreshes create segments and segments must then be merged.
Query-Side Concerns That Bite at Scale
Deep pagination is quadratic in a distributed engine.from: 100000, size: 10 requires every shard to produce its top 100,010 hits and the coordinator to merge and discard 100,000 of them. With ten shards that is a million documents scored and sorted to return ten. Engines cap it (index.max_result_window, 10,000 by default) precisely because it is a cluster-wide memory hazard rather than a slow query. Use a cursor — search_after on a stable sort key — which is the same design as keyset pagination in SQL (Module F-8) and the same reason: an offset must be counted, a cursor can be seeked. If a user genuinely needs page 4,000, the honest read is that they need an export (Module P-19), not a search result.
Autocomplete is a latency problem, not a search problem. The budget is roughly 30–50 ms end to end because it fires per keystroke, which also means the query volume is 5–10× your search volume. Three implementations: prefix queries (simple, slow on large corpora), edge n-grams (index p, po, pos, post… — fast queries at a large index-size cost), and an FST-backed suggester (fastest, a separate structure to build). Two things make it cheap regardless of choice: debounce on the client, and cache aggressively — the distribution of two- and three-character prefixes is small and enormously skewed, so a Redis cache absorbs most of the traffic (Module F-12's working-set argument at its most favourable).
Typo tolerance costs more than it looks. Fuzzy matching expands one term into every term within an edit distance, so the posting lists to union grow fast, and short terms become almost meaningless at distance 2. The standard compromise is distance scaled to term length — exact for ≤3 characters, distance 1 up to ~6, distance 2 beyond — and never applying fuzziness to a phrase query.
Multi-tenancy needs the Module P-8 decision made explicitly. Index-per-tenant gives clean isolation, per-tenant deletion, and per-tenant mappings — but every shard carries fixed memory and file-handle overhead, so a few thousand small tenants each with their own index will exhaust a cluster on overhead alone before it holds any real data. The standard answer is the bridge model from Module P-8: pool small tenants into a shared index with a mandatory tenant filter, and silo the whales into dedicated indices. Whatever you choose, the tenant filter must be applied by a server-side layer that no query can omit — a forgotten filter in a search query is a cross-tenant data leak, and unlike a SQL WHERE clause omission it will not error, it will simply return other customers' documents.
Everything user-facing needs a timeout and a fallback. A search cluster degrading is common, and search is usually not the operation that must succeed. A short timeout, allow_partial_search_results where degraded results beat none, and a fallback to a simple database query on the primary filter is the difference between "search is a bit worse right now" and a broken page. Module A-7's degradation argument, applied to the component most likely to need it.
Measuring Relevance, Because Otherwise You Are Guessing
Search is the one subsystem where "it works" is not observable from the outside — it returns results either way. The instrumentation that makes it improvable:
Zero-result rate, segmented by query. It is the most actionable number you have: the top zero-result queries are a direct list of missing synonyms, missing content, and analysis bugs.
Click-through rate and click position. If users routinely click result seven, your ranking is wrong in a measurable way.
Query reformulation rate. A user searching again within a few seconds did not find what they wanted.
Offline relevance metrics (NDCG, MRR) against a labelled judgement set, so a ranking change can be evaluated before it ships rather than after complaints.
Latency at p99, separated by query type — autocomplete, filtered search, faceted search — because their budgets differ by an order of magnitude and one average hides all three.
Why this matters in production: relevance regressions are invisible to every alert you have. Adding an aggressive stemmer, changing a boost, or reindexing with a slightly different mapping can make results materially worse while every dashboard stays green, latency improves, and error rate is zero. Zero-result rate and CTR are the only alarms that fire, which is why they are worth instrumenting on the day search ships rather than the day someone complains.
Where This Shows Up in Your Stack
PostgreSQL:tsvector in a GENERATED ALWAYS AS … STORED column plus a GIN index is the whole implementation, and generating it rather than maintaining it in application code removes the possibility of drift. Use websearch_to_tsquery for user input — to_tsquery throws a syntax error on unbalanced quotes, which becomes a 500 the first time someone types an apostrophe. Add pg_trgm for typos, substrings, and SKU-like strings. Know that GIN has a pending-insert list, so fastupdate trades write latency for occasional larger cleanups, and that ts_rank scores every matching row, so broad queries are expensive even with a perfect index.
Node.js: the analysis-mismatch bug is usually a client-side default — an analyzer set at index time with no matching search_analyzer. Never build query strings from user input by concatenation; use the engine's structured query DSL, since a query-string parser is an injection surface for both syntax errors and expensive query shapes (a leading wildcard, a distance-2 fuzzy on a common term). Always set a request timeout; the default in most clients is none.
Redis: the natural cache for autocomplete prefixes and for the top-N popular queries, where the hit ratio is extremely high (Module F-12). RediSearch exists and is a credible small-to-mid-scale engine with the same caveat as any Redis-resident data: memory is the capacity limit and eviction policy must not be allowed to touch it (Module P-12).
Elasticsearch / OpenSearch: plan capacity in RAM for the filesystem cache, not CPU. Query an alias, never a concrete index, so reindexing and rollback are metadata operations. Tune refresh_interval up (30s) for bulk indexing and back down for interactive use, and use refresh: "wait_for" on individual writes rather than forcing global refreshes. Keep shard counts low — a shard is a Lucene index with fixed overhead, and over-sharding is the most common self-inflicted cluster problem.
Kafka / CDC: the sound sync mechanisms are the outbox relay (Module A-3) or WAL-based CDC (Module P-23), never a dual write. If you already run Kafka, an indexing consumer gives you replayability for free — a mapping change becomes "reset the consumer offset and let it rebuild," which is the cheapest reindex you will ever run.
Summary
LIKE '%term%' fails twice over: a B-tree cannot serve a leading wildcard so every query is a sequential scan, and substring matching has no concept of relevance, stemming, or language. Making it fast with a trigram index gives you fast substring matching, not search.
An inverted index maps terms to documents, built by an analysis pipeline — tokenise, lowercase, drop stopwords, stem, expand synonyms. Each stage is a lossy trade: stemming raises recall and lowers precision, stopwords destroy phrase queries, and synonyms belong at query time so a non-engineer can edit them without a reindex.
Index-time and query-time analysis must agree. A mismatch returns zero results with no error and nothing unhealthy anywhere — the most common self-inflicted search bug there is.
BM25 replaced TF-IDF because it saturates term frequency (k₁) and normalises document length (b), which stops keyword stuffing paying and stops long documents winning by accumulation. Scores are meaningful only as an ordering within one result set, never as an absolute threshold — and IDF is computed per shard, which is why relevance can look wrong on a small staging index and fine in production.
Postgres full-text goes further than most teams assume:tsvector in a generated column, GIN index, setweight for field boosting, websearch_to_tsquery for safe user input, pg_trgm for typos and substrings. Its decisive advantage is that the index is inside the transaction, so the entire consistency problem below does not exist. Its real ceilings are ranking cost on broad queries, one language per column, four weight classes of tuning, no FST autocomplete, sharing the OLTP instance — and synonym dictionaries living as files on the server's filesystem, which managed hosting usually will not let you write.
A dedicated engine is earned by specific requirements — tunable relevance as a product surface, facets with counts, sub-30 ms autocomplete, default typo tolerance, multi-language analysis, aggregations — not by search feeling important. What you take on: a memory-bound cluster whose capacity is planned in RAM, immutable segments merged in the background (Module P-2's LSM shape again), partial updates that are really full document rewrites, near-real-time rather than real-time visibility, and mapping changes that require a reindex.
The search engine must never be the source of truth. It must be rebuildable from the database at any time; teams that store data only there discover why when a mapping change requires reindexing data they can no longer reconstruct.
Dual writes are broken — the process can die between them, the index write can be lost after the database commits, and concurrent updates can land in the index in the wrong order and settle on the older value. Use an outbox plus an indexing worker that reads current state (which makes late jobs idempotent), or CDC from the WAL, and keep a periodic reindex with an alias swap as the backstop that bounds drift and gives you rollback.
Expect the read-your-own-writes complaint on day one (Module P-11): the refresh interval plus indexing lag means a user's own save is briefly unfindable. Serve "my documents" from the database rather than from search, merge the just-written row for that user, or use a per-write wait_for — never a global refresh per write, which destroys indexing throughput by creating segments that must then be merged.
Deep pagination is a cluster-wide memory hazard, since from: 100000 makes every shard produce and discard 100,010 hits; use a search_after cursor, the same keyset argument as Module F-8. Autocomplete is a latency problem with a 30–50 ms budget and 5–10× the query volume, so debounce and cache the short prefixes where hit ratios are extremely high. Scale fuzziness to term length.
Multi-tenant search is Module P-8's decision: index-per-tenant isolates but each shard carries fixed overhead, so pool small tenants behind a mandatory server-side tenant filter and silo the whales. A forgotten tenant filter does not error — it returns another customer's documents.
Instrument zero-result rate, CTR and click position, reformulation rate, and offline NDCG, because relevance regressions are invisible to every other alert: latency can improve and errors stay at zero while results get materially worse.
The next module, Vector Search and Embeddings, is the other half of retrieval. BM25 matches the words the user typed; embeddings match what they meant, at the cost of an index that is approximate by construction and a recall/latency knob you have to choose deliberately. The two are complements rather than replacements, and hybrid ranking — which the module ends on — is what production retrieval actually looks like.
Knowledge Check
A team migrates search from Postgres to Elasticsearch. They update the application to write to both: the UPDATE to Postgres, then an index call to Elasticsearch. Over weeks, a small number of documents show stale titles in search results, with no errors in logs and no pattern by tenant or document type. What is the diagnosis and the fix?
A product manager asks for a "minimum relevance" cut-off so that weak results are hidden, and an engineer implements if (hit.score < 5.0) drop(). It behaves acceptably in staging against a 200-document index. In production the same threshold hides almost everything for some queries and nothing for others, and the behaviour drifts as the corpus grows. Why?
A SaaS product gives each of its 4,000 customers a dedicated Elasticsearch index for clean isolation. The cluster becomes unstable — high heap pressure and slow cluster-state updates — while holding only a modest total volume of documents. Separately, a code review finds one search endpoint that omitted the tenant filter and returned other customers' documents without erroring. What does the module prescribe?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
-- Store the analysed document as a generated column so it can never drift from the source.ALTERTABLE articles ADDCOLUMN search_doc tsvector
GENERATED ALWAYS AS( setweight(to_tsvector('english',coalesce(title,'')),'A')||-- field boosting setweight(to_tsvector('english',coalesce(body,'')),'B')) STORED;CREATEINDEX articles_search ON articles USING GIN (search_doc);-- websearch_to_tsquery accepts user input safely: quotes, OR, and -exclusion.SELECT id, title, ts_rank(search_doc, q)AS rank
FROM articles, websearch_to_tsquery('english', $1) q
WHERE search_doc @@ q
ORDERBY rank DESCLIMIT20;
CREATE EXTENSION pg_trgm;CREATEINDEX articles_title_trgm ON articles USING GIN (title gin_trgm_ops);-- Now a real index can serve both a substring match and a fuzzy match:SELECT title FROM articles WHERE title ILIKE'%datbase%';-- indexableSELECT title, similarity(title,'datbase') s FROM articles
WHERE title %'datbase'ORDERBY s DESC;-- fuzzy, ranked
// BROKEN. Two writes, no atomicity — Module P-16's gap, exactly.await db.query("UPDATE articles SET title = $1 WHERE id = $2",[title, id]);await es.index({ index:"articles", id, document:{ title }});
1. create articles_v7 with the new mapping
2. bulk index everything from the database into articles_v7
3. catch up the changes that occurred during step 2 (the outbox is still running)
4. atomically move the alias `articles` from articles_v6 → articles_v7
5. keep v6 for a day in case you need to move the alias back