HomeJournalThis post

HNSW Vector Search: Recall vs. Latency

Tune M, ef_construction, and ef_search from an exact-neighbor fixture and a recall, latency, memory, and build-cost Pareto frontier.

JP
JP Casabianca
UI/UX designer and full-stack engineer · Bogotá

HNSW vector search is useful only when recall, query latency, index memory, and build cost are measured on the workload that will actually ship. This guide turns graph degree, construction effort, and query exploration into a reproducible tuning decision instead of inherited defaults.

The intended reader owns a semantic-search or retrieval service and can label a small query set. You will leave with a parameter sweep, a Pareto frontier, and a release rule that separates index-build choices from per-query operating choices.

The operating vocabulary connects vector index tuning, approximate nearest neighbors, recall at k, and ef_search; each term identifies a measurable part of the same retrieval decision.

HNSW vector search: a layered proximity graph with a measured search frontier An authored system diagram connects Entry layer, Sparse hops, Dense neighbors, Top-k proof as one decision path. entrydescentfrontierproof
  1. Entry layer
  2. Sparse hops
  3. Dense neighbors
  4. Top-k proof
Figure 1: Search descends through sparse routing layers before expanding candidates in the dense base layer; the highlighted frontier is controlled by the query exploration budget.

HNSW vector search needs exact ground truth

HNSW vector search begins with building a labeled recall fixture with an exhaustive nearest-neighbor result for every representative query. This is the narrow boundary for this section; everything outside it belongs in a separate capacity, policy, or product decision. The original HNSW paper defines the hierarchical navigable small-world graph and explains the construction and search mechanisms behind the tunable trade-offs.

Work through four explicit moves:

  • Sample queries by traffic cohort, not convenience
  • Compute exact top-k with the production distance metric
  • Store neighbor identifiers and distances
  • Hash the corpus, query set, and labels

In a working review, I would put the first move beside the input fixture, use the second to expose the decision boundary, and make the third observable before polishing the interface. The fourth move is the release check. This order matters because a convincing happy path can still conceal incompatible state, unfair scheduling, inaccessible fallback, or ownership ambiguity. Keeping each move named also lets another engineer reproduce the result without inheriting private context.

The named failure mode is using click-through as the only relevance label. Its consequence is ranking quality and retrieval mechanics become impossible to separate.

Mitigate it with an exact-neighbor fixture beside product relevance judgments. The release receipt is versioned vectors, queries, metric, k value, and exact result hashes. Those fields connect the implementation to the article's single question and make a later update comparable instead of anecdotal.

Separate graph shape from search effort

A useful HNSW vector search decision depends on treating graph degree and construction exploration as rebuild decisions while keeping candidate effort adjustable per query. This is the narrow boundary for this section; everything outside it belongs in a separate capacity, policy, or product decision. The pgvector HNSW documentation documents distinct graph-degree, construction, and query-time controls in a production implementation.

Work through four explicit moves:

  • Choose a small graph-degree range that fits memory
  • Sweep construction effort for each degree
  • Freeze each finished index image
  • Only then vary query effort on identical queries

In a working review, I would put the first move beside the input fixture, use the second to expose the decision boundary, and make the third observable before polishing the interface. The fourth move is the release check. This order matters because a convincing happy path can still conceal incompatible state, unfair scheduling, inaccessible fallback, or ownership ambiguity. Keeping each move named also lets another engineer reproduce the result without inheriting private context.

The named failure mode is changing all three parameters in one benchmark row. Its consequence is the cause of a recall or latency change cannot be attributed.

Mitigate it with a nested experiment with immutable index identifiers. The release receipt is one build table and one query table joined by index hash. Those fields connect the implementation to the article's single question and make a later update comparable instead of anecdotal.

Plot recall@k against tail latency

The worked HNSW vector search fixture makes placing recall@10, p50, p95, and p99 query latency on the same parameter sweep rather than optimizing an average. This is the narrow boundary for this section; everything outside it belongs in a separate capacity, policy, or product decision.

Work through four explicit moves:

  • Warm each index with the same query order
  • Repeat the seeded query set five times
  • Record candidates visited and wall time
  • Plot nondominated configurations as the frontier

