跳到主要内容

知识图谱检索与多源聚合(AggregateSearchResult)

30 秒导读: R2R 的检索有三路。前两章讲了 chunk 那一路(向量/全文/混合,以及 basic/fusion/hyde 策略)。本章讲第三路——知识图谱检索:把已经建好的图谱当成三张向量表(实体、关系、社区)分别做语义检索,再把图结果和 chunk 结果(可能还有 web)汇进同一个信封 AggregateSearchResult,交给上层 RAG 生成用。本章只讲「查图 + 汇总」,不讲图谱是怎么被抽取/构建出来的(那属于 ingestion)。

前置阅读:底层三路检索(pgvector 三路)、检索策略与两级 RRF(basic/fusion/hyde)。不了解 R2R 全貌先看 index.md


1. 这是什么(零基础也能懂)

一句话定义: 图谱检索,就是除了从文档切片里找答案,还从"知识图谱"里找答案——图谱里存的是从文档抽出来的实体(谁)、关系(谁和谁怎么了)、社区(一群相关实体的主题摘要)。

为什么需要它? 纯 chunk 检索找的是"哪段文字最像问题"。但有些问题的答案不是"某一段话",而是结构性的:

  • "OpenAI 和 Microsoft 是什么关系?" —— 答案是一条关系(subject=OpenAI, predicate=invested in, object=Microsoft),而不是某一整段。
  • "这批文档主要围绕哪些主题?" —— 答案是若干社区摘要,而不是任何单个切片。

图谱检索就是为这类"实体/关系/主题"层面的问题准备的第二种召回来源。

一句话直觉/类比: 如果 chunk 检索像"在书里按段落找相关文字",那图谱检索像"查这本书配的索引卡片和人物关系图"——同一个问题,两种角度各查一遍,最后合并。

关键前提: 本章讲的都是"图已经建好了"之后的事。图里的每个实体/关系/社区在入库时都算好了一个向量(description_embedding 等),检索侧只是拿问题的向量去这三张表里做最近邻查询。图是怎么抽出来、embedding 怎么算的,不在本章范围(属 ingestion)。


2. 顶层全景(第三路怎么接进来)

2.1 三路检索的位置

一次 search() 调用,最终产出一个 AggregateSearchResult。它里面并排放着几路来源的结果:

┌──────────────────────────────────────┐
query ───────────▶│ RetrievalService.search() │
│ 按 strategy 分派(见第 02 章) │
└───────┬───────────────┬───────────────┘
│ │
┌───────────▼──┐ ┌─────▼─────────────┐
│ chunk 一路 │ │ graph 一路(本章) │
│ 向量/全文/混合│ │ 实体/关系/社区 │
│(第 01 章) │ │ │
└───────┬──────┘ └─────┬─────────────┘
│ │
▼ ▼
chunk_search_results graph_search_results
│ │
└─────────┬─────────┘

┌───────────────────────────┐
│ AggregateSearchResult │ ← 统一信封
│ + web_page / web / doc … │
└─────────────┬─────────────┘

上层 RAG 生成 / Agent 工具(第 04、05 章)

怎么读这张图: 从上往下是一次检索的数据流。chunk 和 graph 是并列的两路召回,各自查各自的,最后在 AggregateSearchResult 这个信封里平级共存,不互相排名、不合并成一个列表。

2.2 各部件一句话职责

部件干什么在哪
_graph_search_logic图检索的编排层:embed 问题 → 对实体/关系/社区各查一遍 → 包成 GraphSearchResultcore/main/services/retrieval_service.py:731
graphs_handler.graph_search图检索的SQL 层:对某一张图表跑 pgvector 最近邻查询core/providers/database/graphs.py:2549
GraphSearchResult 及三种 content图结果的类型建模:统一外壳 + 实体/关系/社区三种内容shared/abstractions/search.py:71-168
AggregateSearchResult多源结果的统一信封shared/abstractions/search.py:255

2.3 主线走一遍(高层)

  1. 上层(basic/fusion/hyde 任一策略)决定要不要图检索——由 search_settings.graph_settings.enabled 控制。
  2. 若开启,调用 _graph_search_logic(query_text, search_settings, precomputed_vector)
  3. 该函数把问题变成一个向量,然后对三张图表各发一次 SQL(实体表、关系表、社区表)。
  4. 每一路 SQL 返回的行,被包成一个 GraphSearchResult,content 字段按类型填成实体/关系/社区。
  5. 三路结果拼进同一个 list[GraphSearchResult] 返回。
  6. 上层把它塞进 AggregateSearchResult.graph_search_results,和 chunk 那路并排。

