skip to content

In $vectorSearch, what does numCandidates control and how do you choose it?

level: middleimportance: must knowfreq 65%

answer

  1. Approximate search can miss a better neighbour
  2. One number says how hard the engine tries
  3. It has a floor tied to the result count
  4. There is an exhaustive mode to compare against

basics

~20 s

numCandidates is how many near neighbours the approximate search examines before the best limit are returned. Raising it improves recall and costs latency; it must be at least limit, and a common starting point is ten to twenty times limit.

solid answer

~50 s

`$vectorSearch` is approximate by default: it walks an HNSW graph rather than comparing the query against every vector. `numCandidates` sets how wide that walk is — how many candidate neighbours are kept in play before the stage returns the top `limit` by similarity. Too low and the traversal misses genuinely nearer vectors, so recall drops silently; too high and you pay latency and CPU for neighbours you discard. It must be at least `limit` and is capped by the service, so it is a tuning dial, not an arbitrary number. The honest way to choose it is empirical: build a query set, run each query with `exact: true` to get the true nearest neighbours by exhaustive search, then measure what fraction of that ground truth an approximate run at a given `numCandidates` recovers. Raise it until recall meets your target and stop. Note `exact: true` and `numCandidates` are mutually exclusive.

code

javascript · 10 lines
javascript
db.articles.aggregate([
  { $vectorSearch: {
      index: "vector_index",
      path: "embedding",
      queryVector: qv,
      numCandidates: 150,
      limit: 10
  } },
  { $project: { title: 1, score: { $meta: "vectorSearchScore" } } }
]);

go deeper

for a junior

Know that limit is how many results you get back and numCandidates is how many the engine considers first, and that the second must not be smaller than the first.

for a middle

Explain why the search is approximate at all, and how raising the candidate count trades latency for recall on a graph-based index.

for a senior

Demonstrate the measurement loop: exhaustive runs as ground truth, recall@k against p95 latency, and the smallest value that clears the target rather than a copied default.

for a principal

Own the tradeoff across the fleet — per-query-class candidate settings, the interaction with filters and quantization, and whether search should run on isolated nodes so tuning cannot starve operational queries.

## Approximate by default When you index a field as `type: "vector"` in an Atlas Vector Search index, mongot builds a graph-based approximate-nearest-neighbour structure over those vectors. A `$vectorSearch` query then navigates that graph from an entry point toward the query vector, rather than scoring every document. That is what makes similarity search over millions of vectors fast, and it is also why the answer is approximate: the traversal can end in a good neighbourhood while a slightly better neighbour sits in a part of the graph it never visited. `numCandidates` is the knob that controls how thoroughly the traversal explores before committing. ## What the number means operationally A query with `limit: 10` and `numCandidates: 150` asks the engine to consider roughly 150 near neighbours during the search and then return the 10 with the highest similarity score. The stage rules are simple: `numCandidates` must be greater than or equal to `limit`, and the service enforces an upper bound, so you cannot express "consider everything" this way. Setting it equal to `limit` is the fastest and least accurate configuration; increasing it widens the search and monotonically improves — or at worst does not hurt — recall, while increasing work. A useful mental model: `limit` is what the caller wants, `numCandidates` is how hard the engine tries. They are different concerns and it is a mistake to derive one from the other by habit alone. The commonly cited starting point is ten to twenty times `limit`, which is a heuristic, not a law. ## Measuring instead of guessing Recall loss from approximate search is invisible from inside the query: results always come back, they are simply not always the best ones. So measure it. The measurement tool is the stage's own `exact` option. `exact: true` performs an exhaustive (ENN) search, comparing the query vector against every candidate vector and returning the true top-k. It is slow and unsuitable for serving traffic at scale, but it is exactly the ground truth you need offline. The procedure: 1. Sample a few hundred real production query vectors. 2. For each, run `exact: true` and record the true top-k ids. 3. Run the same queries approximately at several `numCandidates` values. 4. Report recall@k — the mean fraction of the true top-k that the approximate run returned — alongside p95 latency. 5. Pick the smallest `numCandidates` that meets your recall target. Because `exact: true` and `numCandidates` are mutually exclusive, an exact query simply omits the parameter. ## What else moves recall `numCandidates` is not the only lever, and treating it as the only one leads to setting it absurdly high. A restrictive `filter` shrinks the reachable neighbourhood and can depress effective recall at a fixed `numCandidates`, so filtered workloads generally need a larger value than unfiltered ones. Quantization — storing vectors in a compressed form to fit more of the index in memory — trades a little accuracy for a lot of capacity, and its effect interacts with the candidate count. And if the embedding itself does not separate your documents well, no candidate count rescues the ranking. ## Cost shape The latency curve is sub-linear but real: doubling `numCandidates` does not double query time, but it does increase distance computations and memory traffic per query, and that cost multiplies by QPS. On a cluster where mongot shares hardware with `mongod`, a generous candidate count under load competes with your operational workload — one of the concrete arguments for dedicated Search Nodes. Fixing a per-query dial at a value chosen for the hardest query also means every cheap query pays for it; some systems set `numCandidates` per query class instead of globally. ## What to say in an interview Define it as the breadth of the approximate search, name the `numCandidates` >= `limit` rule, and then show the discipline that separates a strong answer from a memorised one: use `exact: true` to build ground truth, measure recall@k against latency, and choose the smallest value that clears the bar.

  • What happens if you set numCandidates equal to limit?
    The query is legal — that is the floor — but the traversal keeps no slack, so it returns the first `limit` neighbours it reaches rather than the best ones it could find. Recall drops and the degradation is silent: results still come back, they are just worse. It is the fastest and least accurate setting.
  • How do you actually measure the recall you are losing?
    Sample real query vectors, run each with `exact: true` to get the true top-k by exhaustive search, then re-run approximately and compute the mean fraction of that ground truth recovered — recall@k. Plot it against p95 latency across candidate values and take the smallest value meeting your target.
  • Why might a filtered $vectorSearch need a higher numCandidates than an unfiltered one?
    A restrictive filter removes most of the graph neighbourhood from consideration, so a traversal of fixed breadth surfaces fewer qualifying documents and can return short of `limit` or miss better matches. Highly selective filters generally require a wider candidate search to hold recall steady.

saying these in an interview costs you the question

  • Treats numCandidates as the number of results returned
  • Sets numCandidates equal to limit to save time
  • Assumes vector search results are always the true nearest neighbours
  • Tunes the value by feel with no recall measurement
  • Combines exact true with numCandidates in one query

context