fix(retriever): search a graph source classically when its graph answers nothing

Only a raise routed a source to the ClassicRAG fallback, and every graph read
logs its own failure and returns empty. So a query that broke, a half-built
graph and a walk that genuinely found nothing were indistinguishable, and each
made the source contribute nothing at all to the answer -- no fallback, no
vector blend, which is skipped by the same early return.

A graph source that produces no documents now joins the classic batch, exactly
as a source with no graph already does.

The passage stage also rescanned every node's chunk list once per candidate.
It inverts the links once instead: 7.3 ms to 0.16 ms on a 400-node subgraph at
the candidate cap, with identical output.
This commit is contained in:
Alex committed 2026-09-20 10:45:03 +01:00
1 parent 8c5a5190d9
commit bc0ef9f3b0
3 files changed
+76 -16

No files matched your search

+19
View File
@@ -130,3 +130,22 @@ class TestChunkSimilaritiesGuard:
store = object.__new__(GraphStore)
assert store.chunk_similarities("src", chunk_ids, embedding) == {}
@pytest.mark.unit
class TestNodesByChunk:
"""The passage stage inverts node->chunks once instead of rescanning."""
def test_inverts_and_keeps_node_order(self):
from docsgpt.retriever.graph_rag import _nodes_by_chunk
assert _nodes_by_chunk({"n1": ["c1", "c2"], "n2": ["c2"], "n3": []}) == {
"c1": ["n1"],
"c2": ["n1", "n2"],
}
def test_no_links_invert_to_nothing(self):
from docsgpt.retriever.graph_rag import _nodes_by_chunk
assert _nodes_by_chunk({}) == {}
assert _nodes_by_chunk({"n1": None}) == {}
+14 -3
View File
@@ -180,7 +180,9 @@ class TestGraphRAGPoolDiscipline:
mock_store_cls.return_value = store
rag = _make_retriever()
with patch.object(rag, "_graph_docs_for_source", return_value=[]):
# A real result: an empty one now falls back like a failure does.
graph_docs = [{"title": "g", "text": "graph text", "source": "src1", "filename": "g"}]
with patch.object(rag, "_graph_docs_for_source", return_value=graph_docs):
with patch.object(rag, "_classic_for_sources") as classic:
rag._get_data()
@@ -355,15 +357,24 @@ class TestGraphRAGHappyPath:
@patch("docsgpt.retriever.graph_rag.num_tokens_from_string", return_value=10)
@patch("docsgpt.retriever.graph_rag.GraphStore")
@patch("docsgpt.retriever.graph_rag.graphrag_available", return_value=True)
def test_no_seeds_returns_empty(
def test_a_graph_that_answers_nothing_falls_back_to_classic(
self, _avail, mock_store_cls, _tok, _patch_llm_creator, _patch_embed
):
"""Empty is not an answer. Every graph read swallows its own errors and
returns nothing, so "no rows" covers a broken query as much as a walk
that found nothing — and the source would contribute nothing at all,
with no fallback, because only a raise routes one to ClassicRAG."""
store = _store_with_graph([], [], {}, {}, [])
store.count_nodes_many.side_effect = lambda ids: {s: 5 for s in ids}
mock_store_cls.return_value = store
rag = _make_retriever()
assert rag._get_data() == []
seen = _recording_classic(rag, [_CLASSIC_DOC])
docs = rag._get_data()
assert seen == [["src1"]]
assert [doc["text"] for doc in docs] == ["classic"]
# ── IDF down-weighting ────────────────────────────────────────────────────────