In a working review, I would put the first move beside the input fixture, use the second to expose the decision boundary, and make the third observable before polishing the interface. The fourth move is the release check. This order matters because a convincing happy path can still conceal incompatible state, unfair scheduling, inaccessible fallback, or ownership ambiguity. Keeping each move named also lets another engineer reproduce the result without inheriting private context.

The named failure mode is selecting the fastest median configuration. Its consequence is rare graph traversals can violate the user-facing search budget.

Mitigate it with tail percentiles and cohort-specific frontiers. The release receipt is raw per-query samples plus the script that calculates the frontier. Those fields connect the implementation to the article's single question and make a later update comparable instead of anecdotal.

SignalDecisionProof
M 16 · ef 40Fast baseline0.91 recall@10 · 7 ms
M 24 · ef 80Pareto choice0.97 recall@10 · 12 ms
M 32 · ef 160Reject for default0.98 recall@10 · 27 ms
Figure 2: The worked sweep chooses the middle configuration because the final recall point costs more than twice the query time for one percentage point.

Reproduce the parameter sweep

HNSW vector search needs an explicit rule for using a tiny executable selector that rejects dominated configurations before a deployment choice is discussed. This is the narrow boundary for this section; everything outside it belongs in a separate capacity, policy, or product decision.

Work through four explicit moves:

  • Represent every run as recall, latency, and memory
  • Discard any row worse on all decision axes
  • Apply the declared minimum recall
  • Choose the lowest latency point that clears it

In a working review, I would put the first move beside the input fixture, use the second to expose the decision boundary, and make the third observable before polishing the interface. The fourth move is the release check. This order matters because a convincing happy path can still conceal incompatible state, unfair scheduling, inaccessible fallback, or ownership ambiguity. Keeping each move named also lets another engineer reproduce the result without inheriting private context.

The named failure mode is eyeballing a chart after seeing the preferred configuration. Its consequence is the decision threshold moves to protect an intuition.

Mitigate it with a predeclared recall floor and deterministic Pareto calculation. The release receipt is a passing test that names the selected configuration and rejects domination. Those fields connect the implementation to the article's single question and make a later update comparable instead of anecdotal.

Runnable artifact. Save this as hnsw-vector-search-tuning.test.mjs and run node --test hnsw-vector-search-tuning.test.mjs. Expected result: PASS: cfg-b is the lowest-latency recall-qualified point. The checked-in copy lives with this batch's evidence.

import assert from "node:assert/strict";
import test from "node:test";

const runs = [
  { id: "cfg-a", recall: 0.91, p95: 7, memory: 1.0 },
  { id: "cfg-b", recall: 0.97, p95: 12, memory: 1.3 },
  { id: "cfg-c", recall: 0.98, p95: 27, memory: 1.7 },
];

test("selects the lowest-latency point above the recall floor", () => {
  const choice = runs.filter((run) => run.recall >= 0.96).sort((a, b) => a.p95 - b.p95)[0];
  assert.equal(choice.id, "cfg-b");
  console.log("PASS: cfg-b is the lowest-latency recall-qualified point");
});

Budget index memory and build time

In production, HNSW vector search turns on counting resident graph bytes, vector bytes, build duration, and peak construction memory as separate capacity constraints. This is the narrow boundary for this section; everything outside it belongs in a separate capacity, policy, or product decision.

Work through four explicit moves:

  • Measure steady-state resident bytes
  • Capture peak memory during construction
  • Time a clean build and an incremental update
  • Project replica and failover multiplication

In a working review, I would put the first move beside the input fixture, use the second to expose the decision boundary, and make the third observable before polishing the interface. The fourth move is the release check. This order matters because a convincing happy path can still conceal incompatible state, unfair scheduling, inaccessible fallback, or ownership ambiguity. Keeping each move named also lets another engineer reproduce the result without inheriting private context.

The named failure mode is reporting only the serialized index size. Its consequence is a configuration can fit storage yet fail during build or replica warm-up.

Mitigate it with four explicit memory and time budgets. The release receipt is capacity figures tied to corpus size, dimensions, and replica count. Those fields connect the implementation to the article's single question and make a later update comparable instead of anecdotal.

Set query effort by request cohort

Safe HNSW vector search requires letting interactive lookup, background enrichment, and high-recall investigation use different bounded search effort. This is the narrow boundary for this section; everything outside it belongs in a separate capacity, policy, or product decision.