3. 核心机制一:一次图检索查了什么(_graph_search_logic)

它要解决的小问题: 给一个问题,怎么从图谱里同时召回相关的实体、关系、社区?

思路: 图谱在库里不是一坨,而是三张独立的表——graphs_entitiesgraphs_relationshipsgraphs_communities。每张表的行都带一个描述向量。所以"图检索"其实是对三张表各做一次向量最近邻,再把三种结果贴上类型标签合到一起。

3.1 先把问题变成向量(可复用上游的)

_graph_search_logic 第一步是拿到查询向量。关键设计:优先复用上游已经算好的向量,没有才自己 embed。

# 示意,非源码:向量复用的逻辑
query_embedding = precomputed_vector # 上游(如 hyde/fusion)可能已 embed
if query_embedding is None: # 没给才自己算,省一次 embedding 调用
query_embedding = await embed(query_text)

真实实现见 retrieval_service.py:750-756(_graph_search_logicquery_embedding = precomputed_vector 分支)。这个"传进来就复用"的约定,正是第 02 章 hyde/fusion 能把 chunk 和 graph 共用同一个向量并行拉起的基础——_fanout_chunk_and_graph_search(retrieval_service.py:616)先 embed 一次 alt_text,再把同一个 vec 同时喂给 chunk 检索和图检索。

3.2 对三类图对象各查一遍

拿到向量后,函数依次对三张表发起检索。每一路的差别只有三样:查哪张表(search_type)、取哪些列(property_names)、限量多少(limit)。

search_type取的列(property_names)限量来源
实体"entities"name, description, idlimits["entities"],缺省用 search_settings.limit
关系"relationships"id, subject, predicate, object, description, subject_id, object_idlimits["relationships"]
社区"communities"id, name, summarylimits["communities"]

限量可以逐类单独配:graph_limits = search_settings.graph_settings.limits or {}(retrieval_service.py:759),每类再 graph_limits.get("entities", base_limit) 取自己的上限(retrieval_service.py:762)。这样你能让关系比实体召回更多,互不影响。limits 的定义见 shared/abstractions/search.py:434(GraphSearchSettings.limits: dict[str, int])。

三路调用长得几乎一样,以实体路为例:

# 真实源码节选 retrieval_service.py:763-770 _graph_search_logic
entity_cursor = self.providers.database.graphs_handler.graph_search(
query_text,
search_type="entities",
limit=entity_limit,
query_embedding=query_embedding,
property_names=["name", "description", "id"],
filters=search_settings.filters,
)

重点看: query_embedding 才是真正用来算相似度的东西;query_text 这个字面串虽然也传了,但底层 graph_search 其实不用它做检索(下一节会看到它只做纯向量 KNN)。

3.3 把每行包成带类型的 GraphSearchResult

每一路 async for 遍历游标,把返回的字典包成 GraphSearchResult,并按类型填 contentresult_type。实体路:

# 真实源码节选 retrieval_service.py:780-799 _graph_search_logic(实体分支)
results.append(
GraphSearchResult(
id=ent.get("id", None),
content=GraphEntityResult(
name=ent.get("name", ""),
description=ent.get("description", ""),
id=ent.get("id", None),
),
result_type=GraphSearchResultType.ENTITY,
score=score if search_settings.include_scores else None,
metadata=(
{**(metadata or {}), "associated_query": query_text}
if search_settings.include_metadatas else {}
),
)
)

关系路、社区路结构完全对称,只是 content 换成 GraphRelationshipResult / GraphCommunityResult,result_type 换成 RELATIONSHIP / COMMUNITY(见 retrieval_service.py:828-851876-895)。三路结果都 append 进同一个 results 列表,最后统一返回——没有跨类型再排名,顺序就是"先所有实体、再所有关系、再所有社区"。

