Traversal beats retrieval when the answer is two hops away

Vector search finds passages that sound like the question. Plenty of real questions are not answered by any single passage — and no amount of better embeddings fixes that.

· 6 min read· Closing Brackets

A support agent grounded in a company's documentation answers policy questions well and account questions badly, and the team's instinct is to blame the embeddings. They try a larger model, a smaller chunk size, a reranker. The numbers move a little. The failure mode does not change, because it was never a retrieval quality problem.

"Is this customer affected by the outage?" is not answered by any document. It is answered by walking from the customer to their subscribed services, from those services to the incident, and checking whether the intersection is non-empty. No passage in the corpus contains that sentence, so no similarity search can return it.

The one-hop test

Before building any grounded system, it is worth sorting the real questions into two piles. The test is simple: could a single well-written document answer this?

  • One hop — "what is our refund window", "how do I rotate an API key", "what does error 4021 mean". Vector search is excellent at these and you should use it.
  • Two or more — "which of my accounts are at risk", "who else reported this before it was escalated", "what changed between the last two invoices for this customer". These need a structure, and retrieval will confidently return something plausible and wrong.
The dangerous case is not the question retrieval cannot answer. It is the one it answers plausibly.

Composing the two

The pattern that works is not choosing between them. Retrieval is very good at one thing traversal is bad at — turning an ambiguous natural-language question into a specific entity to start from. Traversal is very good at the thing retrieval cannot do at all. So: retrieve to locate, traverse to gather, then generate over the gathered neighbourhood.

const seeds  = await vectorSearch(question, { k: 5 });
const anchor = await resolveEntity(seeds);       // passages → a node

const hood = await graph.traverse(anchor, {
  edges: ["subscribes_to", "reported", "billed_for"],
  depth: 2,
  since: "90 days",
});

return generate(question, hood);   // grounded in structure, not similarity

The context handed to the model is now a small, connected, explicable subgraph rather than five passages that scored well. It is also dramatically smaller, which matters more than it sounds — a tight, relevant context is worth more than a large one, and it costs less.

Two failure modes to plan for

The first is anchor resolution. If the retrieval step lands on the wrong entity, everything after it is confidently wrong about a different customer. Anchors need a confidence threshold and a defined behaviour below it — asking which of two accounts was meant is a far better outcome than guessing.

The second is neighbourhood explosion. In any real graph some nodes are enormous — a shared service, a popular product, a support macro. A depth-two traversal through one of those returns half the database. Cap the fan-out per hop, order neighbours by edge weight or recency, and treat hub nodes as terminal rather than as things to expand.

Both are ordinary engineering problems with ordinary engineering answers, which is rather the point. Once the relationships are modelled, the hard part of grounding a system in your business stops being a research question and becomes a query you can read.

More in Graph engineering

Graph engineering: model the edges first

Most business questions that seem hard are relationship questions being asked of a schema that only stores things. Modelling the connections first is what makes them ordinary.

Got this problem right now?

Describe it and we will tell you what we would do about it. One business day, no charge for the first answer.