Work through four explicit moves:

  • Define cohorts from product intent
  • Assign each a recall and latency objective
  • Cap exploration within measured values
  • Log the chosen cohort and actual work

In a working review, I would put the first move beside the input fixture, use the second to expose the decision boundary, and make the third observable before polishing the interface. The fourth move is the release check. This order matters because a convincing happy path can still conceal incompatible state, unfair scheduling, inaccessible fallback, or ownership ambiguity. Keeping each move named also lets another engineer reproduce the result without inheriting private context.

The named failure mode is exposing unrestricted search effort as a client parameter. Its consequence is one expensive request can consume shared search capacity.

Mitigate it with server-owned presets with admission limits. The release receipt is a policy table mapping cohort to objective, cap, and fallback. Those fields connect the implementation to the article's single question and make a later update comparable instead of anecdotal.

  1. FreezeFreeze

    Version vectors, distance metric, labels, and machine shape.

  2. BuildBuild

    Sweep M and ef_construction while recording time and bytes.

  3. QueryQuery

    Sweep candidate effort against exact top-k ground truth.

  4. ChooseChoose

    Select a Pareto point and preserve a runtime escape hatch.

Figure 3: Build parameters are frozen before query effort changes, preventing two different costs from being credited to the same knob.

Test updates and filtered queries

A HNSW vector search rollout should preserve replaying inserts, deletes, tombstones, metadata filters, and skewed neighborhoods against the chosen static-index point. This is the narrow boundary for this section; everything outside it belongs in a separate capacity, policy, or product decision.

Work through four explicit moves:

  • Append a known vector cohort
  • Delete neighbors used by the fixture
  • Apply selective and broad filters
  • Recompute exact results after each mutation

In a working review, I would put the first move beside the input fixture, use the second to expose the decision boundary, and make the third observable before polishing the interface. The fourth move is the release check. This order matters because a convincing happy path can still conceal incompatible state, unfair scheduling, inaccessible fallback, or ownership ambiguity. Keeping each move named also lets another engineer reproduce the result without inheriting private context.

The named failure mode is shipping a result measured only on a pristine immutable corpus. Its consequence is recall can decay as production data and filters reshape traversal.

Mitigate it with mutation fixtures and scheduled rebuild criteria. The release receipt is recall deltas by mutation type with a rebuild trigger. Those fields connect the implementation to the article's single question and make a later update comparable instead of anecdotal.

Ship the Pareto rule, not a magic number

The evidence for HNSW vector search is strongest when recording the workload boundary where the selected configuration wins and the observations that should reopen tuning. This is the narrow boundary for this section; everything outside it belongs in a separate capacity, policy, or product decision.

Work through four explicit moves:

  • Name the minimum acceptable recall
  • Name the p95 latency and memory ceilings
  • Preserve the nearest rejected alternatives
  • Schedule retuning after corpus or hardware drift

In a working review, I would put the first move beside the input fixture, use the second to expose the decision boundary, and make the third observable before polishing the interface. The fourth move is the release check. This order matters because a convincing happy path can still conceal incompatible state, unfair scheduling, inaccessible fallback, or ownership ambiguity. Keeping each move named also lets another engineer reproduce the result without inheriting private context.

The named failure mode is documenting only graph degree 24 and query budget 80. Its consequence is future maintainers copy values after their evidence has expired.

Mitigate it with a decision record containing thresholds, fixture hashes, and boundaries. The release receipt is a signed release note linking the frontier to runtime dashboards. Those fields connect the implementation to the article's single question and make a later update comparable instead of anecdotal.

Put the decision into practice

Tune HNSW vector search by freezing a representative corpus, calculating exact top-k neighbors, separating index construction from query effort, and choosing a nondominated point that clears declared recall, latency, and memory limits. The correct values are properties of that evidence, not universal defaults.

Start with the runnable three-row selector, then replace its numbers with raw measurements from one production-shaped cohort. Keep the exact results, index hashes, and nearest rejected configurations so corpus growth or hardware changes trigger a comparable retest.

The method connects to four existing Journal notes: RAG citations that survive document change, verified semantic caching, database isolation foundations, AI evaluation measurement contracts. Each link covers an adjacent boundary while this article stays focused on one outcome. Keep the fixture, visual evidence, command output, and release receipt together so the next review can test the claim against the same starting conditions.