23 Search without a search engine
Two ways to give a help center a search box. Same 50,000 articles, same GIN index, same 4,206 matches for “failed payment”, same top 20. One answers in under 5 ms. The other takes 170 ms, because it indexed an expression instead of storing a column, and every match gets parsed again to be ranked.
Search in Postgres is like that: good pieces, each with a cost you only see when you measure it. Most products never need a search engine, and the ones that do can name the reason.
Beyond the B-tree showed the basics: a tsvector column, a GIN index, a @@ match. Here we build text and vector search on one synthetic help center of 50,000 articles (the lab is 22-search.sh). Its embeddings come from no model, so our recall numbers demonstrate trade-offs rather than forecast your data.
Text becomes lexemes
Full-text search never compares your query to your text. It compares normalized words to normalized words. to_tsvector parses a document and keeps lexemes, normalized words, with their positions:
“Deploys” and “deployment” became deploy; “the”, “were”, “so”, and “we” are gone. The 'english' argument is a text search configuration: a parser plus the dictionaries each kind of token goes through.
Stop words and stemming cut both ways
Stemming is suffix rules, not meaning: in english, “organization” matches “organ”. Search for the band The Who and english throws the whole query away as stop words. Stemming helps prose and hurts names and codes, so give each its own configuration: english for text, simple (which only lowercases) for titles, tags, and error codes.
The configuration is part of the index
Always pass the configuration: the one-argument to_tsvector(body) depends on a session setting, so an index refuses it (functions in index expression must be marked IMMUTABLE).
Accents need a configuration of their own. english keeps “Zürich” as zürich, so “zurich” finds nothing. Add the unaccent dictionary from contrib to a copy of the configuration, not as a function call. Ours is app_english, and to_tsvector('app_english', 'Requests from Zürich and Montréal') gives 'montreal':5 'request':1 'zurich':3.
Deep dive: Inside the parser, and why unaccent() can’t be indexed
english_stem is a Snowball stemmer with a 127-word stop-word list, so plainto_tsquery('english', 'to be or not to be') comes back empty. The unaccent() function is only stable (its rules file could change), so an index refuses to_tsvector('english', unaccent(body)) but accepts to_tsvector('app_english', body), trusting the configuration. Keep that promise by hand: change app_english and old rows keep their old vectors until you rewrite them. For Japanese or Chinese, which the built-in parser can’t split into words, see PGroonga.
Prove it · lab 22-search.sh
Store the tsvector; don’t index the expression
Store the vector in a generated column and index the column. Indexing the expression instead looks thriftier and costs you on every search.
Title words are weighted above body words. For comparison, a second table, docs_expr, skips the column and puts a GIN index on the same expression. Say stored: generated columns are virtual by default in PostgreSQL 18, and a virtual column can’t be indexed (Types are a contract).
| Heap pages | GIN index | WAL to load | |
|---|---|---|---|
| expression index | 3,347 | 9,064 KB | 70 MB |
| stored column | 6,766 | 9,360 KB | 95 MB |
The stored column doubles the heap: a tsvector with positions averages 511 bytes, against 380 for the body it came from.
That’s the cost. The payoff shows when you rank the matches. The stored version took 4.6 ms on our machine; the expression version, 170 ms. Either GIN index finds the 4,206 matches in under a millisecond, but ranking needs each match’s tsvector, and the expression version has to parse all 4,206 documents again to get one.
Pay the heap and the WAL once, on write, instead of paying the parser on every search.
Deep dive: Proving the heap, the WAL, and the re-parse
Why the expression version loses: a GIN index on tsvector stores lexemes and row pointers, nothing more, so it can say which rows match but can’t hand ts_rank the vector to score. With no column to read the vector from, the ranking calls to_tsvector on each match’s title and body again, 4,206 times per search. The two GIN indexes come out about the same size; what the stored column adds is the vector itself, in the heap and in the WAL that logged every row of it.
Prove it · lab 22-search.sh
The expression index only wins when you never rank or highlight.
websearch_to_tsquery never raises a syntax error
Never hand what a user typed to to_tsquery: it expects tsquery syntax, so 'failed payment' is a syntax error. websearch_to_tsquery (PostgreSQL 11+) takes search-box syntax and never raises one: '"roll back" -flags or rollback' becomes 'roll' <-> 'back' & !'flag' | 'rollback', and junk like 'payment & ) ( !' becomes 'payment'.
Phrases need positions
Quotes become <->, “followed by”, checked against stored positions: “roll” and “back” both appear in 7,849 articles, but side by side, as a phrase, in only 4,194.
Deep dive: Stop words still count as positions
phraseto_tsquery('english', 'roll back the deploy') produces 'roll' <-> 'back' <2> 'deploy', so “roll back a deploy” matches too. Close enough for search; surprising if you expected an exact string.
Prove it · lab 22-search.sh
Rank by proximity, not frequency
A match is yes or no; ranking orders the yeses. Use ts_rank_cd: it scores cover density, how close together the query’s words are, so “failed payment” as a phrase beats the two words sentences apart. (ts_rank only counts how often they appear.)
Deep dive: Three documents, three rankings
For the query “failed payment”:
The norm 2 column is ts_rank with normalization 2.
ts_rank slightly prefers the short title match; ts_rank_cd prefers four adjacent hits; both bury the third. By default a title hit (weight A) counts two and a half times a body hit (B). The normalization bits divide the rank by something about the document: 2 by its length cut the long article to a quarter of the short one. There’s no right setting, only the one your users agree with.
Prove it · lab 22-search.sh
Ranking reads every match
order by rank limit 20 looks like it should read 20 rows. It can’t. The top 20 are only known once every match is ranked:
The index lookup is 13 pages; fetching all 4,206 matches is the rest, the same 3,283 pages as with no limit. (The 8 ms here includes EXPLAIN ANALYZE’s own overhead; run plain, the same search took the 4.6 ms quoted earlier.) Ranking “deploy”, which appears in 19,507 articles, reads 97 percent of the table. GIN knows which rows contain a lexeme, not how often or where, so it can’t hand back the best first. That’s the scaling limit of built-in full-text search.
So narrow before you rank. In this corpus a tenant owns about 50 articles in all, so a tenant filter leaves a handful of candidates where the whole corpus offered 4,206. Give the filter an index the planner can combine with the GIN scan: a B-tree on account_id for a BitmapAnd, or one combined GIN index through the btree_gin extension. In a multi-tenant app that’s usually the whole fix.
Prove it · lab 22-search.sh
ts_headline parses the document again
Compute snippets last, only for the rows you show. ts_headline parses the whole original document again, every call. A page of 20 took about 5 ms with snippets or without, because the planner computes them after the limit. Snippets for all 4,206 matches took about 150 ms, and that’s the bill an API or view pays when it snippets every match.
The documentation says it’s no defense against cross-site scripting. Escape the result before rendering, or set StartSel and StopSel to markers your template turns into tags after escaping.
Deep dive: Where the plan puts ts_headline
A materialized CTE that computes snippets before ranking defeats this. MaxWords, MinWords, and MaxFragments shape the snippet.
Prove it · lab 22-search.sh
For most applications, Postgres search is enough
Postgres search searches your own content, in the same transaction as the writes, under the same permissions and tenant filters, with nothing to sync. Reach for more when you can name what’s missing: relevance as a product (BM25 scoring that knows how rare a word is across the corpus, boosts, synonyms), typo tolerance on long prose (pg_trgm covers short strings), facets and counts over huge result sets, or many languages in one field.
A separate engine brings a sync pipeline that lags and drifts, a second copy of every permission rule, and no transactions across the two. Start in Postgres. Move when you can show the query a search engine would answer and Postgres can’t.
Search extensions put BM25 in the index
An extension that ranks like a search engine should be judged like one: on your queries and your corpus, not on a feature table. That said, BM25 no longer requires leaving Postgres. Newer extensions keep term frequencies and corpus statistics inside the index, so they return the best k without fetching every match. Four are worth knowing as of October 2026 (none ran in our lab; this comes from each project’s own documentation):
| Extension | Phrase, fuzzy | License | Where it runs |
|---|---|---|---|
| pg_search (ParadeDB) | yes, yes | AGPL-3.0; Enterprise is commercial | self-hosted, ParadeDB Cloud |
| pg_textsearch (Timescale) | yes, no | PostgreSQL | self-hosted, PostgreSQL 17 and 18 |
| VectorChord-bm25 (TensorChord) | no, no | AGPL-3.0 or ELv2 | self-hosted |
| TIN (PlanetScale) | yes, yes | not published | PlanetScale only |
Before you test one, check that your host offers it, that its index survives failover, and how portable the query syntax is (only pg_textsearch keeps tsquery). PlanetScale’s benchmark puts TIN well ahead; it’s a vendor measuring its own product.
Deep dive: The four extensions, one paragraph each
pg_search (0.26.0, PostgreSQL 15+) builds a log-structured merge index on Tantivy, a Lucene-style Rust library. Its docs say the open source edition doesn’t replicate the search index, so search runs only on the primary; replicas for it come with the commercial editions.
pg_textsearch (1.5.1) is BM25 over this chapter’s configurations, ranked by order by content <@> 'failed payment'. Its README flags a limit of any shared BM25 index: corpus statistics count rows hidden by row-level security, so a tenant can infer something about other tenants’ data (Trust no tenant).
VectorChord-bm25 (0.3.0, December 2025) ranks sparse vectors of token counts: no positions, so no phrases, and tested only on English, per its README.
TIN (generally available September 16, 2026) uses the row’s ctid as the document id, so posting lists compress as bitmaps of pages, then tuples. We found no published source or license; Lead (AGPL-3.0) is a development stand-in that calls itself “intentionally unsuitable for production”. We haven’t reproduced the vendor’s benchmark.
A vector search finds near points, whatever the words
An embedding model turns text into an array of floats, placed so that similar inputs land near each other. “Find articles like this question” becomes “find the nearest points”, whatever words they use. pgvector (the lab runs 0.8.7) adds a vector type and distance operators: <=> for cosine distance, what most text models want, plus <-> for Euclidean (L2) distance, <#> for negative inner product, and <+> for L1.
A 1,536-dimension vector lives in TOAST
A common 1,536-dimension embedding is 6,152 bytes (4 per dimension plus 8), three quarters of a page and far past the roughly 2 KB at which Postgres moves a value out of line into TOAST (Large values are compressed, then moved out of the row). Store 2,000 and the table is 15 heap pages of ids and pointers, plus 2,000 TOAST pages. Anything that skips the vector is cheap: count(*) reads 15 pages. Exact search reads every vector chunk by chunk: 12,024 buffers, about six per vector.
Deep dive: Plain storage, halfvec, and bits: where the vector lives
Set the column to storage plain and the 2,000 vectors sit inline, one per heap page; the same search reads 2,003 buffers. That works only while the row fits in a page (2,100 dimensions is refused: “row is too big”). halfvec(1536) (pgvector 0.7+) is 3,080 bytes and 1,000 TOAST pages; a binary_quantize()d bit(1536) is 200 bytes in the heap, and the table gets no TOAST table at all. Both are approximations (halfvec shrinks the index with no measurable recall loss measures their recall).
Prove it · lab 22-search.sh
Exact search is a sequential scan
Don’t reach for an approximate index first. From here on the embeddings are 384 dimensions, in their own doc_embeddings table so the article list doesn’t drag them through the cache. With no index, order by embedding <=> $1 limit 10 scans all 10,000 pages, about 20 ms here. It’s exact, needs no tuning, and for tens of thousands of vectors, or one tenant’s slice, it’s fast enough.
HNSW walks a graph greedily, and can miss
HNSW (Hierarchical Navigable Small World) is a graph. Every vector links to some of its near neighbors; a few also appear on sparser upper layers with longer links, like express stops on a subway line:
A search enters at the top, walks toward the query, and drops a layer, touching a few hundred nodes instead of all of them; it can miss what the greedy walk can’t reach. Give the build memory and workers: 57 s with the default 64 MB of maintenance_work_mem and no parallel workers, 8 s with 256 MB and two. The index, 98 MB, is bigger than the 78 MB table.
ef_search trades latency for recall
At query time one setting matters: hnsw.ef_search, the candidate list (40 by default). Recall@10 is the fraction of the true ten nearest neighbors that came back:
hnsw.ef_search | Recall@10 | ms per query (ours) | Buffers, one query |
|---|---|---|---|
| 40 (default) | 0.969 | 0.7 | 722 |
| 400 | 0.995 | 4.0 | 3,925 |
| exact (no index) | 1.000 | 21 | 10,035 |
At the default, 97 percent of the true neighbors for a fourteenth of the pages. An HNSW build is randomized, so recall moves a few points from one build to the next: across our runs the default gave 0.92 to 0.99, and ef_search 400 anywhere from 0.98 to a full 1.0. That’s what “approximate” means, and why you measure recall on your own data. Raise it per query where the stakes are high (set local hnsw.ef_search = 100), and measure recall on your own data by comparing against exact search for a sample of real queries.
Deep dive: Build settings, and how much recall moves between builds
With 64 MB the build warns hnsw graph no longer fits into maintenance_work_mem after 28369 tuples. Our timing runs started from a serial build (max_parallel_maintenance_workers = 0; the server default allows two). Memory alone (256 MB) took it from 57 s to 23 s; two parallel workers alone, to 17 s; both, to 8 s. Parallel builds keep the graph in shared memory, so in Docker give the container a bigger /dev/shm.
The sweep also measured 10 (0.956, 340 buffers), 20 (0.968), 100 (0.986, 1,684 buffers), and 200 (0.986). Builds aren’t deterministic: pgvector picks each vector’s graph level at random (and setseed doesn’t reach it), and parallel builds insert in a different order every time. Across our runs the default’s recall ranged from 0.923 to 0.992, so the lab checks for 0.9 or better rather than one run’s number.
Prove it · lab 22-search.sh
IVFFlat’s default of one probe is a trap
IVFFlat clusters the vectors at build time into lists groups and searches only the probes lists nearest the query. It builds in under 2 seconds against HNSW’s 8, and its default is a trap: with 50 lists and ivfflat.probes = 1, recall@10 was 0.636. Five probes gave 1.000. Start at sqrt(lists), and build after loading, since the lists come from the rows present. Our default is HNSW.
halfvec shrinks the index with no measurable recall loss
The table can keep 4-byte floats while the index holds something smaller: using hnsw ((embedding::halfvec(384)) halfvec_cosine_ops) shrank the index from 98 MB to 56 MB with no measurable recall loss. Binary quantization, one bit per dimension, made a 17 MB index that found 0.303 of the true neighbors, and 0.915 after re-ranking 400 candidates by the full vector (fetch the 400 nearest by bits, then reorder them exactly). Measure it on your embeddings first.
Deep dive: Re-ranking binary candidates
The bit index is using hnsw ((binary_quantize(embedding)::bit(384)) bit_hamming_ops), and hnsw.ef_search must be at least the inner limit. Re-ranking 100 gave 0.747. Gaussian noise is a hard case for one bit; real models, some trained for it, do much better. Half precision’s 0.980 beat full precision’s 0.969 in this build: graph-building luck.
Prove it · lab 22-search.sh
Filters and approximate indexes don’t mix
With an approximate index, your where runs after the index scan: HNSW finds its ef_search best candidates, Postgres discards the ones that fail the filter, and whatever is left is your answer. kind = 'faq' matches one article in ten. Ask for ten:
Four rows, and over 100 queries an average of 3.7, some none. Your app asked for ten and got four, with no error, which is the worst kind of wrong.
For broad filters, turn on pgvector 0.8’s iterative index scans (set hnsw.iterative_scan = relaxed_order), which keep fetching candidates until the filter is satisfied: ten rows every time, at more than three times the pages. For selective filters, put a B-tree on the filter column: for one tenant’s 46 articles the planner used it and computed 46 exact distances, 83 pages, no HNSW. A partial HNSW index per value, or partitions, cover the rest.
Deep dive: Iterative scans, order, and the tenant plan
With relaxed_order, one query read 2,529 pages against 708, scanning up to hnsw.max_scan_tuples (20,000) candidates. Results may come slightly out of distance order; strict_order keeps it and gets less recall. A filter on a joined table can’t apply during the scan, which is why account_id and kind are copied onto doc_embeddings.
Prove it · lab 22-search.sh
Keywords and vectors fail differently, so fuse them
Full-text nails exact terms (an error code, “SAML”) and misses paraphrase: “card declined” matches nothing in our corpus. Vectors find the paraphrase and blur exact terms.
Their scores share no scale, so combine positions instead: reciprocal rank fusion (RRF) gives each document 1 / (60 + rank) from each list that returned it and sorts by the sum. An article both lists found always wins: with 50 results per list, two shares of at least 1/110 beat the best single share, 1/61. For “card declined”, the vector side fills all ten places.
Pass the query vector as a parameter: distance to a column of a joined row can’t use the HNSW index.
Deep dive: Hybrid search as one SQL function
With the billing topic’s center as the query embedding, the top ten interleave the two lists. On one build of the index the top result ranked 35th in text and 19th in vectors; on another, no article made both top 50s. An HNSW graph differs from build to build, so the vector list does too. The constant 60 comes from the RRF paper, which calls it k; our function’s k is only the number of results.
Prove it · lab 22-search.sh
A vector database needs a reason you can name
Name the property: hundreds of millions of vectors, latency a busy primary can’t meet, filtered search where iterative scans read too much. Below that, vectors next to their rows win: one backup, one permission model, and an embedding that updates in the same transaction as its text.
The short version
- Most products never need a search engine. Move only for a property you can name: BM25 relevance, typo tolerance, facets at scale.
- Pass the text search configuration everywhere.
- Store the
tsvector: ranking through an expression index took 170 ms against 4.6. - Use
websearch_to_tsqueryfor anything a person types. - Ranking reads every match. Narrow by tenant or date first.
- Compute
ts_headlineonly for the rows you show, and escape it. - A 1,536-dimension
vectorlives in TOAST: free to skip, six buffers each to scan. - Exact search first. HNSW gave 0.92 to 0.99 recall at the default, for a fourteenth of the pages.
- Filters run after an approximate scan: turn on
hnsw.iterative_scan, or let a B-tree make a selective filter exact.
Further reading
- Full Text Search, PostgreSQL documentation. The whole subsystem.
- Controlling Text Search, PostgreSQL documentation. Ranking, normalization, and
ts_headline’s cost and XSS warning. - ParadeDB architecture, ParadeDB documentation. A Tantivy index inside Postgres, and what replicates.
- pg_textsearch, source repository. BM25 on Postgres configurations, with its limitations.
- Introducing TIN, PlanetScale blog, September 2026. The
ctid-as-document-id design. - pgvector, pgvector project. The README is the manual.
- Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs, Yu. A. Malkov and D. A. Yashunin. The HNSW paper.
- Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods, Gordon V. Cormack, Charles L. A. Clarke, and Stefan Büttcher, SIGIR 2009. Two pages that introduce RRF.