datastax/graph-rag (graph-retriever) — investigation notes¶
Repo: https://github.com/datastax/graph-rag · Stars: ~89 · License: Apache-2.0 · Investigated: 20260726 08:52
What it is¶
A small Python library (graph-retriever core + langchain-graph-retriever bindings) that overlays a
traversable graph on an existing vector store using nothing but document metadata — no re-ingestion,
no LLM extraction, no separate graph database. Retrieval = vector search for seeds, then iterative
edge expansion where "follow an edge" is compiled into a metadata-filtered similarity search against
the same store. Core package is ~10 files / ~2k lines including docstrings. Last commit 20250505.
The metadata edge model¶
Defined in packages/graph-retriever/src/graph_retriever/edges/metadata.py and edges/_base.py.
- Declaration:
EdgeSpec = tuple[str | "$id", str | "$id"]— a(source_field, target_field)pair. Source is read from the outgoing doc's metadata, target names the field matched on the incoming doc. The magic string"$id"substitutes the document ID. Examples fromdocs/guide/edges.md:("cites", "$id")follows citations out;("$id", "cites")finds citers (reverse direction is just the flipped tuple);("keywords", "keywords")is a shared-value (undirected) edge;("authors", "primary_author")is an asymmetric shared-value edge. - Runtime form (
edges/_base.py): each doc yieldsEdges(incoming: set[Edge], outgoing: set[Edge]). Outgoing edges are expressed as the incoming edge they match:MetadataEdge(incoming_field, value)(matches docs wheremetadata[field] == valueorCONTAINS valuefor list fields) orIdEdge(id). Traversal is therefore value-equality join, computed lazily — there is no edge table anywhere. - Metadata shapes: scalars or iterables of scalars (
BASIC_TYPES); anything else warns and is skipped. Nested keys supported via dot-paths (_nested_get,MetadataEdgeFunction._edges_from_dict). - Escape hatch:
edgescan be an arbitraryEdgeFunction: Content -> Edgeswhen metadata isn't in joinable shape. Edges may differ per query — they're an argument totraverse(), not the index. - Pinakes mapping: sidecar
links≈("links.cites", "$id")(IdEdge), shared-tag ≈("tags", "tags"), co-located ≈("dir", "dir"), sibling/parent-child ≈ id edges. Every planned Pinakes edge type fits this two-field vocabulary;relbecomes which EdgeSpec you enable.
The traversal strategies¶
Core loop in packages/graph-retriever/src/graph_retriever/traversal.py (_Traversal.traverse):
- Seed:
store.search_with_embedding(query, k=start_k)plus optionalinitial_root_idsviaget()— depth 0. strategy.iteration(nodes, tracker)— the strategy decides, via the tracker, what to keep/expand.- Stop when
select_kreached or nothing queued (NodeTracker._should_stop_traversal). select_next_edges()collects outgoing edges of queued nodes minus already-visited edges (_visited_edges— a hub value like a popular tag is expanded exactly once, globally) and tracks per-edge minimum depth (_edge_depths; new node depth = min over its matched incoming edges)._fetch_adjacent()→Adapter.adjacent(): perMetadataEdgea filtered similarity search withk=adjacent_k, per batch ofIdEdges aget(ids); results merged and cut to top adjacent_k by cosine similarity to the query (utils/top_k.py). Neighbor expansion is query-ranked, never raw.
NodeTracker (strategies/base.py) is the whole strategy API: select(nodes) (add to results),
traverse(nodes) (queue for expansion; enforces visited-set and max_depth), select_and_traverse,
num_remaining (select_k budget left). Selection and expansion are decoupled — a strategy may expand
without keeping, or keep without expanding.
Knobs on Strategy: select_k (total results, default 5), start_k (seeds, 4), adjacent_k
(per-edge fetch, 10), max_depth. Note: max_traverse is declared and documented but never read
by the traversal loop — a dead knob (grep traversal.py).
- Eager (
strategies/eager.py, 3 lines of logic):tracker.select_and_traverse(everything)— plain BFS until select_k or frontier exhaustion. - Mmr (
strategies/mmr.py): best-first, not BFS. Keeps a candidate pool with embeddings; score =lambda_mult * sim(query) − (1−lambda) * max sim(already-selected). Each iteration pops the single best candidate, selects it, and expands only its edges (select_and_traverse([next])then break) — the traversal frontier itself is MMR-guided.min_mmr_scoregives early termination. All local math. - Scored (
strategies/scored.py): user callablescorer(Node) -> float, max-heap (inverted__lt__), pops topper_iteration_limitper round,finalize_nodesre-sorts by score. This is the hook for edge-type/recency/PageRank-weighted traversal without touching the engine.
The LazyGraphRAG example, mechanically¶
docs/examples/lazy-graph-rag.ipynb (rendered on the docs site). Index time is LLM-free: 2wikimultihop
articles get metadata["mentions"] (link structure from the dataset) and metadata["entities"] via a
local spaCy NER transformer. The notebook estimates an LLM-built knowledge graph over the same corpus
at ~$70k, vs "basically free" for the metadata graph. At query time:
- Traversing retrieval:
GraphRetriever(edges=[("mentions","$id"), ("entities","entities")], k=100, start_k=30, adjacent_k=20, max_depth=3)→ ~100 docs. - Build an in-memory networkx
DiGraphover just the retrieved docs using the same edge function (langchain_graph_retriever/document_graph.py:create_graph— two-pass: index docs by incoming edge, then materialise outgoing matches). - Communities via iterated Girvan–Newman, keeping the partition until modularity stops improving
(
document_graph.py:_best_communities,group_by_community). - One gpt-4o structured-output call per community extracting query-relevant claims (
claim,source_id). - One gpt-4o call per claim for RankRAG-style relevance: prompt forces a True/False next token,
rank = probability of "True" from logprobs (
compute_rank). - Select top-ranked claims up to a token budget; one final answer call over the claim list.
So "lazy" = all graph analysis (community structure, claim extraction) happens post-retrieval, on a ~100-node subgraph, with the question already in hand.
Cost profile¶
- Index: embeddings only, plus local NER. No LLM. This is the load-bearing economic claim and it matches Pinakes' sync-time position exactly.
- Traversal: free apart from ANN queries — roughly
1 + Σ_depth |new_edges|filtered searches per query. No LLM ingraph-retrieveritself, ever. - LazyGraphRAG chain: per query ≈ 1 embedding + N_communities extraction calls (each carrying full
community text) + N_claims ranking calls + 1 answer call — easily dozens of gpt-4o calls. Cheap
relative to eager graph construction; expensive relative to Pinakes' free path. Strictly a
--deep-shaped workload.
What's interesting for Pinakes¶
- Structural twin: metadata-defined edges over an existing store is exactly the sidecar position.
Their conclusion after building it — the graph can live entirely in per-doc metadata, joined at
query time — validates the
linkstable + tag columns design with no graph DB. - The
(source_field, target_field)vocabulary cleanly expresses every planned Pinakes edge type, including direction (flip the tuple) and shared-value edges (same field twice). - Query-ranked expansion is the standout mechanic: neighbors are fetched per edge but kept only
top-
adjacent_kby similarity to the query. This is what keeps hub edges (popular tags) from flooding the traversal — bounded fan-out with relevance-ordered truncation, not blind adjacency. - Visited-edge (not just visited-node) dedup: a shared value expands once globally.
- Their LazyGraphRAG pipeline is a ready-made blueprint for
pnk ask --deep: traverse free, then budgeted claim-extraction + logprob ranking over communities of the retrieved subgraph only.
What to steal¶
- The knob set for
pinakes_linksdepth>1 / traversal MCP tool:select_k,start_k,adjacent_k,max_depth, plus per-edge min-depth tracking (select_next_edges) — proven, minimal, sufficient. NodeTracker's two-verb API (selectvstraverse) — decoupling "return this" from "expand this" is the right abstraction if traversal strategies ever become pluggable.- Per-edge fan-out capping ranked by query similarity (
Adapter.adjacent+top_k) — apply verbatim to shared-tag and co-located edges, which are Pinakes' hub risks. - Visited-edge set semantics: dedupe on
(edge_type, value), not only on doc ID. - Best-first MMR traversal (
Mmr._next) as a free-path option — pure numpy, no model calls, and it yields diversity-aware multi-hop expansion. - For
--deep: Girvan–Newman-until-modularity-peaks on the retrieved subgraph (~100 nodes, cheap) as the unit of claim extraction, and logprob-of-"True" as a one-token relevance scorer.
What to avoid / doesn't fit¶
- Query-time filter-join adjacency. Their store has no edge table so every hop is an ANN query
with a metadata filter. Pinakes has SQLite: precomputed
linksrows + SQL joins are cheaper, exact, and support PPR. Steal the ranking/truncation, not the join mechanism. - The LangChain layering (
GraphRetriever, transformers) — pure integration glue. - Per-claim LLM ranking calls: cost scales linearly with claims; Pinakes' local cross-encoder does the
same job for free on the free path. Reserve logprob ranking for
--deeponly, if at all. - No BM25/hybrid anywhere — seeds are vector-only. Pinakes' RRF seeding is strictly stronger.
- Don't copy
max_traverse(dead code) or the deprecatedId/kshims — API churn artifacts. - Dormancy caveat: not archived, but silent since 20250505 — right after IBM's acquisition of DataStax (announced 20250225) — and at 89 stars there is no community carrying it. Quarry only; never a dependency.
Key sources¶
packages/graph-retriever/src/graph_retriever/edges/metadata.py—EdgeSpec,MetadataEdgeFunctionpackages/graph-retriever/src/graph_retriever/edges/_base.py—MetadataEdge,IdEdge,Edgespackages/graph-retriever/src/graph_retriever/traversal.py—_Traversal,select_next_edgespackages/graph-retriever/src/graph_retriever/strategies/{base,eager,mmr,scored}.py—NodeTracker, strategiespackages/graph-retriever/src/graph_retriever/adapters/base.py—Adapter.adjacent,_metadata_filterpackages/langchain-graph-retriever/src/langchain_graph_retriever/document_graph.py—create_graph,_best_communitiesdocs/guide/{edges,strategies,traversal}.md,docs/examples/lazy-graph-rag.ipynb