两个值得记住的细节(坑):

  • metadata 可能是字符串。 从库里回来的 metadata 有时是 JSON 字符串而非 dict,所以每一路都先 if isinstance(metadata, str): json.loads(...) 兜一下(retrieval_service.py:774-778),except静默吞掉解析失败——解析不了就原样用。
  • 关系/社区路里的顶层 id 用了 ent(inferred 隐患)。 关系分支的 GraphSearchResult(id=ent.get("id", None), ...)(retrieval_service.py:830)和社区分支(:878)引用的都是实体循环遗留的变量 ent,而不是当前的 rel / commcontent 内部的 id 是对的(rel.get/comm.get),但外壳的 .id 会串到"最后一个实体的 id",且当本次实体路一条都没返回时 ent 未定义会抛 NameError。看着像 copy-paste 残留。用这些结果时,认 content 里的 id 更稳妥。

它要解决的小问题: 给一张图表和一个向量,怎么用 SQL 取回最相似的若干行,并附上一个可比较的分数?

思路: 就是一条 pgvector 最近邻查询。表名由 search_type 拼出,相似度用 <=>(余弦距离)算,再把"距离"翻成"相似度"。

4.1 表名是拼出来的

# 真实源码节选 graphs.py:2581 graph_search
table_name = f"graphs_{search_type}"

所以 search_type="entities"graphs_entities,以此类推。最终还会被 _get_table_name(graphs.py:55)套上 "项目名"."graphs_entities" 的完整限定名。

补充:入库侧用 _get_entity_table_for_store(store_type)(graphs.py:59)按 StoreType(shared/abstractions/graph.py:143,值为 graphs/documents)拼实体表名,和这里检索侧的命名规则对上——图谱实体落在 graphs_entities,文档级临时实体落在 documents_entities。检索走的是 graphs_* 那套。

4.2 核心 SQL:纯向量 KNN

-- 真实源码节选 graphs.py:2597-2605 graph_search 里的 QUERY
SELECT
{property_names_str},
({embedding_type} <=> $1) as similarity_score
FROM {表名}
{where_clause}
ORDER BY {embedding_type} <=> $1
LIMIT $2;

要点:

  • $1 是查询向量(json.dumps(query_embedding),graphs.py:2586),$2limit
  • embedding_type 缺省是 "description_embedding"(graphs.py:2564)——按"描述"的向量排序,不是按名字。
  • <=> 是 pgvector 的距离算子:越小越相似。所以既拿它当 SELECT 的原始距离,也拿它 ORDER BY

4.3 距离 → 相似度,以及 "n/a" 兜底

SQL 回来的 similarity_score 其实是距离,越小越近。函数把它翻成"越大越好"的相似度再吐出去:

# 真实源码节选 graphs.py:2611-2620 graph_search 结果整形
for result in results:
output = {prop: result[prop] for prop in property_names if prop in result}
output["similarity_score"] = (
1 - float(result["similarity_score"])
if result.get("similarity_score")
else "n/a"
)
yield output

两个坑:

  • 1 - distance 的语义取决于向量是否归一化。 只有向量单位化时 1 - 余弦距离 才是标准余弦相似度;代码直接这么算,默认了这一点。
  • 距离恰好为 0 会掉进 "n/a" 分支。 if result.get("similarity_score")0.0 判为假(0.0 是 falsy),于是"完全命中(距离 0)"反而 similarity_score = "n/a",而不是 1.0。极端相似的行拿不到分数,后续按 score or 0.0 排序时会被当 0 处理。

4.4 关于"混合/全文":图检索不支持,会被忽略

graph_search 里明确写了:即便传了 use_hybrid_search / use_fulltext_search,也只打一条 warning 然后照走纯向量:

# 真实源码节选 graphs.py:2576-2579 graph_search
if use_hybrid_search or use_fulltext_search:
logger.warning(
"Hybrid and fulltext search not supported for graph search, ignoring."
)

所以第 01 章讲的"全文 / 混合"三路,只作用于 chunk;图检索永远是纯语义(向量)那一路。这是理解"三路"时最容易混的一点。

4.5 过滤:图有自己的一套 _build_filters

图检索的 filters 不复用 chunk 的过滤器,而是走 graphs.py:2622_build_filters,它把 Mongo 风格的过滤字典翻成 WHERE 子句,并且search_type 切换基准列:

search_type默认过滤列
communitiescollection_id
entities / relationshipsparent_id

它支持 $eq/$in/$overlap(对应 = $x::uuid= ANY($x::uuid[]))、$and/$or 递归、以及 metadata.<字段> 的 JSON 取值过滤(graphs.py:2699-2715)。不认识的 key 返回空串被跳过(graphs.py:2720),不会报错。

