feat(graphrag): seed the walk from what entities are, and rank with passages and vector hits

Graph retrieval tied plain vector search at best and never beat it. Measured
across five corpora, the bottleneck was seeding, not the graph: the walk
started from nodes whose embeddings were computed from bare entity names, and
a whole question shares almost nothing with a name like "Quill".

Extraction now embeds each node from "name (type): description" and each
relationship as the fact it asserts ("Alder streams_to Quill: ..."), stored on
a new nullable graph_edges.fact_embedding column that ensure_vector_schema adds
in place. Entity names are canonicalised (case, punctuation, word breaks and a
cautious plural) so "VECTOR_STORE" and "vector stores" land on one node. Extraction calls run
concurrently (GRAPHRAG_EXTRACTION_WORKERS, default 8) while embedding and graph
writes stay serial on the task thread, so ordering and idempotency are
unchanged; that measured 8.4x faster with identical output.

Retrieval gains per-source options, stored under retrieval.graph and read live
at query time:

- seed_strategy: start from matching entities (default) or matching
  relationships, which can reach an entity the question never names;
- passage_nodes (on): walk the source's passages alongside entities, with
  PageRank damping 0.5 instead of 0.85;
- blend_vector (on): fuse the graph ranking with the source's vector ranking
  by reciprocal rank.

The defaults are the measured-best configuration. Through GraphRAGRetriever,
the new seeding moved recall@4 from 0.41 to 0.68 on a multi-hop corpus and
from 0.50 to 1.00 on the docs corpus, and regressed none of the corpora
measured. Existing graphs keep name-only embeddings until rebuilt.
This commit is contained in:
Alex committed 2026-09-19 14:07:41 +01:00
1 parent b7e7872bf7
commit a83e1dc0af
13 files changed
+1577 -79

No files matched your search

