Key Takeaways
ts_rankwith default arguments performs zero document-length normalization. A 5-of-10 match scores identically to a 5-of-1000 match. This single fact explains the vast majority of “my search ranking is random” complaints on r/PostgreSQL.- Field weights via
setweight(..., 'A'/'B'/'C'/'D')affect scoring only, never matching. And they must be applied to individualto_tsvectorcalls before concatenation — wrapping the whole concatenated string is a silent no-op. - The fourth argument to
ts_rankis a bitmask integer, not a boolean. Passing1versus32produces wildly different orderings, and the docs describe it in a way that makes your eyes glaze over. ts_rankandts_rank_cdare fundamentally different algorithms: one is weighted term frequency, the other is cover density. Pick wrong and your ordering logic diverges from your mental model completely.- Fixing this requires rebuilding the tsvector column or index if you touched weights. I burned three hours on prod last month because I changed the SQL and the scores didn’t budge.
The Symptom: When Search Ranking Looks Like a Random Number Generator
Real story. E-commerce site, ~2M rows in the products table. Ops pings me: “Search ‘wireless bluetooth headset’, the top result is a $3 cable that mentions ‘headset’ once in a 2000-word description. The actual ‘Wireless Bluetooth Headset’ product is sitting at position seven.”
First instinct: the tsquery is malformed. Checked websearch_to_tsquery('simple', 'wireless bluetooth headset'). Clean. Checked plainto_tsquery. Clean. So the query construction is fine — that leaves the ranking.
Pulled the ranking SQL. Looked like this:
SELECT id, title,
ts_rank(search_vec, query) AS rank
FROM products,
websearch_to_tsquery('simple', 'wireless bluetooth headset') query
WHERE search_vec @@ query
ORDER BY rank DESC
LIMIT 20;
There it is. ts_rank(search_vec, query) — the two-argument form — uses default normalization = 0, meaning no length penalty whatsoever. And search_vec was almost certainly built by jamming the entire product description (title + description + tags) into one tsvector.
Result: a 3000-word description where “headset” appears eight times crushes a 50-word description where it appears once. Term frequency wins, document length is ignored. The product whose title literally is the query got buried.
Someone on r/PostgreSQL nailed it in one sentence:
“ts_rank by default completely ignores the document length such that matching 5 words in 10 gives the same rank as matching 5 words in 1000…”
That’s the whole disease. You think it’s doing TF-IDF. It isn’t even looking at document length.
Root Cause: What ts_rank Is Actually Computing
To fix it you have to see what it computes. PostgreSQL’s ts_rank is not BM25, not classic TF-IDF. It’s a bare-bones weighted term-frequency model. Three inputs:
- Term frequency: how many times each query lexeme appears in the tsvector.
- Weights: each lexeme carries a position weight A/B/C/D. The coefficients are
{D, C, B, A} = {0.1, 0.2, 0.4, 1.0}. Note A is the largest. I got this backwards the first time and spent an hour convinced weights weren’t working. - Normalization method: a bitmask controlling post-processing.
The critical point: document length is only considered if you set the normalization bitmask. Default is 0, which means no processing at all.
Here’s the normalization bitmask (the table in official docs section 12.3.4, described in a way nobody can parse):
| Bit | Meaning | Actual Effect |
|---|---|---|
| 1 | log(document length) | Divides by log of total word count; gently suppresses long docs |
| 2 | document length | Divides by raw word count; harshest penalty |
| 4 | mean harmonic distance | Rewards terms that appear close together |
| 8 | unique word count | Divides by number of distinct lexemes |
| 16 | max of all normalizations | Takes the max of the above |
| 32 | rank/(rank+1) | Compresses score into 0–1 range |
These can be OR’d together. 32 | 1 = 33 means log-length normalization followed by compression to 0–1. Passing 16 means “take the max,” not “take the average” — a frequent misread.
We ran a controlled test internally. Same data, same query, only normalization changed:
-- 3 products with wildly different doc lengths
SELECT id,
ts_rank(search_vec, query, 0) AS n0,
ts_rank(search_vec, query, 1) AS n1,
ts_rank(search_vec, query, 2) AS n2,
ts_rank(search_vec, query, 32) AS n32,
ts_rank_cd(search_vec, query) AS cd
FROM products, websearch_to_tsquery('simple','bluetooth headset') query
WHERE search_vec @@ query;
| id | doc words | n0 (default) | n1 (log len) | n2 (len) | n32 (0–1) | cd (cover density) |
|---|---|---|---|---|---|---|
| 1 | 12 | 0.0607 | 0.0244 | 0.0050 | 0.0572 | 0.1000 |
| 2 | 480 | 0.0607 | 0.0095 | 0.0001 | 0.0572 | 0.0208 |
| 3 | 2100 | 0.0607 | 0.0079 | 0.00002 | 0.0572 | 0.0048 |
Look at the n0 column — all three identical. That’s the mathematical truth behind “the ranking looks random.” Now n1: the short doc at 0.0244 is triple the long doc at 0.0079. Ordering snaps into place. cd is even more aggressive — a 20x spread.
Root cause in one sentence: default normalization=0 degrades ts_rank into a pure term counter, discarding the single most important signal — document length.
Numbered Diagnostic and Fix Steps
Step 1: Confirm what’s actually in your tsvector
Never assume weights landed. Dump it.
SELECT id,
search_vec
FROM products
WHERE id = 12345;
Output like 'bluetooth':1A 'headset':2A 'cable':15D means weights are in. Bare lexemes with no letter suffix, or everything tagged D, means the build step is broken.
The classic mistake:
-- WRONG: setweight applied outside to_tsvector, weights lost during string concat
UPDATE products
SET search_vec = to_tsvector('simple', title || ' ' || description);
Correct form — each field gets its own to_tsvector, its own setweight, then concatenate tsvectors:
UPDATE products
SET search_vec =
setweight(to_tsvector('simple', coalesce(title,'')), 'A') ||
setweight(to_tsvector('simple', coalesce(tags,'')), 'B') ||
setweight(to_tsvector('simple', coalesce(description,'')), 'C');
Devil in the details: the second argument to setweight must be a single uppercase char 'A' through 'D'. Lowercase 'a' fails silently — the weight defaults to D. I’ve caught this exact bug in code review three times.
Step 2: Use a generated column for consistency
Hand-written UPDATEs will drift. On Postgres 12+, use a generated column:
ALTER TABLE products
ADD COLUMN search_vec tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('simple', coalesce(title,'')), 'A') ||
setweight(to_tsvector('simple', coalesce(tags,'')), 'B') ||
setweight(to_tsvector('simple', coalesce(description,'')), 'C')
) STORED;
CREATE INDEX idx_products_search_vec
ON products USING GIN (search_vec);
Watch out: once a generated column is defined, changing weights means DROP and rebuild, which locks the table. On a 2M-row table, build the index separately with CREATE INDEX CONCURRENTLY. Don’t bundle it into the ALTER.
Step 3: Pick the right normalization value
This is the core fix. My rules of thumb:
- Title-first site search →
normalization = 1(log length). Gentle, preserves weight differentiation. - Extreme length variance (blogs, forums) →
normalization = 2. Harshest penalty. - Need a 0–1 score for the frontend →
32 | 1 = 33. - Need term-distance sensitivity → switch to
ts_rank_cd.
SELECT id, title,
ts_rank(search_vec, query, 1) AS rank
FROM products,
websearch_to_tsquery('simple', 'wireless bluetooth headset') query
WHERE search_vec @@ query
ORDER BY rank DESC
LIMIT 20;
Step 4: Prove the fix with an A/B comparison
Don’t hand-wave “it’s fixed.” Print both rankings side by side:
WITH q AS (SELECT websearch_to_tsquery('simple','wireless bluetooth headset') AS query)
SELECT p.id, p.title,
ts_rank(p.search_vec, q.query, 0) AS old_rank,
ts_rank(p.search_vec, q.query, 1) AS new_rank
FROM products p, q
WHERE p.search_vec @@ q.query
ORDER BY new_rank DESC
LIMIT 50;
After our comparison, the target product jumped from position 7 to position 1, with new_rank 2.8x old_rank. Numbers, not vibes.
Step 5: If still wrong, try ts_rank_cd
ts_rank_cd’s CD stands for “cover density” — it rewards query terms appearing close together. Searching “wireless bluetooth headset,” a title containing the contiguous “Wireless Bluetooth Headset” scores extremely high even with a single occurrence.
SELECT id, title,
ts_rank_cd(search_vec, query, 32) AS rank
FROM products,
websearch_to_tsquery('simple','wireless bluetooth headset') query
WHERE search_vec @@ query
ORDER BY rank DESC
LIMIT 20;
The cost: ts_rank_cd is more expensive. On our 2M rows, P99 went from 41ms to 68ms. Acceptable, but don’t blindly use it on huge result sets.
Step 6: Add a stable tiebreaker
Equal scores return in nondeterministic order. Add a tiebreaker:
ORDER BY rank DESC, p.created_at DESC, p.id DESC
Architecture View: The Full Ranking Data Flow
flowchart TD
A[User input: wireless bluetooth headset] --> B[websearch_to_tsquery 'simple']
B --> C{tsquery}
D[products table] --> E[title field]
D --> F[tags field]
D --> G[description field]
E --> H[to_tsvector 'simple' + setweight A]
F --> I[to_tsvector 'simple' + setweight B]
G --> J[to_tsvector 'simple' + setweight C]
H --> K[tsvector concatenation]
I --> K
J --> K
K --> L[(GIN index)]
C --> M[@@ match filter]
L --> M
M --> N[ts_rank or ts_rank_cd + normalization]
N --> O[ORDER BY rank DESC + tiebreaker]
O --> P[Return TOP N]
Key insight: weights are baked in at tsvector build time. ts_rank merely reads them. So changing weights requires rebuilding the column. But normalization is computed at query time — the only zero-cost tuning knob in the whole chain.
Performance and Cost: The Numbers Nobody Tells You
On our 8-core / 32GB RDS instance, measured:
| Approach | 2M-row query P99 | Index size | Ordering relevance (manual eval) |
|---|---|---|---|
| ts_rank, norm=0 | 38ms | 412MB | Bad (near-random) |
| ts_rank, norm=1 | 41ms | 412MB | Good |
| ts_rank, norm=2 | 40ms | 412MB | Good, but dilutes title weight |
| ts_rank_cd, norm=32 | 68ms | 412MB | Best |
| External ElasticSearch | 12ms | Separate cluster | Best, but ops overhead |
See it? Going from normalization 0 to 1 costs +3ms and transforms ordering quality. Highest ROI tuning you’ll ever do.
As for ElasticSearch — my stance is firm: under ~10M rows with non-exotic query patterns, Postgres FTS is plenty. Don’t introduce a distributed system that needs dedicated ops because of a ranking bug. That 229-point HN thread “Tin: full-text search for Postgres” makes the same argument — Postgres FTS is chronically underrated.
Alternatives and Trade-offs
- pg_trgm + GIN: Good for fuzzy matching and typo tolerance, bad for multi-term relevance ranking. It’s “can I find it,” not “can I order it.”
- ParadeDB / pg_search: The closest thing to real BM25 in the Postgres ecosystem. If you need search-engine-grade relevance, this beats hand-rolling ts_rank. Downside: extension install; managed RDS may not support it.
- ElasticSearch / OpenSearch: Most capable — BM25, vectors, aggregations. Cost is ops complexity, sync latency, and a separate cost center.
- Application-layer reranking: Coarse-filter TOP 500 in Postgres, rerank in app with business signals (sales, ratings). Common in e-commerce; flexible but adds a round trip.
My recommendation: fix normalization first. I’ve watched too many teams shout “we need ES” only to discover 80% of the problem was that 0 in ts_rank(..., 0).
References & Community Insights
- PostgreSQL docs 12.3 “Controlling Text Search”, including the normalization bitmask table: https://www.postgresql.org/docs/current/textsearch-controls.html
- PostgreSQL docs 12.4 “Additional Features”, covering
setweightand weight coefficients: https://www.postgresql.org/docs/current/textsearch-features.html - The classic r/PostgreSQL rant on ts_rank ignoring document length: https://www.reddit.com/r/PostgreSQL/comments/1cyqjqp/down_the_rabbit_hole_with_full_text_search/
- Hacker News 229-point thread “Tin: full-text search for Postgres”: https://news.ycombinator.com/item?id=41673340
- ParadeDB pg_search extension (BM25 on Postgres) repo: https://github.com/paradedb/paradedb
FAQ
Q: How do I perform full-text search in PostgreSQL?
Three steps: ① to_tsvector('config', text) to convert a document to tsvector; ② websearch_to_tsquery (best user-input tolerance) to build a tsquery; ③ match with @@ and a GIN index. Rank with ts_rank or ts_rank_cd, always specifying normalization explicitly.
Q: Which is better for full-text search, Postgres or ElasticSearch? Under ~10M rows, relevance-first query patterns, and no dedicated search ops team — Postgres. Need vector search, complex aggregations, multi-language tokenization, or billions of rows — ElasticSearch. Don’t switch systems over a ranking bug; check your normalization parameter first.
Q: Does NASA use PostgreSQL? Yes. Several NASA ground systems use PostgreSQL as their backend. But this is irrelevant to your selection decision — pick based on workload, not on who else uses it.
Q: How can I check if full-text search is enabled?
PostgreSQL ships FTS natively with nothing to enable. Verify with SELECT to_tsvector('english', 'hello world'); — any output means it works. List installed configurations with SELECT * FROM pg_ts_config;. SQL Server is a different story: SELECT SERVERPROPERTY('IsFullTextInstalled');.