How do you combine $search and $vectorSearch results in a single MongoDB pipeline?
answer
- Two stages that both demand first position
- One pipeline can contain another pipeline
- The two score scales do not compare
- Combine positions rather than raw values
basics
~20 sRun one search as the outer pipeline's first stage and the other inside a $unionWith sub-pipeline, assign each result its rank within its own list, then merge on _id and combine the ranks. Recent server versions add a dedicated rank-fusion stage.
solid answer
~50 sBoth `$search` and `$vectorSearch` must open a pipeline, so they cannot be chained. The documented pattern uses `$unionWith`: the outer pipeline starts with one of them, and the other opens the `$unionWith` sub-pipeline, giving two independent result lists in one round trip. Each branch sorts by its own score and stamps a position — `$setWindowFields` with `$documentNumber` is the usual way — because the two scores are not on a comparable scale: `$meta: "searchScore"` is a Lucene relevance number and `$meta: "vectorSearchScore"` is a normalized similarity. After the union, `$group` by `_id` collapses documents appearing in both lists, a computed field combines the two positions into one fused score, and a final `$sort` plus `$limit` produces the answer. Newer MongoDB versions ship a dedicated rank-fusion stage that expresses this directly; the `$unionWith` construction is the portable form worth knowing.
code
javascript · 20 linesdb.articles.aggregate([
{ $vectorSearch: { index: "vec", path: "embedding", queryVector: qv,
filter: { tenantId: t }, numCandidates: 500, limit: 50 } },
{ $set: { vs: { $meta: "vectorSearchScore" } } },
{ $setWindowFields: { sortBy: { vs: -1 }, output: { rank: { $documentNumber: {} } } } },
{ $set: { branch: "vector" } },
{ $unionWith: { coll: "articles", pipeline: [
{ $search: { index: "default", compound: {
must: [ { text: { query: q, path: "body" } } ],
filter: [ { equals: { path: "tenantId", value: t } } ] } } },
{ $limit: 50 },
{ $set: { ts: { $meta: "searchScore" } } },
{ $setWindowFields: { sortBy: { ts: -1 }, output: { rank: { $documentNumber: {} } } } },
{ $set: { branch: "text" } }
] } },
{ $group: { _id: "$_id", title: { $first: "$title" },
fused: { $sum: { $divide: [ 1, { $add: [ 60, "$rank" ] } ] } } } },
{ $sort: { fused: -1 } },
{ $limit: 10 }
]);go deeper
Know that both search stages must open a pipeline, so a hybrid query needs a second pipeline rather than two stages in a row.
Explain the $unionWith construction and why the two branches' scores cannot simply be added, given they come from different engines with different scales.
Show the whole shape: per-branch depth, rank assignment, fusion with weights, duplicated filters on both branches, and the roughly doubled mongot cost per query.
Own the retrieval policy — how the lexical and semantic weights are chosen and evaluated, whether hybrid is worth double search cost at your QPS, and when to adopt the built-in fusion stage.
## The constraint that shapes everything Hybrid search means asking a lexical engine and a vector engine the same question and merging their answers. In MongoDB the obstacle is structural: `$search` and `$vectorSearch` are each only legal as the first stage of a pipeline. You cannot write one after the other, and `$facet` cannot host them either. The escape hatch is that the rule applies per *pipeline*, and `$unionWith` opens a new one. So the shape is: ```javascript db.articles.aggregate([ { $vectorSearch: { /* ... */ limit: 50 } }, // rank the vector branch, then union in the text branch { $unionWith: { coll: "articles", pipeline: [ { $search: { /* ... */ } }, /* rank */ ] } }, // fuse ]); ``` One server round trip, two independent retrievals, one merged output. ## Ranking each branch before the union The two branches produce scores in different units. `{ $meta: "searchScore" }` is a Lucene relevance value whose magnitude depends on term statistics and the query; `{ $meta: "vectorSearchScore" }` is a similarity value derived from the configured metric. There is no principled constant that puts them on a common scale, and a score that happens to be around 0.8 in both cases means two unrelated things. The practical answer is to convert each branch's ordering into a position and combine positions instead. Inside each branch, sort by that branch's score and assign a rank: ```javascript { $setWindowFields: { sortBy: { vs: -1 }, output: { rank: { $documentNumber: {} } } } } ``` Now each document carries a small integer meaning "3rd best according to this retriever", which is comparable across branches by construction. ## Fusing after the union After `$unionWith`, a document found by both retrievers appears twice, once per branch, each with its own rank. `$group` by `_id` collapses them and collects the per-branch contributions; a computed field turns each rank into a diminishing contribution — the standard reciprocal form weights a rank `r` as `weight / (k + r)` — and sums them. Documents found by both branches accumulate two contributions and rise; documents found by only one still appear, which is the point of hybrid retrieval. A final `$sort` on the fused value and `$limit` produce the response, and a `$lookup` or the fields you carried through the union supply the display data. The per-branch weight is where product judgment enters: weighting the lexical branch higher favours exact terms, part numbers and names; weighting the vector branch higher favours paraphrase and intent. ## Depth of each branch Each branch should retrieve deeper than the number of results you intend to show, because a document ranked 30th lexically and 8th by vector may deserve to be in the final top 10 — but only if both branches went at least that deep. Fetching 50 to 100 per branch to produce 10 final results is a common shape; the cost is bounded because the extra work is one wider search per branch, not a per-document operation. ## The newer stage Recent MongoDB versions provide a dedicated rank-fusion aggregation stage that takes named input pipelines and combines their rankings for you, removing the hand-written `$setWindowFields` and `$group` scaffolding. Where it is available it is the better answer, because it is less code to get subtly wrong. Knowing the manual construction still matters: it is what runs on clusters that do not have the stage, and it is what the built-in is doing. ## Operational notes Both branches hit mongot, so a hybrid query costs roughly two searches plus the merge; capacity planning should assume that. Both branches also read the same eventually-consistent index, so neither leg sees a just-written document earlier than the other. And filters must be pushed into *both* branches — a tenant filter present only on the vector leg leaks other tenants' documents through the text leg. ## What to say in an interview Lead with the first-stage constraint and `$unionWith` as the way around it, explain that scores from the two engines are not comparable so you fuse positions rather than values, and mention that current versions can express the fusion as a single stage.
- Why not simply add the searchScore and the vectorSearchScore together?They are values from different systems on different scales — a Lucene relevance number depends on term statistics and query length, while a vector score comes from the configured similarity metric. Their magnitudes are unrelated, so a sum silently lets whichever branch produces larger numbers dominate the ranking.
- How deep should each branch retrieve compared with the number of results you show?Deeper — commonly 50 to 100 per branch for a final top 10. Fusion can only promote a document both branches actually returned, so a shallow branch discards the cross-branch agreement the whole pattern exists to exploit. The extra cost is one wider search per branch, not per-document work.
- What must you be careful to duplicate across both branches?Filters, especially security-relevant ones. A tenant or permission filter applied only to the vector branch means the text branch can surface documents the caller may not see. Each branch is an independent retrieval and needs the full predicate — in the `$vectorSearch` filter option and in the `compound.filter` clause respectively.
saying these in an interview costs you the question
- Chains $vectorSearch after $search in one pipeline
- Adds or averages the raw scores from both engines
- Retrieves only the final page size from each branch
- Applies the tenant filter to just one branch
- Assumes $facet can host the two search stages