Module 3: Full-Text Search + pg_trgm — a quality search engine without Elasticsearch
Ranking with `ts_rank` and `ts_rank_cd`: ordering by relevance, not by date
Capsule description
Your FTS works and it's fast. The user searches "python fastapi" and you get 50 posts that match. You return them ordered by id DESC and the first result is "My lemon chicken recipe with a note about Python that I published in May" — because it happens to have both words somewhere and it's the most recent. The user assumes your search engine doesn't work.
The problem isn't the match. It's the order. PostgreSQL FTS gives you two functions for ranking results by relevance instead of by date or id: ts_rank (which counts weight-adjusted frequency) and ts_rank_cd (which considers the proximity of the terms, "cover density"). This capsule teaches you the difference between the two, when to choose each one, and how to combine them with the setweight weights you set up in capsule 04 so that a match in the title weighs more than a match in the body.
By the end you'll be able to write search endpoints that return the right result in the first position — the one that actually answers the user's intent, not the most recent one that happens to mention the terms.
Mental model: relevance is the inverse of "how noisy the match is"
To understand ranking in FTS, think about what makes a document "more relevant" for a query. Three typical signals:
-
Frequency. A document that mentions "python" 8 times is more about python than one that mentions it once. But long documents mention everything more times, so frequency is normalized by length.
-
Position / section. If "python" shows up in the title, it's central to the document. If it shows up in a footnote, it's tangential. This is modeled with weights (A for title, B for body, etc.).
-
Proximity. If the query is "python fastapi", a document where they appear right next to each other ("Python con FastAPI") is more relevant than one where they appear 50 paragraphs apart. This is modeled with cover density.
PostgreSQL offers two functions that combine these signals in different ways:
┌──────────────────────────────────────────────────────────────────┐
│ │
│ ts_rank(tsv, query, [normalization]) │
│ ──────────────────────────────────── │
│ - Considers weight-adjusted frequency. │
│ - Does NOT consider term proximity. │
│ - Faster. │
│ - Appropriate: when the query has few terms │
│ and they're all separate, or when only frequency matters. │
│ │
│ ts_rank_cd(tsv, query, [normalization]) │
│ ────────────────────────────────────── │
│ - Cover Density: the distance between the matches. │
│ - Matches close together → high score. │
│ - Matches far apart → low score. │
│ - It also considers weights. │
│ - Appropriate: multi-term queries where proximity │
│ is a relevance signal (the recommended default). │
│ │
└──────────────────────────────────────────────────────────────────┘
Both functions return a float4 (typically between 0 and ~10, not normalized). The absolute values aren't comparable across queries; what matters is the relative order within the results of a single query.
Default rule: ts_rank_cd. Proximity almost always feels more natural to the user. ts_rank is reserved for specific cases where you know only frequency matters to you.
ts_rank: weight-adjusted frequency
ts_rank(tsvector, tsquery) counts how many times the query's terms appear in the tsvector, adjusted by the weights assigned with setweight.
SELECT
ts_rank(
setweight(to_tsvector('spanish_unaccent', 'Python para principiantes'), 'A') ||
setweight(to_tsvector('spanish_unaccent', 'Aprende Python con muchos ejemplos. Python es genial. Python facilita el desarrollo.'), 'B'),
websearch_to_tsquery('spanish_unaccent', 'python')
) AS rank;
Output:
rank
-----------
0.7109370
If you compare two documents:
SELECT
-- Doc 1: "python" in the title (weight A) + once in the body
ts_rank(
setweight(to_tsvector('spanish_unaccent', 'Python para principiantes'), 'A') ||
setweight(to_tsvector('spanish_unaccent', 'Aprende a programar con Python.'), 'B'),
websearch_to_tsquery('spanish_unaccent', 'python')
) AS doc1_rank,
-- Doc 2: no "python" in the title, 5 times in the body
ts_rank(
setweight(to_tsvector('spanish_unaccent', 'Programación moderna'), 'A') ||
setweight(to_tsvector('spanish_unaccent', 'Python es popular. Python es flexible. Python crece rápido. Aprende Python ya. Python te conviene.'), 'B'),
websearch_to_tsquery('spanish_unaccent', 'python')
) AS doc2_rank;
Typical output:
doc1_rank | doc2_rank
-----------+-----------
0.6687 | 0.3559
Doc 1 (with "python" in the title, weight A) wins, even though doc 2 has the word five times in the body. Weight A is worth more than frequency. That's what you want.
Relative weights
By default the weights {A, B, C, D} map to {1.0, 0.4, 0.2, 0.1}. You can change those values by passing an array of 4 floats as the third argument:
ts_rank(
'{0.1, 0.2, 0.4, 1.0}', -- D, C, B, A respectively (inverted order)
tsv,
query
)
For 95% of cases the default is fine. Only adjust it if you have evidence that the ranking feels "off" for your specific corpus.
Normalization
ts_rank accepts a fourth argument (normalization, an integer flag) that controls how the rank is normalized by document length, number of unique matches, etc. It's bitwise:
| Value | Effect |
|---|---|
| 0 (default) | No normalization |
| 1 | Divides by log(document length) |
| 2 | Divides by document length |
| 4 | Divides by the harmonic mean of the distances between matches |
| 8 | Divides by the number of unique words |
| 16 | Divides by log(number of unique words) |
| 32 | Divides by (rank + 1) — useful for keeping the rank between 0 and 1 |
For general use, ts_rank(tsv, query, 32) gives you scores between 0 and 1, easy to compare and show in a UI.
ts_rank_cd: cover density (proximity)
ts_rank_cd (cover density) considers how close the query's terms are in the document. Two consecutive terms rank higher than two terms separated by 50 words.
A clear example:
SELECT
-- Doc 1: "python fastapi" right next to each other
ts_rank_cd(
to_tsvector('spanish_unaccent', 'Aprende a usar Python con FastAPI desde cero'),
websearch_to_tsquery('spanish_unaccent', 'python fastapi')
) AS doc1,
-- Doc 2: "python" and "fastapi" very far from each other
ts_rank_cd(
to_tsvector('spanish_unaccent',
'Python es un lenguaje versátil. ' || repeat('Tiene muchos frameworks. ', 50) ||
'Uno de ellos es FastAPI.'),
websearch_to_tsquery('spanish_unaccent', 'python fastapi')
) AS doc2;
Typical output:
doc1 | doc2
----------+----------
0.0500 | 0.0006
Doc 1 (the words adjacent) wins by nearly two orders of magnitude. ts_rank_cd captured the proximity: in doc 2 the terms are separated by 50 filler sentences and the score collapses. ts_rank wouldn't make that distinction — it would only count that both documents have the two terms.
When to use ts_rank_cd:
- A typical 2-5 word query where proximity indicates intent.
- Conversational searches ("como aprender python", "cuál es el mejor framework").
- The safe default for a generic search engine.
When to use ts_rank:
- A single-term query (
python). Proximity doesn't apply with a single term —ts_rank_cdandts_rankbehave similarly, butts_rankis slightly faster. - Catalogs where repetition is a pure signal (e.g., products tagged with keywords).
- Backwards compatibility with legacy systems that use
ts_rank.
Combining weights + ranking: the canonical pattern
The pattern you're going to use 95% of the time:
- The generated
tsvcolumn has the title at weight A and the body at weight B (what you set up in capsule 04). - The query uses
ts_rank_cd(tsv, query)in theORDER BY DESC. - Matches in the title automatically rise to the top because their weight is 1.0 vs the body's 0.4.
SELECT
id,
title,
ts_rank_cd(tsv, websearch_to_tsquery('spanish_unaccent', 'python fastapi')) AS rank
FROM posts
WHERE tsv @@ websearch_to_tsquery('spanish_unaccent', 'python fastapi')
ORDER BY rank DESC
LIMIT 20;
Expected output on a table with diverse data:
id | title | rank
-----+-------------------------------------------+----------
142 | Python con FastAPI: tutorial completo | 0.18
87 | FastAPI vs Flask: comparativa práctica | 0.12
44 | Python para web: guía de frameworks | 0.09
201 | Mi blog con FastAPI | 0.06
...
The first one is the most relevant (both words in the title, weight A, together → high cover density + high weight). The following ones degrade progressively.
The complete pattern in a FastAPI endpoint
# api.py
from fastapi import FastAPI, Query
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from models import Post
engine = create_async_engine(
"postgresql+asyncpg://postgres:postgres@localhost:5432/blog"
)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
app = FastAPI()
TS_CONFIG = "spanish_unaccent"
@app.get("/search")
async def search(q: str = Query(..., min_length=1, max_length=200)) -> dict:
"""FTS search ranked by relevance (cover density + weights)."""
async with SessionLocal() as session:
tsquery = func.websearch_to_tsquery(TS_CONFIG, q)
rank = func.ts_rank_cd(Post.tsv, tsquery).label("rank")
stmt = (
select(Post.id, Post.title, rank)
.where(Post.tsv.bool_op("@@")(tsquery))
.order_by(desc(rank))
.limit(20)
)
result = await session.execute(stmt)
rows = result.all()
return {
"query": q,
"count": len(rows),
"results": [
{"id": r.id, "title": r.title, "rank": float(r.rank)}
for r in rows
],
}
Generated SQL:
SELECT
posts.id,
posts.title,
ts_rank_cd(posts.tsv, websearch_to_tsquery('spanish_unaccent', $1)) AS rank
FROM posts
WHERE posts.tsv @@ websearch_to_tsquery('spanish_unaccent', $1)
ORDER BY rank DESC
LIMIT 20;
Note: websearch_to_tsquery(...) gets called twice in the SQL (once in the SELECT and once in the WHERE). PostgreSQL optimizes this internally — the plan reuses the result. Don't worry about the "redundancy."
ts_headline: highlighted snippets for the UI
Ranking orders things well, but the user only sees the title in the results list. You also want to show a fragment of the body with the searched word highlighted — like Google. That's what ts_headline is for.
SELECT
id,
title,
ts_headline(
'spanish_unaccent',
body,
websearch_to_tsquery('spanish_unaccent', 'python fastapi'),
'StartSel=<mark>, StopSel=</mark>, MaxFragments=2, MinWords=10, MaxWords=25'
) AS snippet,
ts_rank_cd(tsv, websearch_to_tsquery('spanish_unaccent', 'python fastapi')) AS rank
FROM posts
WHERE tsv @@ websearch_to_tsquery('spanish_unaccent', 'python fastapi')
ORDER BY rank DESC
LIMIT 10;
Example output:
id | title | snippet
-----+------------------------------------+-------------------------------------------------
142 | Python con FastAPI: tutorial... | ...vamos a usar <mark>Python</mark> con
<mark>FastAPI</mark> para construir un...
ts_headline trims a fragment around the matches and wraps them in the tags you ask for. The most useful options:
StartSel,StopSel: the opening/closing tags for the matches (default<b>and</b>).MaxFragments: how many fragments to return (default 0 = the whole document with the matches highlighted).MinWords,MaxWords: the size of each fragment.FragmentDelimiter: the separator between fragments whenMaxFragments > 1(default" ... ").
An important caution with ts_headline:
ts_headline operates on the original body, not on the tsvector. That means PostgreSQL calls ts_headline for every row in the result and processes the body to find the matches and build the snippet. It's expensive.
Good practices:
- Call
ts_headlineonly in the finalLIMIT. Don't put it in subqueries that get evaluated for every row. - If your corpus has very long bodies (>10KB), consider limiting the body you pass to
ts_headline:
ts_headline(
'spanish_unaccent',
left(body, 5000), -- truncate to the first 5KB for the snippet
websearch_to_tsquery('spanish_unaccent', 'python')
) AS snippet
That reduces the processing at the price of the snippet only being able to come from the first chunk of the body. For most blogs the relevant match is near the beginning.
- If your UI doesn't show snippets, don't ask for them. It's free work the DB doesn't need to do.
Worked example: a complete search endpoint with ranking + snippets
Let's put it all together into a production-ready endpoint.
# search_api.py
from typing import Any
from fastapi import FastAPI, Query
from pydantic import BaseModel
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from models import Post
engine = create_async_engine(
"postgresql+asyncpg://postgres:postgres@localhost:5432/blog"
)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
app = FastAPI()
TS_CONFIG = "spanish_unaccent"
HEADLINE_OPTIONS = (
"StartSel=<mark>, StopSel=</mark>, "
"MaxFragments=2, MinWords=8, MaxWords=20, "
"FragmentDelimiter= ... "
)
class SearchResult(BaseModel):
id: int
title: str
snippet: str
rank: float
class SearchResponse(BaseModel):
query: str
count: int
results: list[SearchResult]
@app.get("/search", response_model=SearchResponse)
async def search(
q: str = Query(..., min_length=1, max_length=200),
limit: int = Query(20, ge=1, le=100),
) -> SearchResponse:
"""
FTS search with cover density ranking and highlighted snippets.
Uses the GIN index over the generated tsv column.
"""
async with SessionLocal() as session:
tsquery = func.websearch_to_tsquery(TS_CONFIG, q)
rank = func.ts_rank_cd(Post.tsv, tsquery).label("rank")
snippet = func.ts_headline(
TS_CONFIG,
Post.body,
tsquery,
HEADLINE_OPTIONS,
).label("snippet")
stmt = (
select(Post.id, Post.title, snippet, rank)
.where(Post.tsv.bool_op("@@")(tsquery))
.order_by(desc(rank))
.limit(limit)
)
result = await session.execute(stmt)
rows = result.all()
return SearchResponse(
query=q,
count=len(rows),
results=[
SearchResult(
id=r.id,
title=r.title,
snippet=r.snippet,
rank=float(r.rank),
)
for r in rows
],
)
Example curl:
curl 'http://localhost:8000/search?q=python+fastapi'
Typical response:
{
"query": "python fastapi",
"count": 4,
"results": [
{
"id": 142,
"title": "Python con FastAPI: tutorial completo",
"snippet": "...vamos a usar <mark>Python</mark> con <mark>FastAPI</mark> para construir un buscador full-text...",
"rank": 0.187
},
{
"id": 87,
"title": "FastAPI vs Flask: comparativa práctica",
"snippet": "...elegir entre <mark>FastAPI</mark> y Flask depende de tu stack actual de <mark>Python</mark>...",
"rank": 0.122
},
{
"id": 44,
"title": "Python para web: guía de frameworks",
"snippet": "...los frameworks más usados en <mark>Python</mark> moderno incluyen Django, Flask y <mark>FastAPI</mark>...",
"rank": 0.091
}
]
}
Query plan (EXPLAIN ANALYZE):
Limit (cost=425.30..425.81 rows=20 width=...)
-> Sort (cost=425.30..425.55 rows=100 width=...)
Sort Key: (ts_rank_cd(...)) DESC
-> Bitmap Heap Scan on posts
Recheck Cond: (tsv @@ websearch_to_tsquery(...))
-> Bitmap Index Scan on idx_posts_tsv
Index Cond: (tsv @@ websearch_to_tsquery(...))
Planning Time: 0.4 ms
Execution Time: 23 ms
23ms for 100k posts with Spanish FTS + ranking + snippets. That's production-ready.
Optimization: filter first, rank afterwards with a CTE
For large corpora (>1M rows), ts_rank_cd can be expensive because it's computed over every row that matches, not just over the LIMIT. If your query matches 50k rows and you only want the top 20, you still compute the rank for 50k.
Optimization: a CTE that filters first, then ranks only the subset.
WITH matches AS (
SELECT id, title, body, tsv
FROM posts
WHERE tsv @@ websearch_to_tsquery('spanish_unaccent', 'python fastapi')
LIMIT 1000 -- a ceiling so we don't rank thousands of matches
)
SELECT
id,
title,
ts_rank_cd(tsv, websearch_to_tsquery('spanish_unaccent', 'python fastapi')) AS rank,
ts_headline('spanish_unaccent', body, websearch_to_tsquery('spanish_unaccent', 'python fastapi'),
'StartSel=<mark>, StopSel=</mark>, MaxFragments=2') AS snippet
FROM matches
ORDER BY rank DESC
LIMIT 20;
Trade-off: if the CTE's LIMIT is too low, you can lose highly ranked results that are "further down" in the index's scan order. For consumer queries, 1000 candidates is usually enough — the truly relevant matches almost always fall in the top 1000 by GIN's construction.
For the first versions of a search engine, you probably don't need this optimization. Apply it when you measure that ts_rank_cd is the bottleneck.
Why does this matter in real work?
1. It's the difference between "my search works" and "my search is useful." Without ranking, users see random results. With ranking, they see the right result in first place. That's the metric that matters: "click-through-rate of the first result." If it's high, your ranking works.
2. It lets you have a grounded opinion in product discussions. When someone says "the search doesn't find X", you can review the ranking and diagnose: does the document match? what rank does it have? which documents rank above it? That's concrete debugging, not opinion.
3. The "ts_rank vs ts_rank_cd" decision is debatable and you defend it with criteria. Your tech lead asks "why did you use cover density?". You answer: "because most queries have 2-3 terms and proximity is a relevance signal. For single-term queries the cost is marginal and the benefit is consistency. If we wanted to optimize the last 5% of performance, ts_rank would be slightly faster but we'd lose the proximity signal." That's senior work.
4. ts_headline is the visual difference between a "Google-style" search engine and a "year-2002-style" one. Showing the searched word highlighted in a contextual fragment is what makes the results list feel modern. Without it, the user has to open every link to see what it's about. With it, the user decides in the list itself.
Traps and common mistakes
Mistake 1 (conceptual): treating the rank as an absolute score
Symptom: you try to "show results with rank > 0.5" or "the ranks look weird, they should be between 0 and 1."
Why it happens: the absolute values of ts_rank and ts_rank_cd aren't comparable across queries. A query with a very frequent term can have low ranks in absolute terms but the relative ones are correct. A query with a rare term can have high ranks.
How to tell: look at two different queries — one with "python" (common) and another with "kubernetes" (rarer). The rank ranges will be different. There's no "rank > X = relevant."
Fix: the rank is for ordering, not for filtering. Use ORDER BY rank DESC LIMIT N. If you want to normalize, pass the 32 flag to ts_rank (rank between 0 and 1) and understand that even so the value is relative within the query.
Mistake 2 (practical): calling ts_headline over the whole body of many rows
Symptom: the endpoint takes 2 seconds to return 100 results with snippets. Without snippets, it takes 30ms.
Why it happens: ts_headline operates on the raw body, not on the tsvector. For each row, it walks the body looking for matches. For 100 bodies of 5KB each, that's 500KB of processing per request.
Fix:
a) Apply the LIMIT before the headline. In SQLAlchemy:
# Bad: headline computes for all the matches before the limit
stmt = select(Post.id, snippet).where(...).order_by(desc(rank)).limit(20)
# Good: subquery with a limit, then headline only on the 20
subq = (
select(Post.id, Post.body, rank)
.where(...)
.order_by(desc(rank))
.limit(20)
.subquery()
)
stmt = select(
subq.c.id,
func.ts_headline(TS_CONFIG, subq.c.body, tsquery, HEADLINE_OPTIONS).label("snippet"),
)
PostgreSQL generally applies the LIMIT before the SELECT when it can, but being explicit with a subquery guarantees the order.
b) Truncate the body for the headline. ts_headline(TS_CONFIG, left(body, 5000), tsquery, ...). You lose the possibility of snippets from the end of the body, but you gain a lot of performance.
c) Don't ask for snippets if the UI doesn't show them. It sounds obvious, but it happens: you copy/paste from one endpoint to another and you dragged the ts_headline along even though the new UI only shows the title.
Mistake 3 (conceptual): using ts_rank by default without understanding what ts_rank_cd does
Symptom: you follow a tutorial that uses ts_rank and you copy it without thinking. Then your search returns results where "python fastapi" as an adjacent pair ranks the same as separated by 50 paragraphs.
Why it happens: many tutorials are old or copied from English docs that don't go deep into the difference. ts_rank is the first function that shows up in the docs and many people don't read further.
Fix: understand the difference and choose consciously. For 95% of cases, ts_rank_cd. That decision, justified with a comment in the code, sets you apart from copy-paste:
# We use ts_rank_cd (cover density) because the proximity of the terms
# is a strong relevance signal for the user's multi-term queries.
# Basic ts_rank ignores proximity and ranks adjacent vs separated matches the same.
rank = func.ts_rank_cd(Post.tsv, tsquery).label("rank")
Mistake 4 (practical): the ORDER BY rank doesn't use the GIN index for ordering
Symptom: your query has ORDER BY ts_rank_cd(...) DESC LIMIT 20. You expected the GIN index to help with the ordering. The plan says "Sort" after the Bitmap Heap Scan.
Why it happens: the GIN index has the lexemes and the rows that contain them, but it doesn't have the ranks pre-computed (because the rank depends on the query of the moment). PostgreSQL filters with the index, does a bitmap heap scan to read the rows, computes ts_rank_cd for each resulting row, and then sorts.
This is expected and correct. There's no way to pre-compute the ranks because they depend on the query.
What you can do:
- Limit the rows that enter the Sort (with a CTE like I showed earlier).
- If your corpus allows pre-ranking by a fixed metric (popularity, votes, recency), you can combine:
ORDER BY (ts_rank_cd(tsv, q) * 0.7 + popularity * 0.3) DESC.
Mistake 5 (conceptual): thinking that ts_rank_cd always needs weights
Symptom: someone tells you "ts_rank_cd doesn't work if you didn't use setweight." You get confused and start setting weights on all your columns.
Why it's confusing: ts_rank_cd uses weights if they're present. If they aren't, it assumes everything is at weight D (the lowest) — but the relative rank between documents still works because all the rows are at the same default weight.
The truth: ts_rank_cd works without setweight. But if you want matches in the title to weigh more than matches in the body, you need setweight in your generated column to distinguish the sections. Without weights, there's no way for the rank to know which part of the document contains the match.
Rule: if your table has a single "search field" (the whole body), you don't need weights. If it has several (title, body, tags, etc.) and you want some to weigh more, you do.
Mistake 6 (practical): ts_headline breaks the body's existing HTML
Symptom: your body has HTML (it's a blog post). ts_headline wraps the matches with <mark> but the resulting snippet mixes in <p>, <a>, etc. Rendering it is ugly.
Why it happens: ts_headline operates on plain text. It doesn't know how to tell HTML tags from words.
Fix:
a) Sanitize the body before passing it to ts_headline. If you store the body as HTML, consider also storing a "plain text" version in another column and using that for the headline. Example:
ALTER TABLE posts ADD COLUMN body_plain TEXT
GENERATED ALWAYS AS (regexp_replace(body, '<[^>]+>', '', 'g')) STORED;
(A simple regex, not a perfect one — for complex HTML use a plpgsql function or sanitize in the app.)
b) Sanitize the snippet in the app before showing it. This is the most common one: the snippet from the DB is "best effort" and the UI cleans it with a library like bleach or DOMPurify, allowing only <mark>.
Exercises
Exercise 1: predict the order of the results
You have this table with 4 posts (the tsv already has the title at weight A and the body at weight B):
| id | title | body |
|---|---|---|
| 1 | "FastAPI tutorial" | "FastAPI es un framework moderno." |
| 2 | "Python para principiantes" | "Aprende Python con muchos ejemplos. FastAPI es uno de los frameworks que vas a ver." |
| 3 | "Frameworks web modernos" | "Hablamos de Python, FastAPI, Flask, Django." |
| 4 | "Mi viaje a Bali" | "Aprendí algo de Python y FastAPI durante el avión, pero más tiempo nadando." |
For the query "python fastapi", order the 4 posts by ts_rank_cd (from most ranked to least). Justify it.
See solution
First, who matches. The query "python fastapi" goes through websearch_to_tsquery, which joins the terms with an implicit AND: 'python' & 'fastapi'. Both are required.
- Post 1 ("FastAPI tutorial" / "FastAPI es un framework moderno.") has
fastapibut doesn't havepythonanywhere. It doesn't match. It's out of the result set, with rank0.
The other three do match. Now, the order:
| id | rank | why |
|---|---|---|
| 3 | 0.4 | "Hablamos de Python, FastAPI, Flask, Django." The two terms are adjacent (only a comma separates them). Maximum cover density. |
| 4 | 0.2 | "algo de Python y FastAPI durante el avión". Adjacent except for a "y" in between. High cover density, but one position worse than post 3. |
| 2 | 0.1 | Python is in the title (weight A), but FastAPI shows up a full sentence later in the body. The terms are far apart → low cover density. |
Final order: 3 > 4 > 2. (Post 1 excluded.)
The lesson that stings (and that's why it's worth it): post 2 is the only one with a term in the title, at weight A — and it still comes last. ts_rank_cd is cover density: the distance between the query's terms dominates the score. Weight influences it, but it doesn't rescue a document where the terms are scattered.
If your product needs the title to override proximity, ts_rank_cd isn't the function: use ts_rank (which does reward weight-adjusted frequency and ignores distance), or combine the two. This exercise is exactly the case where the choice between one and the other changes the first result the user sees.
To verify in your local database:
SELECT id, title,
ts_rank_cd(
setweight(to_tsvector('spanish_unaccent', title), 'A') ||
setweight(to_tsvector('spanish_unaccent', body), 'B'),
websearch_to_tsquery('spanish_unaccent', 'python fastapi')
) AS rank
FROM (
VALUES
(1, 'FastAPI tutorial', 'FastAPI es un framework moderno.'),
(2, 'Python para principiantes', 'Aprende Python con muchos ejemplos. FastAPI es uno de los frameworks que vas a ver.'),
(3, 'Frameworks web modernos', 'Hablamos de Python, FastAPI, Flask, Django.'),
(4, 'Mi viaje a Bali', 'Aprendí algo de Python y FastAPI durante el avión, pero más tiempo nadando.')
) AS t(id, title, body)
WHERE
setweight(to_tsvector('spanish_unaccent', title), 'A') ||
setweight(to_tsvector('spanish_unaccent', body), 'B')
@@ websearch_to_tsquery('spanish_unaccent', 'python fastapi')
ORDER BY rank DESC;
Exercise 2: choose ts_rank or ts_rank_cd by scenario
For each case, decide which function to use and justify it.
a) A product search engine in an e-commerce site. Typical queries: 1-2 words (zapatillas, zapatillas running, cafetera nespresso).
b) An internal technical documentation search. Queries of 3-7 words (como configurar nginx con ssl letsencrypt).
c) A tag search engine in a ticket system. Single-word queries (urgente, regression).
d) A candidate-to-role matching system: declared skills vs required skills (several technical words).
See solution
a) ts_rank_cd. For 2-term queries, proximity is a signal: "zapatillas running" adjacent in a product's title ranks higher than "zapatillas para hacer running". For 1-term queries, both functions behave similarly — ts_rank_cd works fine. A safe default.
b) ts_rank_cd. Long, narrative queries. Proximity is very informative: documents where the terms appear together probably deal with exactly that topic. Without proximity, a doc with "nginx" in one section, "ssl" in another and "letsencrypt" in a third could rank the same as one where they appear integrated.
c) ts_rank. A single word. Proximity adds nothing. ts_rank is slightly faster. If the number of tickets is in the millions, those milliseconds matter.
d) A hybrid, or ts_rank with weights. For skill matching, what matters is frequency and presence, not proximity (the skills are in a list). If the skills are in separate columns with setweight, ts_rank with weights can give a better signal than ts_rank_cd. Test both on real samples and choose.
General pattern:
- Narrative multi-term queries →
ts_rank_cd. - 1-term queries →
ts_rank(slightly faster, same result). - Structured data (tags, skills, attributes) →
ts_rankwith weights. - The safe default when in doubt →
ts_rank_cd.
Exercise 3: implement the endpoint with ranking + snippets from SQLAlchemy
Implement async def search(session, q: str, limit: int = 20) -> list[dict] that:
- Uses the pre-computed
Post.tsvcolumn. - Uses
ts_rank_cdto order by relevance. - Uses
ts_headlinewith<mark></mark>tags,MaxFragments=2,MinWords=8,MaxWords=18. - Returns a list of
{id, title, snippet, rank}.
See solution
from typing import Any
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
TS_CONFIG = "spanish_unaccent"
HEADLINE_OPTIONS = (
"StartSel=<mark>, StopSel=</mark>, "
"MaxFragments=2, MinWords=8, MaxWords=18, "
"FragmentDelimiter= ... "
)
async def search(
session: AsyncSession, q: str, limit: int = 20
) -> list[dict[str, Any]]:
"""
Ranked FTS search with snippets.
Assumes Post.tsv is a generated column with setweight A for title, B for body.
"""
tsquery = func.websearch_to_tsquery(TS_CONFIG, q)
rank = func.ts_rank_cd(Post.tsv, tsquery).label("rank")
snippet = func.ts_headline(
TS_CONFIG, Post.body, tsquery, HEADLINE_OPTIONS
).label("snippet")
stmt = (
select(Post.id, Post.title, snippet, rank)
.where(Post.tsv.bool_op("@@")(tsquery))
.order_by(desc(rank))
.limit(limit)
)
result = await session.execute(stmt)
rows = result.all()
return [
{
"id": r.id,
"title": r.title,
"snippet": r.snippet,
"rank": float(r.rank),
}
for r in rows
]
Generated SQL:
SELECT
posts.id,
posts.title,
ts_headline('spanish_unaccent', posts.body, websearch_to_tsquery('spanish_unaccent', $1),
'StartSel=<mark>, StopSel=</mark>, MaxFragments=2, MinWords=8, MaxWords=18, FragmentDelimiter= ... ') AS snippet,
ts_rank_cd(posts.tsv, websearch_to_tsquery('spanish_unaccent', $1)) AS rank
FROM posts
WHERE posts.tsv @@ websearch_to_tsquery('spanish_unaccent', $1)
ORDER BY rank DESC
LIMIT 20;
Expected plan: a Bitmap Index Scan for the WHERE, a Sort for the ORDER BY rank, and LIMIT cutting to 20. ts_headline is computed only for those final 20 rows (PostgreSQL is smart enough to defer the SELECT's functions until after the LIMIT).
Exercise 4: optimize an endpoint that takes 2 seconds
Your search endpoint takes 2.1 seconds for queries that match ~30k rows in a 5M-row table. The plan says:
Sort (cost=12450..12500 rows=30000)
Sort Key: ts_rank_cd(...) DESC
-> Bitmap Heap Scan on posts (rows=30000)
-> Bitmap Index Scan on idx_posts_tsv
What do you optimize and how?
See solution
Diagnosis: the Bitmap Heap Scan returns 30k rows. The Sort has to order those 30k by rank before applying the LIMIT. Computing ts_rank_cd for 30k rows + sorting is what costs.
Optimization 1: limit the candidates before ranking, with a CTE.
WITH candidates AS (
SELECT id, title, body, tsv
FROM posts
WHERE tsv @@ websearch_to_tsquery('spanish_unaccent', :q)
LIMIT 1000 -- a reasonable ceiling
)
SELECT
id, title,
ts_rank_cd(tsv, websearch_to_tsquery('spanish_unaccent', :q)) AS rank,
ts_headline('spanish_unaccent', body, websearch_to_tsquery('spanish_unaccent', :q),
'StartSel=<mark>, StopSel=</mark>, MaxFragments=2') AS snippet
FROM candidates
ORDER BY rank DESC
LIMIT 20;
Trade-off: you can lose some matches that GIN returns "further down" in its internal order but that rank high. In practice, the first 1000 cover the relevant matches for most consumer queries.
Optimization 2: combine the rank with pre-computed metrics.
If your table has a popularity_score column (views, votes, etc.), you can combine:
ORDER BY (ts_rank_cd(tsv, query) * 0.6 + popularity_score * 0.4) DESC
That lets popular documents with a lower rank "override" obscure matches with a high rank. It's what Google does.
Optimization 3: a multicolumn index if you filter by something else.
If your query is WHERE tsv @@ ... AND category = 'tech', adding a multicolumn GIN index:
CREATE INDEX idx_posts_tsv_category ON posts USING GIN (tsv, category);
reduces the candidates before the rank.
Optimization 4 (advanced): parallelize with gin_fuzzy_search_limit.
PostgreSQL has gin_fuzzy_search_limit (a session parameter) that limits the matches returned by GIN. Setting it limits the candidates without needing a CTE:
SET gin_fuzzy_search_limit = 1000;
SELECT ... FROM posts WHERE tsv @@ ... ORDER BY ts_rank_cd(...) DESC LIMIT 20;
It's less predictable than the CTE but useful for specific cases.
Practical recommendation: start with the CTE (option 1). Measure. If you need more, combine with popularity (option 2), which also improves the quality of the ranking.
Exercise 5: architectural decision — custom weights
Your team decides that in the posts table the matches should weigh:
- Title: high.
- Tags (a list of keywords): medium-high.
- Body: medium.
- Comments (concatenated): low.
Design the generated tsv column with setweight and justify the mapping to A/B/C/D.
See solution
Recommended mapping:
- Title → A (weight 1.0).
- Tags → B (weight 0.4).
- Body → C (weight 0.2).
- Comments → D (weight 0.1).
Generated column:
ALTER TABLE posts ADD COLUMN tsv tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('spanish_unaccent', coalesce(title, '')), 'A') ||
setweight(to_tsvector('spanish_unaccent', coalesce(array_to_string(tags, ' '), '')), 'B') ||
setweight(to_tsvector('spanish_unaccent', coalesce(body, '')), 'C') ||
setweight(to_tsvector('spanish_unaccent', coalesce(comments_concatenated, '')), 'D')
) STORED;
Justification:
- Title (A): the most representative of the author's intent. If the match is in the title, the document is almost always what the user is looking for.
- Tags (B): an explicit topic signal. The author selected those tags because the post is about that. It weighs more than the body because it's intentional taxonomy.
- Body (C): a certain but noisy signal. A word can show up in a footnote or a tangential mention.
- Comments (D): a very noisy signal. Comments can talk about anything. Having the match here shouldn't make the post become a top result.
Cautions with this strategy:
-
comments_concatenatedcan be very large. Concatenating all the comments inflates thetsv's body. If you have 10k comments per post, consider limiting to the N most recent or the N most upvoted. -
Changing tags requires regenerating the
tsv. SincetsvisGENERATED, any UPDATE oftagsregenerates the column. If tags change frequently, be careful about the WRITE cost. -
The default weights
{1.0, 0.4, 0.2, 0.1}are reasonable. If you later measure and want tags to weigh almost as much as the title, you can adjust the weight array ints_rank_cd:
ts_rank_cd('{0.1, 0.2, 0.7, 1.0}', tsv, query)
-- D=0.1, C=0.2, B=0.7, A=1.0 (more weight to tags, B)
General lesson: assigning weights is modeling. There's no "correct" one, there's "the one that best answers what your product needs." Start with A/B/C/D by editorial importance and adjust by measuring CTR.
Summary and next step
In this capsule you learned to order FTS results by relevance, not by date:
ts_rankconsiders weight-adjusted frequency. Useful for single-term queries or when proximity adds nothing.ts_rank_cd(cover density) also considers proximity: adjacent matches rank higher than separated ones. The recommended default for multi-term queries from real users.- Weights with
setweight(A, B, C, D) in the generated column let matches in the title weigh more than matches in the body. Default:{A: 1.0, B: 0.4, C: 0.2, D: 0.1}. ts_headlinegenerates highlighted snippets with HTML to show in the UI. Careful: it operates on the raw body, expensive for many rows. Apply it only after the LIMIT.- The rank isn't absolute, it's relative within a query. Don't filter by "rank > X"; order.
- Optimization for large corpora: a CTE that limits the candidates before computing the rank.
Before moving on you should be able to:
- Write a search endpoint with
ts_rank_cdordered by relevance. - Add
ts_headlinefor snippets with<mark>and size options. - Decide between
ts_rankandts_rank_cdbased on the type of query and corpus. - Design
setweightweights for a table with several searchable fields. - Optimize a slow endpoint by limiting the candidates with a CTE.
Next capsule — pg_trgm: fuzzy search and similarity. You have a fast, ranked search engine, but there's still a hole: if the user types "pythn" (a typo), FTS returns nothing because "pythn" isn't in the dictionary. Capsule 06 teaches you the pg_trgm extension, which indexes text by trigrams (3-character sequences) and enables similarity search. You're going to learn to use it for autocomplete with prefix matching, fuzzy search ("pythn" → "Python"), and as a fallback to the main FTS when it returns no results. It's the tool that closes the last gap between your search engine and an Algolia-style one.
Resources
- PostgreSQL 16 — Ranking Search Results (12.3.3) — the reference for
ts_rank,ts_rank_cd, and all the normalization flags. - PostgreSQL 16 — Highlighting Results (12.3.4) — the complete
ts_headlinereference with all its options. - Crunchy Data — "Postgres Full-Text Search and Phrase Search" — a deep analysis of ranking and real use cases.
- Lukas Fittl — "Tuning Search Quality with PostgreSQL Full-Text Search" — tips for improving ranking quality beyond the defaults.
- SQLAlchemy 2.0 —
func.ts_rankandfunc.ts_headline— how to express the ranking functions from the ORM. - Hubert "depesz" Lubaczewski — "Tuning ts_rank" — an advanced analysis of ranking in PostgreSQL.
Module 3 — Advanced PostgreSQL for Backend Guide
Next capsule: pg_trgm — fuzzy search, similarity, and the "main FTS + trigram fallback" pattern.