mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 14:15:52 +00:00
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.
152 lines
5.1 KiB
Python
152 lines
5.1 KiB
Python
"""Chunks as nodes in the walk, and the damping that decides how far mass spreads.
|
|
|
|
``_rank_chunks`` reads a chunk's score off its entities by summing their PPR
|
|
mass, which rewards a chunk for touching *many* entities rather than the right
|
|
ones. The passage-node path puts the chunks in the graph instead, so a chunk is
|
|
reachable both by being about the question and by being connected to what is.
|
|
|
|
These tests use a stub store: the ranking is graph arithmetic, and pinning it
|
|
against a real database would measure Postgres rather than the ranking.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from docsgpt.retriever.graph_rag import GraphRAGRetriever, _damping
|
|
|
|
|
|
class _StubStore:
|
|
"""The two reads the passage path makes, and nothing else."""
|
|
|
|
def __init__(self, chunk_links, similarities):
|
|
self._chunk_links = chunk_links
|
|
self._similarities = similarities
|
|
|
|
def get_chunk_ids_for_nodes(self, source_id, node_ids):
|
|
return {n: c for n, c in self._chunk_links.items() if n in set(node_ids)}
|
|
|
|
def chunk_similarities(self, source_id, chunk_ids, query_embedding):
|
|
return {c: self._similarities.get(c, 0.0) for c in chunk_ids}
|
|
|
|
|
|
def _retriever(chunks=2):
|
|
"""A retriever without its constructor — which builds a ClassicRAG, opens
|
|
settings-driven collaborators, and has nothing to do with ranking."""
|
|
retriever = object.__new__(GraphRAGRetriever)
|
|
retriever.chunks = chunks
|
|
return retriever
|
|
|
|
|
|
def _subgraph():
|
|
return {
|
|
"nodes": [
|
|
{"id": "a", "doc_freq": 1},
|
|
{"id": "b", "doc_freq": 1},
|
|
{"id": "hub", "doc_freq": 40},
|
|
],
|
|
"edges": [
|
|
{"src_node_id": "a", "dst_node_id": "hub", "weight": 1.0},
|
|
{"src_node_id": "b", "dst_node_id": "hub", "weight": 1.0},
|
|
],
|
|
}
|
|
|
|
|
|
class TestDamping:
|
|
"""Each ranking mode runs at the damping it was measured at."""
|
|
|
|
def test_passage_walk_keeps_mass_near_the_seeds(self):
|
|
assert _damping(passage_nodes=True) == 0.5
|
|
|
|
def test_entity_only_ranking_keeps_the_conventional_value(self):
|
|
assert _damping(passage_nodes=False) == 0.85
|
|
|
|
|
|
class TestPassageNodes:
|
|
def test_ranks_the_chunk_the_question_matches(self, monkeypatch):
|
|
"""Two chunks are equally connected; only their own relevance differs,
|
|
so the more relevant one must win."""
|
|
store = _StubStore(
|
|
chunk_links={"a": ["c1"], "b": ["c2"]},
|
|
similarities={"c1": 0.1, "c2": 0.9},
|
|
)
|
|
|
|
ranked = _retriever()._rank_chunks_with_passages(
|
|
store, "src", _subgraph(), {"a": 1.0, "b": 1.0}, [0.0] * 4
|
|
)
|
|
|
|
assert ranked[0] == "c2"
|
|
|
|
def test_a_chunk_reached_only_through_the_graph_still_ranks(self, monkeypatch):
|
|
"""The point of the walk: a chunk with no similarity of its own is
|
|
still reachable through the entity the seeds point at."""
|
|
store = _StubStore(
|
|
chunk_links={"a": ["c1"], "b": ["c2"]},
|
|
similarities={"c1": 0.0, "c2": 0.0},
|
|
)
|
|
|
|
ranked = _retriever()._rank_chunks_with_passages(
|
|
store, "src", _subgraph(), {"a": 1.0}, [0.0] * 4
|
|
)
|
|
|
|
assert set(ranked) == {"c1", "c2"}
|
|
|
|
def test_no_linked_chunks_returns_nothing(self):
|
|
store = _StubStore(chunk_links={}, similarities={})
|
|
|
|
assert (
|
|
_retriever()._rank_chunks_with_passages(
|
|
store, "src", _subgraph(), {"a": 1.0}, [0.0] * 4
|
|
)
|
|
== []
|
|
)
|
|
|
|
def test_over_fetches_past_the_chunk_budget(self, monkeypatch):
|
|
"""Same contract as ``_rank_chunks``: candidates exceed the budget so
|
|
chunks with missing text cannot drop the final count below it."""
|
|
links = {"a": [f"c{i}" for i in range(10)]}
|
|
store = _StubStore(
|
|
chunk_links=links,
|
|
similarities={f"c{i}": i / 10 for i in range(10)},
|
|
)
|
|
|
|
ranked = _retriever(chunks=2)._rank_chunks_with_passages(
|
|
store, "src", _subgraph(), {"a": 1.0}, [0.0] * 4
|
|
)
|
|
|
|
assert len(ranked) == max(2 * 2, 2 + 5)
|
|
|
|
|
|
class TestChunkSimilaritiesGuard:
|
|
"""The store call the passage path depends on short-circuits before it
|
|
touches a connection, so an empty subgraph costs no query."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"chunk_ids,embedding", [([], [0.1]), (["c1"], []), ([], [])]
|
|
)
|
|
def test_empty_inputs_return_empty(self, chunk_ids, embedding):
|
|
from docsgpt.graphrag.store import GraphStore
|
|
|
|
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}) == {}
|