4.6 其它图读取接口(非检索路,顺带认个门)

除了向量检索,graphs_handler 还有几个"按结构直接读图"的方法,给非语义场景用——它们不吃向量,按 parent_id 分页或按名字聚合:

方法干什么位置
get_relationshipsparent_id 分页取关系,可按 id / predicate 过滤graphs.py:2069
get_entitiesparent_id 分页取实体graphs.py:1985
get_entity_map给一个 document_id,拼出"实体 → 它的关系"的邻接映射graphs.py:2483

get_entity_map 值得一提:它先取该文档的实体名列表,再把 subject/object 落在这些名字上的关系挂回去,组成 {实体名: {"entities": [...], "relationships": [...]}}(graphs.py:2531-2547)。这是"以实体为中心看邻域"的读法,和本章主线的"向量最近邻检索"是两种不同用途——检索侧的主路是 graph_search


5. 核心机制三:结果怎么被建模(shared/abstractions/search.py)

它要解决的小问题: 实体、关系、社区三种内容形状完全不同,怎么用一套类型装下、还能让上层统一处理?

思路: 一个统一外壳 + 一个"三选一"的内容字段。外壳负责通用元数据(分数、类型标签、id、来源 chunk),内容用联合类型区分三种形状。

5.1 三种内容各自的形状

类型关键字段定义
实体GraphEntityResultname, descriptionsearch.py:77
关系GraphRelationshipResultsubject, predicate, object, subject_id, object_id, scoresearch.py:93
社区GraphCommunityResultname, summarysearch.py:117

关系是三者里字段最多的——它天然是个三元组 (subject, predicate, object),还额外带两端实体的 id,方便回连到实体。

5.2 统一外壳 GraphSearchResult

# 真实源码节选 search.py:140-146 GraphSearchResult
class GraphSearchResult(R2RSerializable):
content: GraphEntityResult | GraphRelationshipResult | GraphCommunityResult
result_type: Optional[GraphSearchResultType] = None
chunk_ids: Optional[list[UUID]] = None
metadata: dict[str, Any] = {}
score: Optional[float] = None
id: UUID

三个设计点:

  • content 是联合类型——一个字段装三种形状,靠 result_type(GraphSearchResultType,search.py:71,枚举 entity/relationship/community)告诉消费方该按哪种解读。
  • chunk_ids 回指"这个图对象是从哪些原文切片抽出来的",让答案能追溯回原文(和 chunk 那路打通)。
  • score 在外壳上,所以三类结果能放进同一个列表、用同一个分数字段比较/排序。

5.3 上层如何并行拉起图检索(与第 02 章衔接)

三种策略拉图检索的方式各不相同,但都落到同一个 _graph_search_logic:

策略图检索怎么拉起位置
basic直接调 _graph_search_logic,和 chunk 用同一个 query_vectorretrieval_service.py:311-318
rag_fusion每个子问题各跑一次 _basic_search,收集每份 graph_search_resultsretrieval_service.py:362-364
hyde_fanout_chunk_and_graph_search 里 chunk 与 graph 共用 alt_text 的向量retrieval_service.py:640-647

fusion 还会对多份图结果做第二级 RRF(_reciprocal_rank_fusion_graphs,retrieval_service.py:375),再按 score 排序切顶(:392-397)——这属第 02 章的策略逻辑,本章不展开。要记住的只是:不管哪种策略,图检索的"查"永远是本章的 _graph_search_logic,策略层只决定"拉几次、怎么合"。


6. 核心机制四:多源汇总(AggregateSearchResult)

它要解决的小问题: chunk、graph、web、document 这些来源形状各异,上层(RAG 生成、Agent 工具)不想为每种来源写一套对接。怎么给它们一个统一入口?

思路: 一个"信封"对象,每种来源占一个可选列表字段,谁没查就是 None

# 真实源码节选 search.py:255-265 AggregateSearchResult
class AggregateSearchResult(R2RSerializable):
chunk_search_results: Optional[list[ChunkSearchResult]] = None
graph_search_results: Optional[list[GraphSearchResult]] = None
web_page_search_results: Optional[list[WebPageSearchResult]] = None
web_search_results: Optional[list[WebSearchResult]] = None
document_search_results: Optional[list[DocumentResponse]] = None
generic_tool_result: Optional[Any] = None # FIXME: 尚无明确类型

