What is Hybrid Search?
Hybrid Search is an AI design pattern for LLM apps and agents, in the Context and knowledge group. Combine keyword and vector search so you miss neither synonyms nor exact codes.
Busca Híbrida
Hybrid Search is an AI design pattern for LLM apps and agents, in the Context and knowledge group. Combine keyword and vector search so you miss neither synonyms nor exact codes.
Combine keyword search and vector search in a single query, so you miss neither synonyms nor exact terms. It is for search and RAG systems where users mix natural language with codes, acronyms, and proper names.
Two librarians working together: one knows the catalog by heart, the other understands what you meant.
Pedro runs search for an online auto parts store. Vector search did well on “brake pads for a compact car”: it found pads compatible with several models. But it failed badly on “pad 4D0698151”: a part number’s vector carries no meaning, and the search returned other brands’ pads, none with that code.
The old keyword search got the code right and the natural language wrong. Each method fails where the other succeeds, and customers write both ways, often in the same sentence: “front pad 4D0698151 for A4”.
The same pattern shows up outside e-commerce. In the internal chat, “MV when WFH” needs lexical search for the acronyms and vector search for the remote-work paraphrase. On a Brazilian electronic invoice (NF-e), the 44-digit access key is only found by exact match. An embedding approximates meaning; it does not memorize identifiers.
The question this pattern answers: how does one search get both the meaning of a sentence and the exact term right?
Run both searches over the same set of documents and combine the results:
The most common combination is rank fusion (Reciprocal Rank Fusion, Cormack et al., 2009): each document earns points for its position in each list, and the sum decides the final order. It does not require calibrating different score scales. Exact filters (brand, category, stock) apply to both searches.
flowchart LR
Q(["1. Query"]):::io --> L["2a. Lexical search"]:::tool
Q --> V["2b. Vector search"]:::tool
I[("Index with text and vectors")]:::store --> L
I --> V
L --> F{"3. Rank fusion"}
V --> F
F --> R["4. Combined top N"]:::tool
R --> OUT(["5. Results or context for the model"]):::io
Query: “front pad 4D0698151 for A4”. After normalizing the code (4d0698151), the two lists of 50 look like this at the top:
lexical vector
1. sku-4D0698151-a (code) 1. sku-other-pads-a4 (meaning: pad + A4)
2. sku-4D0698151-b (code) 2. sku-front-pad-compact
3. install-manual-4D06 3. sku-4D0698151-a
RRF with constant 60. The document at rank 0 gets 1/60, at rank 2 gets 1/62. sku-4D0698151-a appears on both lists, sums the two terms, and rises to first place combined. The vector’s “similar” pads, without the code, sit lower.
The mixed sentence stops being a tie between two bad systems. Each contributes what it knows, and fusion promotes whoever showed up on both.
function hybrid_search(query, filters, k=10):
parallel { # 2
lexical = index.bm25(query, filters, top=50)
vector = index.knn(embed(query), filters, top=50)
}
# 3. Reciprocal Rank Fusion
scores = {}
for rank, doc in enumerate(lexical): scores[doc] += 1 / (60 + rank)
for rank, doc in enumerate(vector): scores[doc] += 1 / (60 + rank)
ranked = sort(scores, desc)
trace.span("hybrid", lexical_top=lexical[:3], vector_top=vector[:3])
return ranked[:k] # 4
Hybrid search is the base of RAG when the vocabulary mixes both worlds. It does not solve fine ordering: if the right result sits at position 20 of the combined list, the next step is Reranking. If the query arrives vague, the previous step is Query Rewriting.
Normalize identifiers before indexing and before searching. 4D0-698-151, 4D0698151, and 4d0 698 151 need to become the same token. Without that, lexical search misses the case it was supposed to hit.
category = pad and vector search does not. The combined result leaks a product from another category. The filter goes into both, or into fusion afterwards, never into only one.| Aspect | What happens | How it shows up in the trace |
|---|---|---|
| Calls | Two searches and one query embedding; no language model call | hybrid span with both lists |
| Latency | That of the slower search, if parallel | span duration |
| Cost | A duplicated index (text and vectors) | index storage |
| Failure: one list dominates | Poorly tuned weights or scales | final results identical to one of the lists |
| Failure: different filters | One search filters and the other does not | documents outside the filter in the results |
| Failure: terms with varied spelling | Lexical misses variations (“4D0 698 151”) | code queries with no hits; normalize first |
{ "query": "front pad 4D0698151 for A4", "relevant": ["sku-4D0698151-a", "sku-4D0698151-b"], "type": "mixed" }
If hybrid does not beat lexical on type=code nor vector on type=natural, fusion is broken (ids, filters, or normalization), not “a domain that does not combine”.
The span with both lists is what lets you see, on a bad question, whether lexical, vector, or fusion failed.
Permission filters go into both searches, before fusion. Filtering afterwards, on the combined list, is late: ranking already mixed documents the user cannot see, and a bug in post-filtering leaks a passage.
Indexed content is still untrusted data at RAG time. Hybrid search only changes who enters the list, not the fact that a passage can carry an instruction. See Prompt injection in practice.
| Pros | Cons |
|---|---|
| Gets meaning and exact terms right | Two indexes to maintain |
| Rank fusion needs no calibration | One more stage to evaluate |
| Works well with domain vocabulary | Weights may need tuning per query type |
| A solid base for RAG | Does not solve fine ordering without reranking |
Both tabs run lexical and vector search in parallel and combine them with Reciprocal Rank Fusion.
Python. Use this when the index fits in memory or you want to understand fusion without a search engine.
import numpy as np
from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer
embedder = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
def normalize(text: str) -> list[str]:
return text.lower().replace("-", "").split() # "4D0-698-151" and "4d0698151" become equal
class HybridIndex:
def __init__(self, docs: list[dict]):
self.docs = docs
self.bm25 = BM25Okapi([normalize(d["text"]) for d in docs])
self.vectors = embedder.encode([d["text"] for d in docs], normalize_embeddings=True)
def search(self, query: str, k: int = 10, pool: int = 50, c: int = 60) -> list[dict]:
lexical = np.argsort(-self.bm25.get_scores(normalize(query)))[:pool] # 2a
q = embedder.encode(query, normalize_embeddings=True)
vector = np.argsort(-(self.vectors @ q))[:pool] # 2b
scores: dict[int, float] = {}
for ranking in (lexical, vector): # 3. Reciprocal Rank Fusion
for rank, i in enumerate(ranking):
scores[int(i)] = scores.get(int(i), 0.0) + 1.0 / (c + rank)
best = sorted(scores, key=scores.get, reverse=True)[:k]
return [self.docs[i] for i in best] # 4What you gained: full fusion in a few lines, easy to test in the eval. What you lost: at millions of documents, use an engine that already has both searches.
TypeScript. Use this when documents live in Postgres, with text search and pgvector.
import { Pool } from "pg";
const db = new Pool();
// 2 and 3 in one query: both searches as CTEs and rank fusion in SQL
export async function hybridSearch(query: string, queryEmbedding: number[], category: string, k = 10) {
const { rows } = await db.query(
`WITH lexical AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(tsv, q) DESC) AS r
FROM products, plainto_tsquery('english', $1) q
WHERE tsv @@ q AND category = $3
ORDER BY r LIMIT 50
),
vector AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> $2::vector) AS r
FROM products WHERE category = $3
ORDER BY embedding <=> $2::vector LIMIT 50
)
SELECT p.id, p.title, p.sku,
COALESCE(1.0 / (60 + l.r), 0) + COALESCE(1.0 / (60 + v.r), 0) AS score
FROM products p
LEFT JOIN lexical l ON l.id = p.id
LEFT JOIN vector v ON v.id = p.id
WHERE l.id IS NOT NULL OR v.id IS NOT NULL
ORDER BY score DESC LIMIT $4`,
[query, JSON.stringify(queryEmbedding), category, k],
);
return rows; // 4
}What you gained: a single database, with identical filters on both searches by construction. What you lost: fine-tuning each search is limited to what Postgres offers.
Hybrid Search is an AI design pattern for LLM apps and agents, in the Context and knowledge group. Combine keyword and vector search so you miss neither synonyms nor exact codes.
In the real-world scenarios E-commerce semantic search, Internal knowledge base chat, and Legal assistant.