Hybrid search in MongoDB comes down to one aggregation stage. $rankFusion runs a $vectorSearch pipeline and a $search pipeline independently, deduplicates the results, and merges them into a single ranking using Reciprocal Rank Fusion. Because it fuses on rank rather than raw score, you never have to normalize a cosine similarity against a BM25 score. If you would rather combine the scores arithmetically, MongoDB 8.3 added $scoreFusion for exactly that. This guide covers the version prerequisites, both index definitions, a working pipeline, the constraints that generate most of the errors, and how to tune the result.
The problem hybrid search actually solves
Semantic search and lexical search fail in opposite directions, and that asymmetry is the whole argument for combining them.
Embedding models flatten rare literal strings. A query for CVE-2026-23479, a SKU, an internal ticket ID, or an uncommon function name gets compressed into a vector that sits near every other "looks like an identifier" document. You get plausible neighbors instead of the exact record.
Lexical search nails those literals and misses everything else. It has no idea that "resume after disconnect" and "reconnection handling" describe the same behavior, and it collapses on paraphrase.
Running both is easy. The hard part — and what $rankFusion exists to do — is merging two result sets whose scores are not comparable into one ordering you can defend.
How Reciprocal Rank Fusion decides
RRF ignores the raw scores entirely and looks only at each document's position in each pipeline's output. That sidesteps the normalization problem by construction: a $vectorSearch similarity in the 0-to-1 range and a Lucene-style relevance score have no shared unit, but "third place" means the same thing in both lists.
The official $rankFusion reference describes the mechanics precisely: all input pipelines execute independently, results are deduplicated so each document appears at most once, and the final ranking is derived from where the document placed in each pipeline, how many pipelines returned it, and the configured pipeline weights.
The practical consequence is that agreement wins. A document ranked fourth by both pipelines will generally outrank one ranked first by a single pipeline and missed entirely by the other. For most retrieval workloads that is the behavior you want.
Check your deployment first
This is where teams lose an afternoon, so confirm it before writing any pipeline code.
$vectorSearch availability is documented on its reference page: MongoDB Atlas 6.0.11 and later, MongoDB Enterprise 8.2 and later (with the Kubernetes Operator), and MongoDB Community 8.2 and later. Vector search is no longer Atlas-only — self-managed deployments on 8.2+ can run it. Vectors are capped at 8192 dimensions.
$rankFusion was introduced in MongoDB 8.0, but read the fine print on the reference page before you plan around it: it is not generally available on 8.0.X releases, where using it requires opening a support case, and upgrading from 8.0 may require pausing in-flight $rankFusion queries. Treat both as upgrade preconditions rather than surprises.
$scoreFusion is newer. Its reference page documents it as introduced in MongoDB 8.3 and later.
Choosing between $rankFusion and $scoreFusion
$rankFusion |
$scoreFusion |
|
|---|---|---|
| Fuses on | Document rank (RRF) | Pipeline scores |
| Introduced | MongoDB 8.0 (support case on 8.0.X) | MongoDB 8.3+ |
| Normalization | Not needed | input.normalization: none, sigmoid, minMaxScaler |
| Combination | combination.weights |
combination.weights plus combination.method of avg or expression |
| Tuning surface | Small | Large |
| Reach for it when | Starting out; score distributions differ wildly | You have calibrated scores and an offline eval set |
Start with $rankFusion. It has fewer knobs, which means fewer ways to be quietly wrong, and it gets you a defensible baseline in a day. Move to $scoreFusion only once you have an evaluation set that can prove the change helped. Adopting score fusion without one mostly expands the search space for tuning while leaving you unable to tell whether you improved anything.
Building the two indexes
Hybrid search needs two independent indexes on the same collection. Take a support knowledge base with fields title, body, embedding, and product.
The vector index:
{
"fields": [
{
"type": "vector",
"path": "embedding",
"numDimensions": 1024,
"similarity": "cosine"
},
{
"type": "filter",
"path": "product"
}
]
}
Declaring product as a filter field is what makes pre-filtering possible inside $vectorSearch. Pre-filtering narrows the candidate set before vectors are compared, which is cheaper than retrieving broadly and discarding with a later $match.
The full-text index:
{
"mappings": {
"dynamic": false,
"fields": {
"title": { "type": "string" },
"body": { "type": "string" },
"product": { "type": "token" }
}
}
}
Setting dynamic to false is deliberate. Index only the fields that participate in retrieval; letting the mapping index everything inflates storage and write amplification for no retrieval benefit.
Writing the fused pipeline
A standalone vector stage first. $vectorSearch must be the first stage of any pipeline it appears in — that is a hard constraint, not a style preference:
{
$vectorSearch: {
index: "kb_vector",
path: "embedding",
queryVector: [/* query embedding */],
numCandidates: 400,
limit: 20,
filter: { product: "cronova" }
}
}
numCandidates sets the size of the priority queue used during the HNSW graph search. The documented guidance is to make it at least 20 times limit. The trade-off is stated plainly: a larger queue explores more of the graph and can surface better matches, at the cost of latency, and a well-tuned value lands roughly 90-95% overlap with exact nearest-neighbor results.
Setting exact: true switches to ENN, which exhaustively computes distance against every indexed embedding. That suits small curated datasets, heavily pre-filtered candidate sets, or cases where exactness is a requirement. On a large collection it is computationally intensive and will show up directly in your p99.
Now the fusion:
db.kb.aggregate([
{
$rankFusion: {
input: {
pipelines: {
semantic: [
{
$vectorSearch: {
index: "kb_vector",
path: "embedding",
queryVector: [/* query embedding */],
numCandidates: 400,
limit: 20,
filter: { product: "cronova" }
}
}
],
lexical: [
{
$search: {
index: "kb_text",
compound: {
must: [
{ text: { query: "retry backoff", path: ["title", "body"] } }
],
filter: [
{ equals: { path: "product", value: "cronova" } }
]
}
}
},
{ $limit: 20 }
]
}
},
combination: {
weights: { semantic: 1, lexical: 1 }
},
scoreDetails: true
}
},
{ $limit: 10 },
{
$project: {
title: 1,
product: 1,
scoreDetails: { $meta: "scoreDetails" }
}
}
])
Three things worth noticing. The pipeline names semantic and lexical are yours to choose and they surface in scoreDetails, which is how you trace why a document ranked where it did. Keep scoreDetails: true while tuning and turn it off in production if response size matters. The trailing $limit: 10 applies after fusion and is independent of the per-pipeline limit: 20 — confusing the two is a common source of "why am I only getting ten results from each path".
Constraints that cause most errors
The reference page enumerates these, and working through them once will save you a debugging session:
- Every input pipeline must run against the same collection.
input.pipelinesneeds at least one pipeline, and names must be unique.- A pipeline name cannot be empty, cannot begin with
$, and cannot contain.or an ASCII null character. - Each input pipeline must satisfy two roles simultaneously:
- Selection pipeline — it may only contain
$match,$search,$vectorSearch,$sample,$geoNear,$sort,$skip, and$limit. It retrieves documents unmodified. No$project, no$addFields, no$lookup. - Ranked pipeline — it must either begin with
$search,$vectorSearch, or$geoNear, or contain an explicit$sort. - Weights in
combination.weightsmust be non-negative; omitted pipelines default to a weight of 1.
The "unmodified documents" rule catches people most often. Projecting away a large embedding field inside the input pipeline feels natural and is rejected — that projection belongs after $rankFusion.
Separately, $vectorSearch cannot be used in view definitions, $lookup sub-pipelines, or $facet stages. As of MongoDB 8.0 it can be used inside $unionWith.
Tuning without fooling yourself
Equal weights are a reasonable starting point and a poor stopping point. Which direction to move depends on your query mix:
- A high share of exact identifiers — error codes, SKUs, version strings, API names — argues for more weight on the lexical pipeline.
- Conversational, long-form, paraphrase-heavy queries argue for more weight on the semantic pipeline.
- A roughly even mix, which is where most documentation and support corpora land, argues for staying near parity and tuning recall depth and
numCandidatesinstead.
Before touching weights, build an evaluation set. Fifty to a hundred real queries with hand-labeled correct answers is enough to turn tuning into measurement. Without one, every weight change is an uncontrolled experiment running on live traffic.
To be explicit about what this section is and is not: this is a method, not a recommended constant. Optimal weights vary with corpus, embedding model, and query distribution, and any claim that a specific ratio is universally best does not survive contact with a second dataset.
Wiring it into a service
The fused query is an ordinary aggregation call, so there is nothing exotic about calling it from an application. The operational constraints are worth settling up front.
Embedding generation is a network call that will time out. Running it inline with the database query means every hiccup in the embedding provider becomes a 5xx on your search endpoint. Give the embedding call its own timeout and retry budget, and define a degradation path: falling back to a lexical-only $search returns worse results, which is strictly better than returning an error page.
If you stream results to the client as they resolve, Server-Sent Events is the usual transport, and the details around reconnection, event IDs, heartbeats, and proxy buffering are their own problem — we covered that separately in FastAPI SSE in Production, and the same patterns apply to streaming fused search results.
Keeping the index consistent with document state is the other half. The publish-snapshot and optimistic-locking approach described in Building an Atomic Bilingual Publishing System transfers directly: let a document reach a stable published state first, then trigger re-embedding and index updates, so retrieval never surfaces a half-edited draft.
If the destination for this pipeline is a tool exposed to an AI agent, the authorization surface becomes the harder problem — what the tool may read, how much it returns, and how you stop an agent from exfiltrating internal documents through a search endpoint. The boundaries in the MCP Server Security Checklist map onto retrieval tools with almost no translation.
Failure modes and how to read them
Fused output looks identical to one pipeline's output. Almost always a recall imbalance. If the lexical pipeline returns five documents and the semantic pipeline returns fifty, the semantic side dominates by volume. Bring the two recall depths within the same order of magnitude before drawing conclusions.
"Invalid input pipeline" errors. Walk the constraint list above. The usual culprit is a $project or $addFields inside an input pipeline.
$vectorSearch position errors. It must be first in its pipeline. If you added a $match in front of it to filter, move that condition into the stage's own filter argument.
Pre-filtering changes the result set but not the scores. Expected. The documentation states that pre-filtering does not affect the returned vectorSearchScore; filtering constrains the candidate set rather than participating in the similarity computation.
Queries that worked before an upgrade now fail. Revisit the version section. $rankFusion is not GA on 8.0.X, and the documentation flags that upgrading from 8.0 may require pausing those queries.
Ranking drifts between identical queries. Check numCandidates. ANN is approximate; too small a queue means shallow graph exploration and unstable output. Raise it to at least 20x limit and re-measure.
Pre-launch checklist
- Deployment version supports
$vectorSearchand$rankFusion(or$scoreFusion); any 8.0.X support-case prerequisite is resolved - Both indexes exist, and every pre-filter field is declared as a
filterfield in the vector index - Input pipelines contain only permitted stages — no
$project,$addFields, or$lookup - Recall depth is comparable across pipelines, with
numCandidatesat 20xlimitor higher - Post-fusion
$limitis set independently from per-pipeline limits - Embedding calls have their own timeout and a lexical-only fallback path
- An evaluation set of at least 50 labeled queries exists before any weight is changed
scoreDetailsenabled while tuning, deliberately decided for production- Re-embedding is decoupled from document editing so retrieval never hits an intermediate state
FAQ
Do I need Atlas? No. $vectorSearch runs on MongoDB Community 8.2+ and Enterprise 8.2+ (with the Kubernetes Operator), and on Atlas from 6.0.11.
Can I fuse more than two pipelines? Yes. input.pipelines is a map; the documented requirements are at least one pipeline and unique names, with no stated maximum. A common third pipeline is a recency sort.
Does RRF dilute strong exact matches? It can, and that is inherent to rank fusion. A document ranked first lexically but absent from the semantic results will score below one that placed in the top three of both. If exact matches must always win in your product, add an explicit short-circuit for them rather than trying to approximate that with weights.
Is there a dimension limit? Yes, 8192.
When should I use exact: true? Small collections, aggressively pre-filtered candidate sets, or requirements for reproducible exact results. It is computationally intensive and a poor default at scale.