mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-05 07:10:29 +08:00
Fix: restore GraphRAG entity ranking by indexing pagerank and n-hop paths (#15797)
### Summary Closes #15795 Knowledge-graph queries rank entities by `pagerank * sim` in `KGSearch`, but the entity chunks written at index time stopped carrying the values that ranking depends on. `graph_node_to_chunk` only stored `entity_type`, `description`, and `source_id`, dropping the node `pagerank` and the n-hop neighbour paths, while `search.py` still read them back as `rank_flt` and `n_hop_with_weight`. The producer of these fields, `update_nodes_pagerank_nhop_neighbour`, was removed in #6513, but the read side in `KGSearch` was never updated. The result is that on every knowledge-graph query: - `pagerank` resolves to `0`, so the `pagerank * sim` sort key is `0` for every entity and selection falls back to arbitrary order. - Every displayed entity score is `0.00`. - The n-hop relation-enrichment block is dead code because `n_hop_ents` is always empty, leaving `merge_tuples` and `is_continuous_subsequence` orphaned. This PR restores the missing index-time fields so the documented `P(E|Q) = pagerank * sim` ranking and the n-hop enrichment work again. What changed: - `graph_node_to_chunk` now writes `rank_flt` from the node pagerank and `n_hop_with_weight` from the recomputed n-hop neighbour paths. - Reintroduced the n-hop path computation (`n_neighbor`) in `rag/graphrag/utils.py`, reusing the previously orphaned `merge_tuples` / `is_continuous_subsequence` helpers, with a direction-agnostic edge-weight lookup for undirected graphs. `set_graph` computes the paths per added or updated node and passes them through. - `KGSearch` now selects `n_hop_with_weight` in the entity keyword search so Infinity and OceanBase return it (Elasticsearch and OpenSearch already read it from `_source`), and the read is hardened against missing keys or empty strings before `json.loads`. - Added the `n_hop_with_weight` column to OceanBase, including the `EXTRA_COLUMNS` migration entry so existing tables get it. The other engines already map both fields via dynamic templates or the Infinity mapping. Scope note: pagerank and n-hop are re-indexed for the added or updated nodes in each pass, consistent with the existing incremental indexing design. ### Type of change - [x] Bug Fix (non-breaking change which fixes an issue) ### Testing Added unit tests in `test/unit_test/rag/graphrag/test_graphrag_utils.py`: - `n_neighbor`: path and weight shape, one-hop vs two-hop, isolated nodes, missing weights, and direction-agnostic lookup. - `graph_node_to_chunk`: `rank_flt` populated from pagerank and defaulting to `0`, `n_hop_with_weight` serialized and defaulting to an empty list. ``` uv run pytest test/unit_test/rag/graphrag/ # 106 passed uv run ruff check rag/graphrag/ rag/utils/ob_conn.py ```
This commit is contained in:
@@ -14,9 +14,14 @@
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import networkx as nx
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import rag.graphrag.utils as graphrag_utils
|
||||
from rag.graphrag.utils import (
|
||||
GRAPH_FIELD_SEP,
|
||||
GraphChange,
|
||||
@@ -31,6 +36,7 @@ from rag.graphrag.utils import (
|
||||
is_continuous_subsequence,
|
||||
is_float_regex,
|
||||
merge_tuples,
|
||||
n_neighbor,
|
||||
pack_user_ass_to_openai_messages,
|
||||
perform_variable_replacements,
|
||||
split_string_by_multi_markers,
|
||||
@@ -510,6 +516,108 @@ class TestMergeTuples:
|
||||
assert merge_tuples([], []) == []
|
||||
|
||||
|
||||
@pytest.mark.p1
|
||||
class TestNNeighbor:
|
||||
"""Tests for n_neighbor function (n-hop neighbour path enumeration).
|
||||
|
||||
Regression coverage for the GraphRAG entity-ranking pipeline: the result
|
||||
is serialized into each entity chunk as ``n_hop_with_weight`` and consumed
|
||||
by KGSearch for n-hop relation enrichment.
|
||||
"""
|
||||
|
||||
def _line_graph(self):
|
||||
# A -1- B -2- C -3- D
|
||||
g = nx.Graph()
|
||||
g.add_edge("A", "B", weight=1)
|
||||
g.add_edge("B", "C", weight=2)
|
||||
g.add_edge("C", "D", weight=3)
|
||||
return g
|
||||
|
||||
def test_isolated_node_returns_empty(self):
|
||||
g = nx.Graph()
|
||||
g.add_node("A")
|
||||
assert n_neighbor(g, "A") == []
|
||||
|
||||
def test_result_shape(self):
|
||||
nbrs = n_neighbor(self._line_graph(), "A")
|
||||
assert isinstance(nbrs, list)
|
||||
for nbr in nbrs:
|
||||
assert set(nbr.keys()) == {"path", "weights"}
|
||||
assert len(nbr["weights"]) == len(nbr["path"]) - 1
|
||||
|
||||
def test_two_hop_paths_and_weights(self):
|
||||
# From A, 2-hop reaches the path A -> B -> C with weights [1, 2].
|
||||
nbrs = n_neighbor(self._line_graph(), "A", n_hop=2)
|
||||
paths = {tuple(n["path"]): n["weights"] for n in nbrs}
|
||||
assert ("A", "B", "C") in paths
|
||||
assert paths[("A", "B", "C")] == [1, 2]
|
||||
|
||||
def test_one_hop_only(self):
|
||||
nbrs = n_neighbor(self._line_graph(), "A", n_hop=1)
|
||||
paths = {tuple(n["path"]) for n in nbrs}
|
||||
assert paths == {("A", "B")}
|
||||
|
||||
def test_missing_weight_defaults_to_zero(self):
|
||||
g = nx.Graph()
|
||||
g.add_edge("A", "B") # no weight attribute
|
||||
nbrs = n_neighbor(g, "A", n_hop=1)
|
||||
assert nbrs[0]["weights"] == [0]
|
||||
|
||||
def test_weight_lookup_is_direction_agnostic(self):
|
||||
# Undirected graph: edge attributes may be keyed either way; the
|
||||
# weight must still be recovered regardless of traversal direction.
|
||||
nbrs = n_neighbor(self._line_graph(), "D", n_hop=1)
|
||||
assert nbrs[0]["path"][0] == "D"
|
||||
assert nbrs[0]["weights"] == [3]
|
||||
|
||||
|
||||
@pytest.mark.p1
|
||||
class TestGraphNodeToChunk:
|
||||
"""Tests for graph_node_to_chunk field population.
|
||||
|
||||
Regression coverage for the dropped ranking fields: the entity chunk must
|
||||
carry ``rank_flt`` (pagerank) and ``n_hop_with_weight`` so KGSearch's
|
||||
``pagerank * sim`` ranking and n-hop enrichment are not permanently dead.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def fake_embd(self, monkeypatch):
|
||||
# Skip the real encode/Redis path by returning a cached embedding.
|
||||
monkeypatch.setattr(graphrag_utils, "get_embed_cache", lambda *_a, **_k: np.array([0.1, 0.2, 0.3]))
|
||||
return graphrag_utils
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_writes_rank_flt_from_pagerank(self, fake_embd):
|
||||
chunks = []
|
||||
meta = {"entity_type": "PERSON", "description": "desc", "source_id": ["s1"], "pagerank": 0.42}
|
||||
await fake_embd.graph_node_to_chunk("kb1", SimpleNamespace(llm_name="m"), "ALICE", meta, chunks)
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0]["rank_flt"] == pytest.approx(0.42)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rank_flt_defaults_to_zero_without_pagerank(self, fake_embd):
|
||||
chunks = []
|
||||
meta = {"entity_type": "PERSON", "description": "desc", "source_id": ["s1"]}
|
||||
await fake_embd.graph_node_to_chunk("kb1", SimpleNamespace(llm_name="m"), "ALICE", meta, chunks)
|
||||
assert chunks[0]["rank_flt"] == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_writes_n_hop_with_weight(self, fake_embd):
|
||||
chunks = []
|
||||
meta = {"entity_type": "PERSON", "description": "desc", "source_id": ["s1"], "pagerank": 0.1}
|
||||
nhop = [{"path": ("ALICE", "BOB"), "weights": [3]}]
|
||||
await fake_embd.graph_node_to_chunk("kb1", SimpleNamespace(llm_name="m"), "ALICE", meta, chunks, nhop)
|
||||
stored = json.loads(chunks[0]["n_hop_with_weight"])
|
||||
assert stored == [{"path": ["ALICE", "BOB"], "weights": [3]}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_n_hop_defaults_to_empty_list(self, fake_embd):
|
||||
chunks = []
|
||||
meta = {"entity_type": "PERSON", "description": "desc", "source_id": ["s1"]}
|
||||
await fake_embd.graph_node_to_chunk("kb1", SimpleNamespace(llm_name="m"), "ALICE", meta, chunks)
|
||||
assert json.loads(chunks[0]["n_hop_with_weight"]) == []
|
||||
|
||||
|
||||
class TestFlatUniqList:
|
||||
"""Tests for flat_uniq_list function."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user