几个要点:

  • 来源之间平级、不合并。 chunk 和 graph 各占一格,不会被拼成一个统一排名列表——上层拿到后自己决定怎么用两种来源(RAG 生成侧会分别给 chunk 和 graph 结果编 short-id 引用,见 第 04 章)。
  • web_* 两格由本章之外填充。 basic/fusion/hyde 的返回只填 chunk_search_results + graph_search_results(如 retrieval_service.py:611-614321-324);web 检索是 Agent/Deep-Research 层的事(见 第 05 章)。
  • as_dict() 做了序列化兜底。 search.py:275 起把每格 None 归一成 [],并对每个元素调 to_dict()——注意 chunk 那格用的是 result.as_dict(),graph/web 那格用 to_dict(),细节上不完全统一。

6.1 一次完整数据流回顾

问题 q
│ embed 一次 → vec(basic 直接算;hyde/fusion 复用)
├────────────────────────────┬────────────────────────────┐
▼ ▼ ▼
chunk 检索 graph 检索(本章) (web,仅 agent 层)
pgvector 三路 graph_search × 3
(第 01 章) 实体/关系/社区
│ │
▼ ▼
list[ChunkSearchResult] list[GraphSearchResult]
└──────────────┬─────────────┘

AggregateSearchResult
{ chunk_search_results, graph_search_results, web_*… }

上层 RAG 生成 / Agent(第 04、05 章)

7. 巧妙之处 & 边界

7.1 值得借鉴

  • 向量复用穿透整条链。 precomputed_vector 从策略层一路传到图检索,一个问题只 embed 一次就同时喂 chunk 和三路图检索(retrieval_service.py:626-647)。
  • 三张表同构、参数化复用同一条 SQL。 graph_search 靠拼 table_nameproperty_names_strembedding_type 三个变量,一段 SQL 覆盖实体/关系/社区(graphs.py:2581-2605),没有为三类各写一遍。
  • 联合类型 + 类型标签让异构图对象能进同一个 list[GraphSearchResult],上层只认外壳(search.py:140)。
  • 统一信封让"多来一路来源"只是给 AggregateSearchResult 加一个可选字段,不动上层主干(search.py:255)。

7.2 边界与已知糙点(诚实)

现象说明位置
图检索只有向量一路hybrid/fulltext 传了也被忽略,只 warninggraphs.py:2576-2579
距离为 0 → 分数 "n/a"0.0 被判 falsy,极端命中反而丢分graphs.py:2615-2619
关系/社区外壳 id 串用 ent顶层 .id 取到"最后一个实体"的 id;实体路空时会 NameError(inferred)retrieval_service.py:830, 878
1 - distance 假设向量归一化未归一化时该值不是标准余弦相似度graphs.py:2616
三路结果不跨类型排名返回顺序恒为"实体→关系→社区"retrieval_service.py:780-895
generic_tool_result 无类型源码自带 # FIXMEsearch.py:263

8. 代码地图(导航索引)

主题文件符号
图检索编排(embed→三路→包装)py/core/main/services/retrieval_service.py_graph_search_logic
chunk+graph 共向量并行拉起py/core/main/services/retrieval_service.py_fanout_chunk_and_graph_search
basic 策略拉图检索py/core/main/services/retrieval_service.py_basic_search
图结果的第二级 RRFpy/core/main/services/retrieval_service.py_reciprocal_rank_fusion_graphs
图检索 SQL(pgvector KNN)py/core/providers/database/graphs.pygraph_search
图过滤器构建py/core/providers/database/graphs.py_build_filters
表名拼接py/core/providers/database/graphs.py_get_table_name / _get_entity_table_for_store
按结构读关系/实体py/core/providers/database/graphs.pyget_relationships / get_entities
实体邻域映射py/core/providers/database/graphs.pyget_entity_map
图结果类型建模py/shared/abstractions/search.pyGraphSearchResult / GraphSearchResultType
三种图内容py/shared/abstractions/search.pyGraphEntityResult / GraphRelationshipResult / GraphCommunityResult
多源统一信封py/shared/abstractions/search.pyAggregateSearchResult
图检索开关与逐类限量py/shared/abstractions/search.pyGraphSearchSettings
StoreType 枚举py/shared/abstractions/graph.pyStoreType