graphwalk: guide for coding agents¶
graphwalk answers questions over a knowledge graph by walking it. Each hop is a classification decision: the current node's relations (plus STOP) are the options, and a fast decision model (Jev) returns a probability for each. A walk's answer comes with a confidence, so unsure walks can be re-run ("escalated") with a slower LLM decider.
This guide says when graphwalk is the right tool, when it is not, and how to use it.
Every claim below comes from a measured experiment. The docs/results-*.md files in the
repository hold the tables, CIs, and caveats. Samples are small (50–300 questions per
setting, mostly one seed), so treat the numbers as directions, not guarantees.
Decide first: is graphwalk the right tool?¶
| Your situation | Use | Evidence |
|---|---|---|
An agent that already explores a knowledge graph with low-level tools (neighbors, a relation list) |
Also give it walk (the MCP server does). The agent calls walk first, then checks or continues with neighbors. |
A8b: a strong agent with walk cost 17% less (95% CI 8–27%) at equal F1 (0.76 vs 0.75, n=100, WebQSP); with a cheap agent, F1 was equal and the cost a wash. Text search over the same facts tied on F1, but only while the model knew the names: with names replaced by aliases, graph tools beat it by 0.25 F1 (P3) |
| A knowledge graph you already have (triples, RDF), any schema size, and you want the best accuracy | Have an LLM write the query from the relations around the question's entity (fetch them with one adjacency query; for a whole large schema, it fits a 1M-token context too). | A6: Freebase, 5,419 relations in one graph: 0.66 vs 0.49 F1 for walking |
| The same, but cost or latency matters more than ~15 F1 points, or you need a per-answer confidence | graphwalk walk: 6× cheaper and ~4× faster than the LLM path writer, with a confidence that ranks its answers |
A6; A2 (AUROC 0.92 on WebQSP) |
| A noisy graph extracted from text (inconsistent relation names, edges in either direction) | graphwalk walk beats LLM-written paths, but every method is weak here; prefer RAG over the source text |
E2b: 3–5× the F1 of LLM-written paths at 1/20–1/35 the cost, absolute F1 0.18–0.25 |
| A graph with a small, clean schema (≲ 50 self-describing relations) | Have an LLM write the query (a relation path, Cypher, SPARQL). It is more accurate, and here also cheaper than walking with escalation. | E2: MetaQA 3-hop 0.955 vs 0.887 F1; A2: WebQSP 0.76 vs 0.54 F1 |
| You want a cheap first pass that knows when it's wrong, and to pay for a strong model only on hard queries | walk with escalate_below, but expect modest savings: escalating to an LLM-written path matched its accuracy for ~19% less on Freebase (A6, offline), and on MetaQA the path writer alone is cheaper |
A1, A6 |
| Compositional questions with constraints ("the earliest...", "which X that also Y", superlatives, comparisons) | Not graphwalk. It follows one relation path from the topic entity and does no filtering, ranking, or intersection. Use a dedicated KG-QA method or LLM-written queries with filters. | A2: CWQ hits@1 ≈ 0.28 for every system tried, vs ≈ 0.63–0.69 published |
| Questions over documents (no curated graph) | Multi-step RAG, not graphwalk. Building a graph from text loses dates, order, and qualifiers. | M7, E4: RAG wins by 10–19 F1 points |
| Retrieval over documents where questions chain through named entities ("the director of the film X...") | graphwalk locate(mode="hybrid") as the retriever, then read the text |
E1: +18 points recall over dense on 2Wiki; ties on HotpotQA; −5 on FanOutQA |
| Broad or list-style questions over documents | Plain dense retrieval | E1 |
| As an add-on to an existing multi-step RAG loop | Don't, unless your reader model is expensive. It cuts LLM rounds by 16–21% but not error, and its own calls cost more than the rounds saved at cheap-reader prices. | E7 |
Rules of thumb:
- The graph must hold the answer. graphwalk's answers are nodes. If facts live in free text, attributes, or qualifiers the graph lacks, no walk finds them.
- A decider that reads probabilities buys cost, speed, and confidence, not
accuracy. An LLM writing the query is as accurate or better. Jev is 3–4× cheaper
and 7–10× faster per decision than an LLM deciding, and its confidence separates
right from wrong answers (AUROC 0.92–0.97 on MetaQA 2–3 hop and WebQSP; weaker,
0.64–0.71, on MetaQA 1-hop, 2Wiki, and CWQ). The
logprobdecider with an open-weights model matched that (P1) at ~10× Jev's latency: the confidence comes from reading probabilities, not from Jev. Thellmdecider's stated scores barely do (0.50–0.69). - Walking is fast; the model calls dominate. graphwalk adds ~10 ms per walk on typical nodes and ~0.1–0.5 s at nodes with 20k neighbors (SQLite, 1.4M edges).
Install and keys¶
pip install 'graphwalk[embeddings]' # or, from a clone: uv sync --extra embeddings
Extras: embeddings (local fastembed embedder: prefilter and dense locate), llm
(LiteLLM: text ingestion, the LLM decider, escalation), mcp (the MCP server), eval.
Each hop is decided by a decider, and you choose it (GRAPHWALK_DECIDER, or
Index.open(decider=...) with your own DecisionBackend):
| Decider | What it is | Keys and settings | Measured |
|---|---|---|---|
jev (default) |
TypeSafe's Jev classification model | OPENROUTER_API_KEY (model typesafe/jev-1.13) or TYPESAFE_API_KEY with GRAPHWALK_DECISION_PROVIDER=typesafe |
Fastest: ~0.3–0.5 s per hop |
logprob |
Any chat model that returns token log-probabilities, at any OpenAI-compatible endpoint (OpenRouter, vLLM, llama.cpp, OpenAI) | GRAPHWALK_DECIDER_MODEL, GRAPHWALK_DECIDER_BASE_URL (default OpenRouter, with an open-weights model), GRAPHWALK_DECIDER_API_KEY |
With Qwen3.8-27B (the default): as accurate as Jev, confidence as informative (P1); ~10× slower through OpenRouter. Other models vary (DeepSeek V4 Flash: EM 0.44 vs 0.80), so check yours |
llm |
A chat model that states a score per option | the LLM settings below | Works, but its confidence is barely informative (stated scores, not probabilities) |
GRAPHWALK_DECISION_FALLBACK=logprob (or llm) uses another decider only when no Jev
key is set. LLM (escalation, ingestion): OPENROUTER_API_KEY, or OPENAI_API_KEY /
ANTHROPIC_API_KEY / XAI_API_KEY with GRAPHWALK_LLM_PROVIDER; set
GRAPHWALK_ESCALATION_DECIDER=logprob so escalated answers carry probabilities too.
Importing triples needs no key at all.
Recipe 1: question answering over an existing knowledge graph¶
graphwalk import kg.nt --graph kg.db # .nt, .csv, .tsv, .jsonl, or a|b|c .txt
graphwalk query kg.db "Where was the director of Inception born?" --escalate-below 0.9
(query walks with TraversalConfig.kgqa() by default; flags such as --hop-mode or
--strategy override single settings.)
import asyncio
from graphwalk import Index, TraversalConfig
async def main() -> None:
async with Index.open(
"kg.db",
traversal=TraversalConfig.kgqa(), # the default; shown for clarity
node_types=["person", "film", "place"], # your graph's types, for the type hint
escalate_below=0.9,
) as index:
await index.import_triples("kg.nt") # once; idempotent
result = await index.walk("Where was the director of Inception born?")
if result.best is None:
print("no answer") # entities not linked, or nothing reached
else:
print(result.best.names, result.confidence, result.escalated, result.cost_usd)
asyncio.run(main())
TraversalConfig.kgqa()is the configuration measured in the experiments (greedy relation hops, typed options, answer-type hint, depth ≤ 4), and whatIndex, the CLI, and the MCP server walk with unless given another. A bareTraversalConfig()(beam search over individual neighbors) is a low-level default forTraverser, not a measured setting.result.best.namesis a set of entities (a relation hop moves to all its targets).result.best.pathis the list of hops taken; show it to users as the justification.result.confidenceisexp(score), the length-normalized path probability. Treat it as a ranking signal, not the probability of being right: it is overconfident where accuracy is low (WebQSP: mean 0.80, 54% correct). A logistic fit on log-confidence over 100+ labeled answers turns it into one (P7). 0.9 was the best default threshold on MetaQA; on a graph with a different shape, check it on 50–100 labeled questions before relying on it.- A walk the decider never answered (only forced moves to a dead end:
result.best.n_choices == 0) gets confidence 0.001, so it escalates. On graphs with broken links (one entity split into differently named nodes) these walks are mostly wrong (P9). escalate_belowre-walks unsure queries with the LLM decider (needs thellmextra and an LLM key), or withfallback_decider=if you pass one.result.cost_usdincludes both walks.- Start entities are found by matching names in the question. If you already know them
(from your own entity linker or the user's selection), call the CLI with
--start <node id>(repeatable).
Triples formats: CSV/TSV with a header (subject,relation,object, aliases
head/source/s, predicate/type/p, tail/target/o, plus optional subject_type,
object_type, subject_name, object_name); JSONL with the same keys; N-Triples
(rdfs:label becomes the name, rdf:type the type, literals become literal nodes).
Give nodes readable names and types: decisions are made from names, relation names,
and types, not from opaque ids.
Recipe 2: locate passages in documents (experimental)¶
Only when your questions chain through named entities (see the table). Otherwise use dense retrieval or multi-step RAG.
from graphwalk import Index
async with Index.open("docs.db") as index: # inside async code; needs [llm,embeddings]
await index.ingest("docs/") # LLM extraction; costs money; idempotent
for location in await index.locate("Who directed ...?", k=5, mode="hybrid"):
passage = await index.read(location, context=200)
print(location.path, passage.text) # answer from the text, not the graph
Recipe 3: give an agent the graph (MCP)¶
pip install 'graphwalk[mcp,llm]'
graphwalk mcp --db kg.db # stdio; keys from the environment
Tools: walk (answers with confidence and path), locate and read (documents),
neighbors and get_node (explore), status, ingest and ingest_status. The server
also serves this guide as the resource graphwalk://guide.
Set GRAPHWALK_ESCALATE_BELOW=0.9 (after checking it on your graph) to escalate unsure
walks. The server's instructions then state the threshold, and every walk result
reports the threshold it used, so the agent knows which answers cleared it. Escalated
answers carry the LLM decider's confidence, which is not calibrated, so the agent
should verify them with neighbors.
API surface¶
Public names are those exported from graphwalk (anything else may change):
Index, WalkResult, TraversalConfig, ImportReport, IngestConfig,
IngestReport, Location, Passage, Node, Edge, Neighbor, Provenance,
FileSource, TextSource, SourceDocument, FileDocuments, StoredDocuments,
GraphwalkError, DocumentNotFoundError, StaleLocationError, guide; and for
deciders, DecisionBackend, DecisionRequest, DecisionResponse, ChoiceQuestion,
ChoiceResult, Usage, DecisionBackendError, normalize_distribution,
LogprobDecider, LLMDecider.
Index methods: open(path, **kwargs), import_triples(path), walk(query),
ingest(source), locate(query, k, mode=), read(location, context=),
neighbors(node), close(). All I/O methods are async.
CLI: graphwalk import, query, ingest, locate, migrate, mcp, guide.
Performance¶
- Per query: one Jev call per hop (~0.3–0.5 s each) plus graphwalk's overhead. MetaQA walks cost $0.05–0.15 per 1,000 queries; Freebase subgraph walks ≈ $0.2.
- SQLite: imports ~9k triples/s; a 1.4M-edge graph is ~1.1 GB on disk. Listing a node's edges takes <0.1 ms (56 ms at a 20k-neighbor hub).
- Walk overhead, excluding model calls: ~8 ms on typical nodes; 0.1 s (relation hops)
to 0.2 s (entity hops) from a 20k-neighbor hub. With an embedder, the first visit to a
hub embeds its neighbors' names (seconds with a local CPU model; cached after), so
prefer relation hops (
TraversalConfig.kgqa()) on graphs with big hubs.
Common mistakes¶
- Using graphwalk to answer questions over documents. Use RAG; use
locateonly as a retriever. - Expecting constraint handling ("first", "largest", "both A and B"). graphwalk doesn't filter, rank, or intersect answer sets; do that in your own code, or pick another tool.
- Comparing confidence across deciders, or trusting the LLM-decider fallback's: it is the model's stated scores, not probabilities, and was found barely informative.
- Passing a bare
TraversalConfig()toIndexorTraverserfor KG-QA. UseTraversalConfig.kgqa()(theIndexdefault) and override single fields. - Assuming the escalation threshold transfers. Check it on your own graph.
- Opaque node ids as names (
Q42,m.0abc). Import names (rdfs:labelor the name columns) so the decider can read the options.