Guide
GraphRAG in production: what the paper leaves out
A paper version of GraphRAG builds a graph once and answers a question against it. A production version has to absorb documents one at a time, stop the same supplier from appearing under three names, and decide per question whether the graph is the right tool at all. Three gaps, and the code we run in each.
1 · The same entity has three names
Extraction is the easy half. In a real corpus the same supplier arrives as a legal name, a trading name and a typo, and every answer downstream inherits the split. Merging has to be an operation against the graph store, not a cleanup script somebody runs once. Ours merges into a target entity through LightRAG's own merge, so the graph holds the result instead of a side table.
async def merge_entities(user, source_id, source_entities, target_entity):
source = await get_manageable_source(user, source_id)
rag = await get_lightrag(source)
result = await rag.amerge_entities(
source_entities=source_entities,
target_entity=target_entity,
)
return {"ok": True, "target": target_entity, "merged": source_entities}2 · Documents arrive one at a time
A paper pipeline loads a corpus; a product loads one file and waits. Extraction is LLM-bound, so the write cannot finish inside the request. We insert a processing row and return immediately, then run the LightRAG write in a background task under a timeout guard. A timeout is recorded as timeout_may_have_partial_index and retrieval only trusts documents marked ready — a half-written graph is a real failure mode, not a theoretical one.
async def process_document_ingest(source, document_id, text) -> None:
try:
await asyncio.wait_for(
insert_document(source, document_id, text),
timeout=get_settings().ingest_timeout_seconds,
)
_mark_document(document_id, "ready")
except asyncio.TimeoutError:
_mark_document(
document_id,
"failed",
{"error": "timeout_may_have_partial_index"},
)3 · The graph is not a retrieval policy
Storing relations does not tell you when to traverse them. The module exposes five query modes and defaults to mix; picking the mode is a per-question decision, and the wrong mode returns a confident answer built on the wrong evidence. Retrieval asks LightRAG for context only, so the graph lookup itself does not pay for a generation call.
SUPPORTED_QUERY_MODES = {"local", "global", "hybrid", "naive", "mix"}
async def search(user, query, limit=10, *, source_id=None, mode="mix"):
normalized_mode = mode if mode in SUPPORTED_QUERY_MODES else "mix"The module
All four LightRAG stores — KV, vector, document status and graph — run on Postgres, so a relationship question and a document question hit the same database. The query path takes the mode as a parameter and returns context for our layer to compose.
rag = LightRAG(
working_dir=_working_dir(),
workspace=source.workspace,
llm_model_func=_llm_model_func,
llm_model_name=_require(settings.agent_model, "AGENT_MODEL"),
embedding_func=EmbeddingFunc(...),
kv_storage="PGKVStorage",
vector_storage="PGVectorStorage",
doc_status_storage="PGDocStatusStorage",
graph_storage="PGGraphStorage",
)async def query_source(source, query, *, mode="mix", limit=10):
rag = await get_lightrag(source)
return await rag.aquery(
query,
param=QueryParam(mode=mode, top_k=limit, only_need_context=True),
)What it costs
- Ingest pays the LLM bill. Every document goes through the extraction model wired into LightRAG, which is the expensive half of the pipeline. We do not publish a per-document figure: it moves with the model and the document length, and a number without that context would be theatre.
- Retrieval does not pay it twice. The query asks for context only, so generation happens in our layer, where it can be swapped or skipped.
- A failed ingest is not free. The timeout path exists because a cancelled extraction may already have written part of the graph; the guard keeps those edges out of answers instead of pretending they were never written.
When not to use GraphRAG
- Single-hop lookups. If the question is “what does this policy say”, keyword plus vector answers with lower latency: in the project's own benchmark the document engine answered in 1,583 ms p50 against 3,318 ms for GraphRAG (`CompanyBrain/README.md:34-37`).
- A corpus replaced wholesale. Extraction is the cost centre; replacing the corpus means paying it again.
- Questions with no relationships to traverse. A graph adds edges, not evidence.
- A hard first-token budget. Traversal and expansion happen before the answer starts.
The GraphRAG module is one of three retrieval engines in CompanyBrain; the document and page engines answer what it cannot.