Introduction
If you've shipped a Retrieval-Augmented Generation (RAG) application to production, you've probably hit this wall: your vector search works beautifully for semantic queries like "how do I reset my mood after a bad day at work," but it completely fails when a user searches for something precise — an order ID, a product SKU, an error code like ECONNREFUSED, or a person's exact name.
This isn't a bug in your embedding model. It's a fundamental limitation of dense vector search. Embeddings are excellent at capturing meaning, but they're notoriously bad at capturing exact tokens. A vector search for "invoice INV-2024-0091" might return documents about invoices in general, but miss the one document that actually contains that exact string, because the embedding model smooths over the specific characters in favor of general semantic intent.
This is exactly the kind of retrieval gap that causes context hallucination — your LLM confidently generates an answer based on the wrong (or missing) context, because the retrieval layer handed it semantically-similar-but-factually-wrong chunks.
The fix is hybrid search: combining dense vector search (semantic understanding) with sparse keyword search (exact term matching), then merging the two ranked lists using a fusion algorithm. In this deep-dive, we'll build this entire pipeline from scratch using:
- PostgreSQL as our single source of truth (no extra infrastructure)
- pgvector for dense embedding storage and similarity search
- PostgreSQL's native full-text search (
tsvector/tsquery) for sparse keyword search - Reciprocal Rank Fusion (RRF) to merge both result sets
- Node.js to orchestrate the entire retrieval pipeline
By the end of this article, you'll have a production-ready hybrid search function you can drop straight into your RAG pipeline.
Why Hybrid Search? Dense vs. Sparse Retrieval, Explained
Before writing a single line of SQL, it's worth understanding why combining these two approaches works so well.
Dense Retrieval (Vector Search)
Dense retrieval represents text as a fixed-length numerical vector (an embedding) in high-dimensional space. Similar meanings end up close together geometrically, even if the words used are completely different.
Strengths:
- Understands synonyms and paraphrasing ("car" ≈ "automobile")
- Captures conceptual and contextual similarity
- Works well for conversational, natural-language queries
Weaknesses:
- Struggles with exact identifiers, codes, acronyms, and rare tokens
- Can return "semantically close but factually wrong" results
- Sensitive to embedding model quality and domain mismatch
Sparse Retrieval (Full-Text / Keyword Search)
Sparse retrieval — the classic approach used by search engines for decades — represents documents as a bag of weighted terms. PostgreSQL's tsvector implementation uses techniques like stemming, stop-word removal, and term frequency weighting.
Strengths:
- Excellent at exact-match and rare-token queries
- Predictable, explainable ranking (BM25-like scoring via
ts_rank) - Cheap to compute and index at scale
Weaknesses:
- No understanding of meaning or intent
- Fails on paraphrased or conceptually similar queries with no shared vocabulary
Why Fusion Wins
Neither approach is sufficient alone for production RAG. Hybrid search takes the top-K results from each method and merges them into a single, more robust ranking. Multiple industry benchmarks (including internal evaluations from teams building enterprise search and RAG systems) have shown that hybrid retrieval consistently outperforms either method in isolation — often improving retrieval precision by 15–30% on real-world query sets that mix conversational and precise queries.
Prerequisites
Before diving in, make sure you have:
- PostgreSQL 15+ (16 or 17 recommended for better index performance)
- The
pgvectorextension installed - Node.js 20+
- A Postgres client library — we'll use
pg(node-postgres) - An embeddings provider (we'll use OpenAI's
text-embedding-3-smallin examples, but any model works)
Install the Node dependencies:
npm install pg openai dotenv
Step 1: Setting Up PostgreSQL with pgvector
First, enable the pgvector extension in your database. If you're on a managed provider like Supabase, Neon, or RDS, this is usually a single command (some managed platforms expose it as a toggle in their dashboard).
-- Run this once per database
CREATE EXTENSION IF NOT EXISTS vector;
Verify it installed correctly:
SELECT extname, extversion FROM pg_extension WHERE extname = 'vector';
If you're self-hosting, you may need to install the extension binaries first:
# Debian/Ubuntu example
sudo apt install postgresql-16-pgvector
Step 2: Designing the Schema
The core idea of hybrid search in PostgreSQL is deceptively simple: store both a vector column and a generated tsvector column on the same table, then index both.
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
content TEXT NOT NULL,
metadata JSONB DEFAULT '{}'::jsonb,
embedding VECTOR(1536), -- dense vector column
content_tsv TSVECTOR GENERATED ALWAYS AS -- sparse search column
(to_tsvector('english', content)) STORED,
created_at TIMESTAMPTZ DEFAULT now()
);
A few important design decisions here:
VECTOR(1536)matches the dimensionality of OpenAI'stext-embedding-3-small. If you use a different model (e.g.,text-embedding-3-largeat 3072 dims, or an open-source model likebge-large-enat 1024 dims), adjust accordingly.content_tsvis a generated column, meaning PostgreSQL automatically keeps it in sync withcontent— no manual trigger management needed.metadata JSONBstores source references, chunk IDs, document titles, etc., which you'll need later for citation in your RAG responses.
Indexing Both Columns
Without indexes, both search types degrade to a full table scan past a few thousand rows. Create an HNSW index for the vector column (the current best-practice choice over IVFFlat for most production workloads as of 2026, due to better recall/speed tradeoffs) and a GIN index for the text column.
-- Vector index (cosine distance is standard for normalized embeddings)
CREATE INDEX idx_documents_embedding_hnsw
ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- Full-text search index
CREATE INDEX idx_documents_content_tsv
ON documents
USING GIN (content_tsv);
Note:
mandef_constructioncontrol HNSW's graph density and build quality. Higher values improve recall at the cost of slower index builds and more memory.m = 16, ef_construction = 64is a solid default for collections up to a few million rows.
Step 3: Generating and Storing Embeddings in Node.js
Now let's ingest documents. We'll chunk text (in a real pipeline, use a proper chunking strategy — see the Best Practices section), embed each chunk, and insert it alongside its raw text.
// ingest.js
import { Pool } from "pg";
import OpenAI from "openai";
import "dotenv/config";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
async function embedText(text) {
const response = await openai.embeddings.create({
model: "text-embedding-3-small",
input: text,
});
return response.data[0].embedding;
}
async function insertDocument(content, metadata = {}) {
const embedding = await embedText(content);
// pgvector expects a string like '[0.01,0.02,...]'
const vectorLiteral = `[${embedding.join(",")}]`;
await pool.query(
`INSERT INTO documents (content, metadata, embedding)
VALUES ($1, $2, $3)`,
[content, metadata, vectorLiteral]
);
}
async function main() {
const chunks = [
"Order INV-2024-0091 was shipped via FedEx on March 3rd.",
"To reset your password, go to Settings > Security > Reset Password.",
"The ECONNREFUSED error usually means the target server is not accepting connections on that port.",
];
for (const chunk of chunks) {
await insertDocument(chunk, { source: "support-docs" });
console.log("Inserted:", chunk.slice(0, 40) + "...");
}
await pool.end();
}
main().catch(console.error);
Notice the content_tsv column requires no manual work here — since it's a GENERATED column, PostgreSQL populates it automatically on insert.
Step 4: Understanding PostgreSQL Full-Text Search Queries
Before combining anything, let's make sure the sparse search side works on its own. PostgreSQL's ts_rank function scores how relevant a document is to a query based on term frequency and proximity.
SELECT
id,
content,
ts_rank(content_tsv, websearch_to_tsquery('english', 'ECONNREFUSED error')) AS rank
FROM documents
WHERE content_tsv @@ websearch_to_tsquery('english', 'ECONNREFUSED error')
ORDER BY rank DESC
LIMIT 5;
We use websearch_to_tsquery instead of plainto_tsquery or raw to_tsquery because it accepts natural user input (including quoted phrases and -exclusions) without throwing syntax errors — a crucial detail if you're passing raw user queries from a search box or chat interface.
Step 5: Implementing Reciprocal Rank Fusion (RRF)
This is the heart of hybrid search. Reciprocal Rank Fusion is an elegant algorithm because it doesn't require normalizing or comparing scores from fundamentally different systems (cosine distance vs. ts_rank are on completely different scales — trying to weight-average them directly is a common mistake).
Instead, RRF only cares about rank position. For each document, its RRF score is:
RRF_score(d) = Σ 1 / (k + rank_i(d))
Where rank_i(d) is the document's position in result list i (starting from 1), and k is a constant (typically 60, a value popularized by the original RRF paper and widely adopted as a sane default because it dampens the influence of top-ranked outliers without needing per-dataset tuning).
If a document appears near the top of both the vector search and the keyword search results, its combined RRF score will be high. If it only appears in one list, it still gets credit — just less.
The Full Hybrid Search SQL Query
Here's the complete query, implemented as a single SQL statement using CTEs (Common Table Expressions):
WITH vector_search AS (
SELECT
id,
RANK() OVER (ORDER BY embedding <=> $1) AS rank
FROM documents
ORDER BY embedding <=> $1
LIMIT 20
),
fulltext_search AS (
SELECT
id,
RANK() OVER (ORDER BY ts_rank(content_tsv, websearch_to_tsquery('english', $2)) DESC) AS rank
FROM documents
WHERE content_tsv @@ websearch_to_tsquery('english', $2)
ORDER BY ts_rank(content_tsv, websearch_to_tsquery('english', $2)) DESC
LIMIT 20
)
SELECT
COALESCE(v.id, f.id) AS id,
d.content,
d.metadata,
COALESCE(1.0 / (60 + v.rank), 0.0) +
COALESCE(1.0 / (60 + f.rank), 0.0) AS rrf_score
FROM vector_search v
FULL OUTER JOIN fulltext_search f ON v.id = f.id
JOIN documents d ON d.id = COALESCE(v.id, f.id)
ORDER BY rrf_score DESC
LIMIT 10;
Here's what's happening:
vector_searchranks the top 20 documents by cosine distance (<=>is pgvector's cosine distance operator).fulltext_searchranks the top 20 documents byts_rankagainst the keyword query.- A
FULL OUTER JOINensures documents that appear in either list are included — a document doesn't need to be in both to be considered. - The RRF formula combines both rank positions into a single
rrf_score. - Results are sorted by that fused score.
Step 6: Wiring It Together in Node.js
Now let's wrap this query in a reusable, production-ready function.
// hybridSearch.js
import { Pool } from "pg";
import OpenAI from "openai";
import "dotenv/config";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const RRF_QUERY = `
WITH vector_search AS (
SELECT id, RANK() OVER (ORDER BY embedding <=> $1) AS rank
FROM documents
ORDER BY embedding <=> $1
LIMIT $3
),
fulltext_search AS (
SELECT id, RANK() OVER (
ORDER BY ts_rank(content_tsv, websearch_to_tsquery('english', $2)) DESC
) AS rank
FROM documents
WHERE content_tsv @@ websearch_to_tsquery('english', $2)
LIMIT $3
)
SELECT
COALESCE(v.id, f.id) AS id,
d.content,
d.metadata,
COALESCE(1.0 / (60 + v.rank), 0.0) +
COALESCE(1.0 / (60 + f.rank), 0.0) AS rrf_score
FROM vector_search v
FULL OUTER JOIN fulltext_search f ON v.id = f.id
JOIN documents d ON d.id = COALESCE(v.id, f.id)
ORDER BY rrf_score DESC
LIMIT $4;
`;
async function embedQuery(text) {
const response = await openai.embeddings.create({
model: "text-embedding-3-small",
input: text,
});
return `[${response.data[0].embedding.join(",")}]`;
}
/**
* Performs hybrid (vector + full-text) search using Reciprocal Rank Fusion.
* @param {string} query - The raw user query.
* @param {number} candidatesPerMethod - Top-K to pull from each method before fusion.
* @param {number} finalLimit - Number of fused results to return.
*/
export async function hybridSearch(query, candidatesPerMethod = 20, finalLimit = 10) {
const embedding = await embedQuery(query);
const { rows } = await pool.query(RRF_QUERY, [
embedding,
query,
candidatesPerMethod,
finalLimit,
]);
return rows;
}
Using it inside a RAG pipeline:
// ragPipeline.js
import { hybridSearch } from "./hybridSearch.js";
import OpenAI from "openai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
async function answerQuestion(userQuery) {
const results = await hybridSearch(userQuery);
const context = results
.map((r, i) => `[${i + 1}] ${r.content}`)
.join("\n");
const completion = await openai.chat.completions.create({
model: "gpt-4.1",
messages: [
{
role: "system",
content:
"Answer the user's question using ONLY the provided context. Cite sources using [n] notation. If the answer isn't in the context, say so explicitly.",
},
{ role: "user", content: `Context:\n${context}\n\nQuestion: ${userQuery}` },
],
});
return completion.choices[0].message.content;
}
That's it — a fully functioning hybrid retrieval layer feeding a grounded LLM response, with no external search infrastructure required.
Real-World Example: Why This Matters
Imagine a customer support RAG bot for a SaaS product. A user types:
"My integration is throwing ECONNREFUSED, and my invoice INV-2024-0091 shows I was double-charged."
This single query has two very different retrieval needs:
- The phrase "throwing an error when connecting" is conceptual — vector search handles this well, even matching documentation that never uses the literal string "ECONNREFUSED."
- The token "INV-2024-0091" is an exact identifier. Vector search will almost certainly miss it or rank it low, because embedding models don't preserve character-level precision. Full-text search, however, will match it exactly and rank it near the top.
With pure vector search, the bot would likely hallucinate a generic answer about billing without ever surfacing the specific invoice record. With hybrid search, both the conceptual documentation chunk and the exact invoice record surface in the fused top-10 — giving the LLM everything it needs to answer accurately and cite specifics.
This pattern — one query, two very different information needs — is extremely common in production RAG systems: e-commerce search, internal knowledge bases, legal document retrieval, and technical support are all full of exact identifiers mixed with natural language.
Performance Tuning & Indexing Best Practices
- Tune
ef_searchat query time for HNSW to trade off speed vs. recall:SET hnsw.ef_search = 100;before running vector queries. Higher values improve recall but increase latency. - Pre-filter with metadata before ranking when possible (e.g.,
WHERE metadata->>'tenant_id' = $x) to shrink the candidate set for multi-tenant applications — this dramatically improves performance at scale. - Use
EXPLAIN ANALYZEon your RRF query regularly. If either CTE isn't hitting its index, you'll see a sequential scan instead of anIndex Scanonidx_documents_embedding_hnswor aBitmap Index Scanon the GIN index. - Batch your embedding calls during ingestion — most embedding APIs support batched input, which is significantly cheaper and faster than one call per chunk.
- Keep
candidatesPerMethodreasonably small (10–30). Pulling too many candidates before fusion increases query latency without meaningfully improving final relevance. - Re-index periodically for HNSW if you do heavy bulk deletes — vacuum and reindex to reclaim graph quality.
🚀 Pro Tips
- Weight the fusion, don't just sum it. If your use case leans more conceptual (e.g., customer chat) or more precise (e.g., legal search), apply a multiplier:
(vectorWeight * 1/(k+rank_v)) + (textWeight * 1/(k+rank_f)). Start at 0.5/0.5 and tune empirically against a labeled eval set. - Normalize your embeddings before insertion if your model doesn't already return unit vectors — this makes cosine distance behave more predictably and lets you safely use
vector_ip_ops(inner product) for a speed boost if desired. - Store chunk-level metadata like
chunk_indexandparent_doc_idso you can reconstruct surrounding context or deduplicate near-identical chunks after fusion. - Log both individual rankings and the fused rank during development — it's the fastest way to debug why a document surfaced (or didn't).
- Consider a re-ranker as a second-stage filter on top of RRF output for high-stakes applications — a cross-encoder re-ranker (e.g., Cohere Rerank or an open-source BGE reranker) can further sharpen the final top-5 before they hit your LLM.
Common Mistakes to Avoid
- Averaging raw scores instead of using rank-based fusion. Cosine distance (0 to 2) and
ts_rank(an unbounded float) live on completely different scales. Naively summing or averaging them produces meaningless rankings — always fuse on rank, not raw score, unless you've done careful score normalization. - Forgetting to index the
tsvectorcolumn. Without a GIN index,@@queries silently fall back to sequential scans, and your "full-text search" becomes a full-table scan that gets slower with every row you add. - Using
to_tsquerydirectly on raw user input. Unescaped special characters (like&,|,!) will throw syntax errors. Always usewebsearch_to_tsquery(or sanitize input) when handling free-form user queries. - Skipping the
LIMITon each CTE. If you rank the entire table before fusing, you lose the performance benefits of the indexes and pay for a massive sort on every query. - Assuming HNSW defaults are always fine. For collections beyond a few million vectors, you may need to tune
ef_construction,m, andef_searchexplicitly — the defaults are a starting point, not a guarantee. - Ignoring recall degradation from over-aggressive
LIMITvalues. IfcandidatesPerMethodis too small (e.g., 5), you risk missing relevant documents before fusion even has a chance to consider them. - Not re-generating embeddings after changing chunking strategy. If you change how you split documents, old embeddings become stale relative to new chunk boundaries — always re-embed after a chunking change.
📌 Key Takeaways
- Dense vector search and sparse keyword search solve different problems — semantic similarity vs. exact term matching — and production RAG systems need both.
- PostgreSQL with
pgvectorand native full-text search lets you build hybrid retrieval without adding a separate search engine like Elasticsearch or OpenSearch to your stack. - Reciprocal Rank Fusion is the industry-standard way to merge differently-scaled ranked lists, using only rank position — no fragile score normalization required.
- Proper indexing (HNSW for vectors, GIN for
tsvector) is non-negotiable for production performance; without it, queries degrade into sequential scans. - Hybrid search directly reduces context hallucination by ensuring your LLM receives both conceptually relevant and factually precise context.
Conclusion
Hybrid search isn't an exotic optimization reserved for search-engine teams — it's quickly becoming table stakes for any serious RAG pipeline. The good news is that if you're already using PostgreSQL, you likely don't need new infrastructure to implement it. pgvector and native full-text search live in the same database, can be queried in a single round trip, and — as we've shown — can be fused with a well-understood, tuning-light algorithm like RRF.
The real payoff shows up in production: fewer hallucinated answers, better handling of mixed conversational-and-precise queries, and a noticeably more trustworthy RAG application. Start with the schema and query patterns in this guide, measure retrieval quality against your own query logs, and tune the fusion weights and candidate limits from there.
If you take one thing away from this article, let it be this: don't force your retrieval layer to choose between meaning and precision — give it both, and let fusion do the rest.
References
- pgvector GitHub Repository
- PostgreSQL Documentation: Full Text Search
- PostgreSQL Documentation:
tsvectorandtsqueryTypes - Cormack, G. V., Clarke, C. L. A., & Buettcher, S. (2009). Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods. SIGIR.
- node-postgres (
pg) Documentation - OpenAI Embeddings API Reference