No retrieval technique eliminates hallucinations. This page describes how to
reduce unsupported answers, ground them in retrieved evidence, and
make grounding inspectable and verifiable — not how to guarantee a
correct answer every time.
Why RAG systems produce unsupported answers
A hallucination is a fluent answer the evidence does not support. In a RAG system it is usually a retrieval failure rather than a model failure: the model was handed context that never contained the answer, and it filled the gap anyway. A plain vector-search pipeline embeds the question, finds the k most similar chunks, and asks the LLM to answer from them. That fails in three common ways:- Retrieval misses the connecting fact. The answer requires combining two facts from different documents (or different parts of the same document), but no single chunk contains both, so similarity search never surfaces the link between them. Multi-hop questions — “who manages the office that Acme opened in 2026?” — are the usual casualty.
- Chunks are similar but not relevant. Cosine similarity rewards topical overlap, not answer-bearing content. A chunk can rank highly because it shares vocabulary with the question while containing none of the facts needed to answer it.
- The model fills the gap. When the retrieved context is incomplete, an LLM will usually still produce a fluent, confident-sounding answer rather than say “I don’t know” — because nothing in the pipeline checks whether the context actually supports the claim before generation.
How each mechanism grounds an answer
Retrieval is handled byMultiPathRetrieval
(multi_path.py),
the SDK’s default retrieval strategy. It runs several independent search
mechanisms in parallel and merges their results, so a fact only needs to be
found by one path to make it into the final context — see
Retrieval for the full step-by-step breakdown. The mechanisms
named in the opening paragraph map onto it like this:
Vector search
Embedding similarity search runs in two places: overChunk text (finds
passages with similar meaning, even without shared keywords) and over
RELATES edges (finds structured facts — Entity —[REL]→ Entity — by
meaning). They use different FalkorDB procedures: chunk and entity search call
db.idx.vector.queryNodes, while edge search calls
db.idx.vector.queryRelationships (FalkorDB ≥ 4.2), falling back to a
vec.cosineDistance Cypher scan on older servers
(search_relates_edges() in
entity_discovery.py,
retrieve_chunks() in
chunk_retrieval.py).
Both paths return a similarity score, but note where that score is and isn’t
used as a filter: facts are thresholded by filter_facts_by_relevance(),
whereas chunk retrieval applies no similarity floor — it takes the top k
and ranks them. Scores reach you, but low-relevance passages are not dropped
for you.
Full-text / keyword search
A fulltext index over chunk text and entity names catches exact keyword and name matches that embedding similarity sometimes misses — useful for rare proper nouns, IDs, or terms the embedding model doesn’t weight heavily. This runs as an independent path alongside vector search rather than as a fallback, so both contribute candidates every query.Graph traversal and relationship expansion
Entities discovered in the question are expanded outward through the graph: 1-hop direct relationships and 2-hop indirect connections (expand_relationships() in
relationship_expansion.py),
plus MENTIONED_IN traversal from an entity back to the chunks it was
extracted from. This is what lets the pipeline answer questions that require
connecting two facts — the case a single similarity search misses — because
the connection is a graph edge, not a coincidence of vocabulary.
Ontology-guided text-to-Cypher
The LLM can optionally generate a read-only Cypher query against the graph schema for questions that need structural answers (counts, exact lists, multi-hop paths) that text search can’t express (execute_cypher_retrieval() in
cypher_generation.py).
This path is off by default (enable_cypher=False) and adds latency when
enabled — treat it as an opt-in capability to turn on once your ontology is
stable, not a default-on feature:
Given initialized embedder, llm, and rag instances:
Cosine reranking
Passage candidates gathered from every path are pooled, then reranked by cosine similarity against the question using the embeddings already stored on eachChunk node at ingestion time — no extra embedding API call
(rerank_chunks() in
result_assembly.py).
Knowledge-graph facts go through a separate, higher-threshold filter
(filter_facts_by_relevance()) instead, since short fact strings have higher
similarity variance than prose passages. Only the top-ranked, above-threshold
evidence reaches the LLM prompt.
Inspecting the evidence
Every extracted entity is linked back to its source text by aMENTIONED_IN edge (__Entity__ → Chunk), written during ingestion
(see the ingestion pipeline). That edge is what lets the
retrieval pipeline trace an entity mentioned in an answer back to the exact
passage it came from, and it’s what the MENTIONED_IN graph traversal path
(above) uses to fetch supporting passages for the entities discovered in a
question. Because the chunk is the unit that MENTIONED_IN points at, an
answer can always be walked back to the document text that supports it.
To see this trail yourself, pass return_context=True to completion(). The
returned RagResult.retriever_result then contains the full set of retrieved
items — a list of RetrieverResultItem objects, each with content, score
and a metadata dict — including the passages section with each cited
source chunk:
passages section is prefixed with [Source: <document path>] when the source document could be resolved, so you can confirm which
document (and, via MENTIONED_IN, which chunk) backs a claim in the answer —
rather than trusting the answer at face value.
retrieve() runs the same retrieval path without reaching the generation
call, which makes it the natural place to put a gate: you decide whether to
generate before paying for a generation. (It is not LLM-free — see the cost
note below.)
Defining an abstention path
Grounding only helps if the application acts on it. If retrieval comes back empty — or with nopassages / facts evidence — the model still generates a
fluent answer unless the application checks first. The pattern is small:
retrieve, count the supporting items, and either answer or return an explicit
evidence-insufficient response.
retrieve() before generation is the point: an unsupported
question never reaches the generation call. Three details matter in practice:
- Filter non-evidence sections. The default
MultiPathRetrievalstrategy emits an answer-formathintsection that carries no document evidence; counting it would defeat the gate. - Only threshold on scores when the pipeline produces them.
MultiPathRetrievalreturns section items withscore = None— scoring happens internally during fact filtering and passage reranking and is not surfaced per section. Attach aCosineReranker(or useLocalRetrieval) to populateitem.scorebefore settingmin_score. See Strategies. - A count-only gate barely gates. No retrieval path applies a similarity
floor: chunk vector search takes its top 15 hits and reranking takes the
top k of those, neither with a threshold. A populated graph therefore
returns something for almost any question, however unrelated, so
min_itemsalone fires only on an empty graph, a failed ingestion or a retrieval error. Scores are what make the gate discriminating. - Return a fixed refusal string, rather than prose the model wrote, so abstentions stay machine-detectable in logs and evaluations.
- Don’t share one
Contextacross both calls.Contextstarts its clock when it is constructed andremaining_budget_msnever resets, so actxpassed toretrieve()and then reused forcompletion()charges the gate’s latency against the generation’s budget — and the gate is the cheap half. Passctx.child()to the second call: it inherits the tenant, trace and remaining budget, but starts its own clock.
"passages" specifically if only source-text-grounded
answers are acceptable for your use case. A min_score is corpus- and
embedding-model-dependent: read real scores off your own questions before
picking one.
The gate is not free, on either path.
MultiPathRetrieval issues an LLM
keyword-extraction call on every retrieval, so a refused question still
costs one LLM call — it saves the generation call, not all of them. And
because completion() retrieves again internally, an answered question costs
three LLM calls instead of two and repeats the graph and vector work. The
pattern pays off when a meaningful share of your traffic is unanswerable;
when almost everything is answerable, gate on a cheaper signal or rely on
prompt-level abstention.examples/grounded_answers_with_abstention.py,
and is covered by graphrag_sdk/tests/test_grounded_abstention.py.
Schema and ontology as a guardrail
Extraction is constrained by the ontology you define: only entity labels and relationship patterns present in the schema are extracted and written to the graph (see Graph Schema and Extraction). Off-schema entities and relationships are pruned before the graph is written, so extraction output that doesn’t fit is never silently promoted to a stored “fact”:Person, Organization, and
WORKS_AT cannot retrieve (and therefore cannot ground an answer in) a
relationship type it never captured. A narrower, well-scoped ontology
produces fewer but more precise facts; a broad one captures more but with
more noise for the reranker to filter. Fewer junk facts in the graph means
fewer junk facts in the retrieved context — grounding is only as good as what
got stored. See Ontology Discovery if you would rather
derive the schema from your corpus than write it by hand.
Verifying grounding
Treat grounding as something you measure, not something you assume:- Check the trail. Run
completion(question, return_context=True)and confirm thepassagesandfactssections contain the specific fact the answer depends on — not just topically related text. - Cross-reference the source. Compare
[Source: <document>]tags against the actual source document to confirm the passage says what the answer claims. - Prefer structured evidence for high-stakes claims. Questions
answerable from the
factssection (RELATESedges) or acypher_resultssection beat free-text passages alone — structured graph results are direct query output, not LLM-summarized prose. - Log the trail. Store
retriever_resultitems next to each served answer so any claim can be re-checked later. - Test the refusal path. Assert that a question with no supporting context returns your evidence-insufficient response.
- Track abstention rate. A rate near zero usually means the gate is too
loose; a rate that spikes usually means ingestion or
finalize()coverage regressed.
Related
- Reliability and Grounding — the API-level reference behind every mechanism on this page
- Architecture — how chunks, entities and
MENTIONED_INedges fit together - Retrieval — the retrieval paths that gather evidence
- Strategies — retrieval and reranking strategies, including scored ones
- Benchmark — measured accuracy and reproduction instructions