+213
View File
@@ -138,6 +138,129 @@ def _extraction_json(entities, relationships):
return json.dumps({"entities": entities, "relationships": relationships})
class TestFactText:
"""A relationship rendered as the sentence it asserts.
This is what fact seeding matches a question against, so it has to read as
a claim rather than as three fields concatenated.
"""
def test_renders_the_relationship_as_a_sentence(self):
text = extraction_module._fact_text(
{
"source": "Alder",
"target": "Quill",
"type": "streams_to",
"description": "Alder streams audit events to Quill.",
}
)
assert text == "Alder streams_to Quill: Alder streams audit events to Quill."
def test_omits_an_absent_description(self):
text = extraction_module._fact_text(
{"source": "Alder", "target": "Quill", "type": "streams_to"}
)
assert text == "Alder streams_to Quill"
def test_defaults_a_missing_relation(self):
text = extraction_module._fact_text({"source": "Alder", "target": "Quill"})
assert text == "Alder related to Quill"
@pytest.mark.parametrize(
"rel",
[
{"source": "Alder", "target": ""},
{"source": "", "target": "Quill"},
{},
],
)
def test_an_edge_without_both_endpoints_has_no_fact(self, rel):
assert extraction_module._fact_text(rel) == ""
class TestEmbedFacts:
"""Fact embeddings are always recorded, so a source can switch to
relationship seeding at query time without being rebuilt."""
def _relationships(self):
return [{"source": "Alder", "target": "Quill", "type": "streams_to"}]
def test_attaches_one_embedding_per_fact_in_a_single_call(self):
relationships = self._relationships() + [{"source": "", "target": "Nowhere"}]
calls = []
class _Embedding:
def embed_documents(self, texts):
calls.append(texts)
return [[0.5] * 4 for _ in texts]
extraction_module._embed_facts(_Embedding(), relationships)
# One batched call, and the endpoint-less relationship is skipped
# rather than embedded as an empty string.
assert calls == [["Alder streams_to Quill"]]
assert relationships[0]["fact_embedding"] == [0.5] * 4
assert "fact_embedding" not in relationships[1]
def test_survives_an_embedding_failure(self):
"""The graph is still correct without fact embeddings — only
relationship seeding degrades, and it falls back to entities — so a
failure here must not fail the chunk."""
relationships = self._relationships()
class _Embedding:
def embed_documents(self, texts):
raise RuntimeError("embeddings down")
extraction_module._embed_facts(_Embedding(), relationships)
assert "fact_embedding" not in relationships[0]
class TestSeedText:
"""What a node's embedding is computed from.
Retrieval matches a whole question against these embeddings, so what goes
into them decides what the graph walk can start from.
"""
def _entity(self):
return {
"name": "Quill",
"normalized_name": "quill",
"type": "store",
"description": "A write-ahead store.",
}
def test_includes_type_and_description(self):
assert (
extraction_module._seed_text(self._entity())
== "Quill (store): A write-ahead store."
)
def test_falls_back_to_the_name_when_fields_are_missing(self):
assert extraction_module._seed_text({"name": "Quill"}) == "Quill"
def test_embedded_text_is_keyed_by_the_normalized_name(self):
"""The richer text must reach ``embed_documents``, keyed by the same
normalized name the store resolves nodes by — otherwise the embedding
is computed for a node it never reaches."""
captured = {}
class _Embedding:
def embed_documents(self, texts):
captured["texts"] = texts
return [[0.0] * 4 for _ in texts]
result = extraction_module._embed_names(_Embedding(), [self._entity()], [])
assert captured["texts"] == ["Quill (store): A write-ahead store."]
assert set(result) == {"quill"}
@pytest.mark.integration
class TestExtractionLive:
@pytest.fixture
@@ -193,6 +316,96 @@ class TestExtractionLive:
finally:
store.delete_by_source(source_id)
def test_parallel_workers_process_every_chunk_once(
self, store, source_id, monkeypatch, stub_embedding
):
"""Running the model calls concurrently must not change what gets written.
Extraction spends nearly all of a chunk's time waiting on the model, so
the calls run in a pool while every graph write stays on the calling
thread. Six chunks share one entity here: whatever order the pool
finishes in, that entity is upserted once, each chunk is linked, and all
six are marked processed.
"""
from docsgpt.core.settings import settings
try:
payload = _extraction_json(
entities=[{"name": "Ada", "type": "person", "description": "d"}],
relationships=[],
)
llm = _StubLLM([payload] * 6)
_install_stub_llm(monkeypatch, llm)
monkeypatch.setattr(settings, "GRAPHRAG_EXTRACTION_WORKERS", 4)
summary = extract_graph_for_source(
source_id,
user="owner-1",
chunks=[
_chunk(f"c{i}", f"Ada appears here, take {i}.") for i in range(6)
],
config=SourceConfig(),
request_id="req-parallel",
)
assert summary["chunks_processed"] == 6
assert summary["failed_chunks"] == 0
assert summary["nodes"] == 1
assert len(llm.gen_calls) == 6
node = store.get_node_by_normalized(source_id, "ada")
assert node is not None
mapping = store.get_chunk_ids_for_nodes(source_id, [node["id"]])
assert sorted(mapping[node["id"]]) == [f"c{i}" for i in range(6)]
finally:
store.delete_by_source(source_id)
def test_embedding_runs_on_the_calling_thread(
self, store, source_id, monkeypatch, stub_embedding
):
"""Only the LLM call may run in the extraction pool, never embedding.
Inside a Celery worker the embeddings client decides to embed locally
from the task on the *current thread's* stack. A pool thread has none,
so from there it dispatches an embed task to the worker and waits on
it — which Celery refuses inside a task, so every chunk of a graph
build failed.
"""
import threading
from docsgpt.core.settings import settings
caller = threading.current_thread()
seen = []
real_embed_names = extraction_module._embed_names
def _recording_embed_names(*args, **kwargs):
seen.append(threading.current_thread())
return real_embed_names(*args, **kwargs)
monkeypatch.setattr(extraction_module, "_embed_names", _recording_embed_names)
try:
payload = _extraction_json(
entities=[{"name": "Ada", "type": "person", "description": "d"}],
relationships=[],
)
_install_stub_llm(monkeypatch, _StubLLM([payload] * 4))
monkeypatch.setattr(settings, "GRAPHRAG_EXTRACTION_WORKERS", 4)
summary = extract_graph_for_source(
source_id,
user="owner-1",
chunks=[_chunk(f"c{i}", f"Ada, take {i}.") for i in range(4)],
config=SourceConfig(),
request_id="req-thread",
)
assert summary["failed_chunks"] == 0
assert len(seen) == 4
assert all(thread is caller for thread in seen)
finally:
store.delete_by_source(source_id)
def test_same_entity_across_chunks_merges(
self, store, source_id, monkeypatch, stub_embedding
):
@@ -0,0 +1,109 @@
"""The graph retriever's default path, end to end through ``_graph_docs_for_source``.
The shipped defaults — seed from entities, walk the passages, blend with vector
search — are the configuration that measured best, so they are what most graph
sources run. This drives that whole path with a store that returns real values,
and checks each per-source option actually switches its stage off.
"""
from __future__ import annotations
from docsgpt.retriever.graph_rag import GraphRAGRetriever
from docsgpt.storage.db.source_config import RetrievalConfig
TEXTS = {
"c-alder": "Alder streams audit events to Quill.",
"c-quill": "Quill is compacted every six hours.",
}
VECTOR_ONLY = "A passage only plain vector search found."
class _Store:
"""A two-entity chain: the question matches Alder, the answer is on Quill."""
def __init__(self):
self.calls: list[str] = []
def search_nodes_by_embedding(self, source_id, query_embedding, k=10):
return [{"id": "alder", "name": "Alder", "distance": 0.1}]
def get_subgraph(self, source_id, node_ids, hops=1):
return {
"nodes": [{"id": "alder", "doc_freq": 1}, {"id": "quill", "doc_freq": 1}],
"edges": [{"src_node_id": "alder", "dst_node_id": "quill", "weight": 1.0}],
}
def get_chunk_ids_for_nodes(self, source_id, node_ids):
return {"alder": ["c-alder"], "quill": ["c-quill"]}
def chunk_similarities(self, source_id, chunk_ids, query_embedding):
self.calls.append("chunk_similarities")
return {"c-alder": 0.9, "c-quill": 0.2}
def get_chunk_texts(self, source_id, chunk_ids):
return {
c: {"text": TEXTS[c], "metadata": {"title": c}}
for c in chunk_ids
if c in TEXTS
}
def _retriever(per_source=None):
"""A retriever without its constructor (which builds a ClassicRAG)."""
retriever = object.__new__(GraphRAGRetriever)
retriever.chunks = 3
retriever.base_chunks = None
retriever.doc_token_limit = 50000
retriever.vectorstores = ["src"]
retriever.per_source_retrieval = per_source or {}
retriever.vector_calls = 0
def _vector_ranking(source_id, query_embedding):
retriever.vector_calls += 1
return [(VECTOR_ONLY, {"title": "vector"})]
retriever._vector_ranking = _vector_ranking
return retriever
def _texts(docs):
return [doc["text"] for doc in docs]
class TestDefaultPath:
def test_walks_passages_and_blends_in_vector_hits(self):
store = _Store()
retriever = _retriever()
docs = retriever._graph_docs_for_source(store, "src", [0.1, 0.2])
# The answer sits one edge away from the seed: the walk reached it.
assert TEXTS["c-quill"] in _texts(docs)
# A hit only vector search found is blended in, not lost.
assert VECTOR_ONLY in _texts(docs)
assert store.calls == ["chunk_similarities"]
assert retriever.vector_calls == 1
class TestPerSourceOptions:
def test_passage_walk_can_be_switched_off(self):
store = _Store()
retriever = _retriever(
{"src": RetrievalConfig(chunks=3, graph={"passage_nodes": False})}
)
docs = retriever._graph_docs_for_source(store, "src", [0.1, 0.2])
assert "chunk_similarities" not in store.calls
assert TEXTS["c-quill"] in _texts(docs)
def test_vector_blending_can_be_switched_off(self):
store = _Store()
retriever = _retriever(
{"src": RetrievalConfig(chunks=3, graph={"blend_vector": False})}
)
docs = retriever._graph_docs_for_source(store, "src", [0.1, 0.2])
assert retriever.vector_calls == 0
assert VECTOR_ONLY not in _texts(docs)
+132
View File
@@ -0,0 +1,132 @@
"""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) == {}
+129
View File
@@ -0,0 +1,129 @@
"""Where the graph walk starts, per the source's graph retrieval options.
Seeding decides more than ranking does — a walk that starts on the wrong nodes
cannot be rescued downstream. The options are per source and live (no
re-ingest), carried on the per-source retrieval config the Dispatcher hands the
retriever, so both the dispatch and how the options are resolved are pinned.
The fallback matters most: relationship seeding reads fact embeddings written
at ingest, and a source built before they were recorded has none. It must keep
retrieving through entity matching rather than returning nothing.
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from docsgpt.retriever.graph_rag import GraphRAGRetriever
from docsgpt.storage.db.source_config import GraphRetrievalConfig, RetrievalConfig
ENTITY_ROWS = [{"id": "n1", "name": "Quill", "distance": 0.2}]
FACT_ROWS = [
{"id": "f1", "name": "Alder", "distance": 0.1},
{"id": "f2", "name": "Quill", "distance": 0.1},
]
class _StubStore:
def __init__(self, fact_rows=None):
self.fact_rows = list(FACT_ROWS) if fact_rows is None else fact_rows
self.calls: list[str] = []
def seed_nodes_from_facts(self, source_id, query_embedding, fact_limit=5, limit=10):
self.calls.append("facts")
return self.fact_rows
def search_nodes_by_embedding(self, source_id, query_embedding, k=10):
self.calls.append("entities")
return list(ENTITY_ROWS)
def _retriever(per_source=None):
"""A retriever without its constructor, which builds a ClassicRAG."""
retriever = object.__new__(GraphRAGRetriever)
if per_source is not None:
retriever.per_source_retrieval = per_source
return retriever
def _relationships_config():
return RetrievalConfig(graph={"seed_strategy": "relationships"})
class TestDefaults:
def test_measured_best_configuration_is_the_default(self):
options = GraphRetrievalConfig()
assert options.seed_strategy == "entities"
assert options.passage_nodes is True
assert options.blend_vector is True
def test_a_source_with_no_per_source_config_gets_the_defaults(self):
assert _retriever()._graph_options("src") == GraphRetrievalConfig()
def test_existing_retrieval_configs_validate_without_the_new_block(self):
"""Source configs saved before this existed carry no ``graph`` key."""
config = RetrievalConfig.model_validate({"retriever": "graphrag"})
assert config.graph == GraphRetrievalConfig()
@pytest.mark.parametrize(
"bad", [{"seed_strategy": "vector"}, {"seed_strategy": "union"}, {"damping": 0.5}]
)
def test_retired_and_unknown_options_are_rejected(self, bad):
with pytest.raises(ValidationError):
GraphRetrievalConfig.model_validate(bad)
class TestEntitySeeding:
def test_seeds_from_entities_and_never_reads_facts(self):
store = _StubStore()
rows = _retriever()._seed_rows(store, "src", [0.0, 0.1])
assert [r["id"] for r in rows] == ["n1"]
assert store.calls == ["entities"]
class TestRelationshipSeeding:
def test_seeds_from_facts_when_the_source_asks_for_it(self):
store = _StubStore()
retriever = _retriever({"src": _relationships_config()})
rows = retriever._seed_rows(store, "src", [0.0, 0.1])
assert [r["id"] for r in rows] == ["f1", "f2"]
assert store.calls == ["facts"]
def test_reads_the_option_from_a_plain_dict_config_too(self):
store = _StubStore()
retriever = _retriever({"src": {"graph": {"seed_strategy": "relationships"}}})
retriever._seed_rows(store, "src", [0.0, 0.1])
assert store.calls == ["facts"]
def test_falls_back_to_entities_without_fact_embeddings(self):
"""A source built before fact embeddings were recorded still retrieves."""
store = _StubStore(fact_rows=[])
retriever = _retriever({"src": _relationships_config()})
rows = retriever._seed_rows(store, "src", [0.0, 0.1])
assert [r["id"] for r in rows] == ["n1"]
assert store.calls == ["facts", "entities"]
def test_options_are_per_source(self):
store = _StubStore()
retriever = _retriever({"other": _relationships_config()})
retriever._seed_rows(store, "src", [0.0, 0.1])
assert store.calls == ["entities"]
def test_a_malformed_stored_option_falls_back_to_the_defaults(self):
"""A bad value must not take graph retrieval down with it."""
retriever = _retriever({"src": {"graph": {"seed_strategy": "nonsense"}}})
assert retriever._graph_options("src") == GraphRetrievalConfig()
+126 -5
View File
@@ -157,6 +157,122 @@ class TestGraphStoreLive:
finally:
store.delete_by_source(source_id)
def test_seed_nodes_from_facts_returns_both_endpoints_of_the_match(
self, store, source_id
):
"""Fact seeding's whole point: the question matches the *relationship*,
and both of its endpoints become seeds — including the one the question
never names."""
try:
alder = store.upsert_node(source_id, "Alder", "alder", "service", "d")
quill = store.upsert_node(source_id, "Quill", "quill", "store", "d")
birch = store.upsert_node(source_id, "Birch", "birch", "service", "d")
ridge = store.upsert_node(source_id, "Ridge", "ridge", "store", "d")
store.add_edge(
source_id, alder, quill, "streams_to", "Alder streams to Quill",
1.0, ["c1"], fact_embedding=_embedding(1.0),
)
store.add_edge(
source_id, birch, ridge, "streams_to", "Birch streams to Ridge",
1.0, ["c2"], fact_embedding=_embedding(-1.0),
)
rows = store.seed_nodes_from_facts(
source_id, _embedding(1.0), fact_limit=1, limit=10
)
assert {row["name"] for row in rows} == {"Alder", "Quill"}
assert all(row["distance"] <= 1.0 for row in rows)
finally:
store.delete_by_source(source_id)
def test_seed_nodes_from_facts_is_empty_without_fact_embeddings(
self, store, source_id
):
"""A source ingested before fact embeddings existed returns nothing,
which is the signal the retriever falls back to name matching on."""
try:
a = store.upsert_node(source_id, "A", "a")
b = store.upsert_node(source_id, "B", "b")
store.add_edge(source_id, a, b, "rel")
assert store.seed_nodes_from_facts(source_id, _embedding(1.0)) == []
finally:
store.delete_by_source(source_id)
def test_add_edge_skips_self_loops(self, store, source_id):
"""A relationship whose endpoints resolve to one node is noise.
A self-loop feeds a node's PageRank mass straight back to itself, and a
real extraction produced 121 of them on a 98-page corpus.
"""
try:
a = store.upsert_node(source_id, "A", "a", "thing", "desc a")
assert (
store.add_edge(source_id, a, a, "related", "a relates to a", 1.0, ["c1"])
is None
)
assert store.get_subgraph(source_id, [a], hops=1)["edges"] == []
finally:
store.delete_by_source(source_id)
def test_add_edge_merges_a_repeated_pair(self, store, source_id):
"""The same relationship seen in many chunks is one edge, not many rows.
``graph_edges`` carries no uniqueness constraint, so re-extracting a
relationship used to insert a row per chunk — 19.9% of a real corpus's
edges — inflating traversal weight and wasting the subgraph fetch
budget. The surviving row keeps the strongest weight and both chunk ids.
"""
try:
a = store.upsert_node(source_id, "A", "a", "thing", "desc a")
b = store.upsert_node(source_id, "B", "b", "thing", "desc b")
first = store.add_edge(source_id, a, b, "related", "d", 2.0, ["chunk-1"])
second = store.add_edge(source_id, a, b, "related", "d", 5.0, ["chunk-2"])
assert second == first
edges = store.get_subgraph(source_id, [a, b], hops=1)["edges"]
assert len(edges) == 1
assert float(edges[0]["weight"]) == 5.0
# Both chunks are still recorded as evidence for the merged edge.
conn = store._get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"SELECT source_chunk_ids FROM graph_edges WHERE id = %s;", (first,)
)
chunk_ids = cursor.fetchone()[0]
finally:
cursor.close()
conn.rollback()
assert sorted(chunk_ids) == ["chunk-1", "chunk-2"]
finally:
store.delete_by_source(source_id)
def test_get_subgraph_keeps_the_heaviest_edges_when_capped(
self, store, source_id, monkeypatch
):
"""A capped fetch must drop the weakest edges, not an arbitrary subset.
The cap is applied with ``LIMIT``; without an ordering Postgres is free
to return any rows at all, so a dense graph silently retrieves a random
neighbourhood.
"""
try:
a = store.upsert_node(source_id, "A", "a", "thing", "d")
b = store.upsert_node(source_id, "B", "b", "thing", "d")
c = store.upsert_node(source_id, "C", "c", "thing", "d")
store.add_edge(source_id, a, b, "light", "d", 1.0, ["c1"])
store.add_edge(source_id, a, c, "heavy", "d", 9.0, ["c1"])
monkeypatch.setattr(store_module, "MAX_SUBGRAPH_EDGES", 1)
edges = store.get_subgraph(source_id, [a], hops=1)["edges"]
assert [e["type"] for e in edges] == ["heavy"]
finally:
store.delete_by_source(source_id)
def test_apply_chunk_writes_nodes_links_and_edges(self, store, source_id):
"""One transactional write: entities linked to the chunk, edges added,
and a bare relationship endpoint upserted but not chunk-linked."""
@@ -203,18 +319,23 @@ class TestGraphStoreLive:
store.delete_by_source(source_id)
def test_self_loop_degree_agrees_across_paths(self, store, source_id):
"""``add_edge``'s incremental +1 and ``set_node_degrees`` recompute must
agree on a self-loop (count it once)."""
"""``add_edge``'s incremental bump and ``set_node_degrees`` recompute must
agree on a self-loop.
They now agree on zero rather than one: the self-loop is rejected at
write time, so neither path has an edge to count. The property under
test is that the two paths agree, not the number they agree on.
"""
try:
node = store.upsert_node(source_id, "Solo", "solo")
store.add_edge(source_id, node, node, "self")
assert store.add_edge(source_id, node, node, "self") is None
incremental = store.get_node_by_normalized(source_id, "solo")["degree"]
assert incremental == 1
assert incremental == 0
store.set_node_degrees(source_id)
recomputed = store.get_node_by_normalized(source_id, "solo")["degree"]
assert recomputed == 1
assert recomputed == incremental == 0
finally:
store.delete_by_source(source_id)
+23
View File
@@ -45,6 +45,29 @@ def _patch_embed(monkeypatch):
)
@pytest.fixture(autouse=True)
def _entity_only_ranking(monkeypatch):
"""Pin the ranking path these tests were written for.
Everything here exercises entity-only PPR ranking without vector blending,
driven through ``MagicMock`` stores. The shipped default now walks the
passages and blends with vector search — covered end to end in
``tests/graphrag/test_retriever_default_path.py`` with a store that returns
real values. Pinning keeps each test here asserting what it was written to
assert, rather than whatever a mock happens to return on a path it never set
up.
"""
from docsgpt.storage.db.source_config import GraphRetrievalConfig
monkeypatch.setattr(
GraphRAGRetriever,
"_graph_options",
lambda self, source_id: GraphRetrievalConfig(
passage_nodes=False, blend_vector=False
),
)
# ── Fallback to ClassicRAG ────────────────────────────────────────────────────