2.0 KiB
id, title, type, tags, status, author, created_at, updated_at
| id | title | type | tags | status | author | created_at | updated_at | ||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| postgres-full-text-search | PostgreSQL Full-Text Search | documentation |
|
published | asep | 2026-08-20 | 2026-08-20 |
PostgreSQL Full-Text Search
MCPedia's keyword search is backed by PostgreSQL's native full-text search (FTS), not
an external engine. The documents table carries a generated tsvector column that
combines the title (weight A) and body (weight B).
Generated search vector
The column is generatedAlwaysAs, so it is always consistent with the row and needs
no trigger:
searchVector: tsvector("search_vector")
.notNull()
.generatedAlwaysAs(
sql`setweight(to_tsvector('simple', coalesce(${documents.title}, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(${documents.body}, '')), 'B')`,
),
The 'simple' config disables stemming, so mixed identifier/English queries (e.g.
websocket, tsvector) match literally. A GIN index on searchVector keeps lookups
fast.
Query + ranking
Search parses the user query with websearch_to_tsquery (or plainto_tsquery), then
ranks with ts_rank:
SELECT *, ts_rank(search_vector, q) AS rank
FROM documents, websearch_to_tsquery('simple', $1) q
WHERE search_vector @@ q
ORDER BY rank DESC;
| Config | Stemming | Use case |
|---|---|---|
simple |
none | Identifiers, ports, exact codes |
english |
yes | Natural-language body text |
websearch |
yes | Google-like queries ("a" OR b) |
A headline snippet for the UI comes from ts_headline, which bolds the matched lexemes.
Hybrid fusion
Semantic search (embedding cosine) and FTS are fused with Reciprocal Rank Fusion
(RRF) in packages/search. Each result set is ranked, scored 1/(k + rank), and the
summed scores re-rank the union — no cross-score normalization needed, which is robust
when the two signals live on different scales.