跳到主要内容

Graphiti 混合检索 — 多路召回 + 重排

本章讲 search:一个查询进来,怎么从图里把相关事实/实体捞出来排好序。核心思路是「多路召回 + 重排」——不押注单一信号,而是让向量、关键词、图结构各召回一批,再融合。

1. 要解决的小问题

纯向量检索擅长「语义近」但常漏掉精确关键词;纯 BM25 擅长关键词但不懂同义;图里还有「邻近关系」这个向量和 BM25 都用不上的信号。Graphiti 不挑,而是全都要,然后用重排器把多路结果融合成一个排序。

2. 顶层结构:四个层 × 多路方法

search(search/search.py:98)把检索拆成四个层(scope),并行执行:

search(query, config, ...)
│ (先按需把 query 嵌成向量)

并行 semaphore_gather:
├─ edge_search → 返回 EntityEdge(事实)
├─ node_search → 返回 EntityNode(实体)
├─ episode_search → 返回 EpisodicNode(原始数据)
└─ community_search → 返回 CommunityNode(社区摘要)

合并成 SearchResults

哪些层开、每层用哪些召回方法、用哪个重排器,全由 SearchConfig 决定(search/search.py:168-225)。每个层内部又是「多路召回 → 重排 → 取 top-k」的统一结构。

一个性能细节: 只有当配置里真的需要向量(某层用了 cosine 或 MMR)时才嵌入 query;否则用零向量占位省掉一次 embedding 调用(search/search.py:120-152)。

3. 以 edge_search 为例:一层内部怎么走

edge_search(search/search.py:253)是最典型的层,拆开看:

3.1 多路召回(按 config 开关)

# 示意,非源码(逻辑见 search/search.py:283-311)
if EdgeSearchMethod.bm25 in config.search_methods:
tasks.append(edge_fulltext_search(...)) # 关键词
if EdgeSearchMethod.cosine_similarity in ...:
tasks.append(edge_similarity_search(...)) # 向量
if EdgeSearchMethod.bfs in ...:
tasks.append(edge_bfs_search(...)) # 图遍历
results = await semaphore_gather(*tasks) # 并行

每路召回 2 * limit 条作为候选池(search/search.py:286 等),给重排留余量。

3.2 重排(五选一)

召回完,所有边汇成 edge_uuid_map(uuid→边),然后按 config.reranker 选一种重排(search/search.py:368-445):

重排器怎么排用什么信号
rrf倒数排名融合,见 §4多路名次
mmr最大边际相关,见 §5向量(相关+多样)
cross_encoder把 query 和每条 fact 配对打分一个重模型
node_distance按到 center 节点的图距离排图结构
episode_mentions按边被多少 episode 提及排提及次数

排完取前 limit 条返回(search/search.py:460)。node/episode/community 层是同构的(search/search.py:463663763)。

4. RRF:倒数排名融合(默认融合器)

问题: 三路召回各给一个排序,名次怎么合并?RRF(Reciprocal Rank Fusion)的做法极简:一个文档在某路排第 i 名,就得 1/(i + 常数) 分,各路相加。

# 示意,非源码(逻辑见 search/search_utils.py:1780-1795)
scores = defaultdict(float)
for result in results: # 每一路召回的 uuid 列表
for i, uuid in enumerate(result):
scores[uuid] += 1 / (i + rank_const) # rank_const 默认 1
# 按总分降序

直觉:在多路里都排前面的,总分高;只在一路偶然命中的,分数低。它不需要各路分数可比(向量分和 BM25 分量纲不同),只看名次,所以特别适合融合异构信号。

5. MMR:相关又不冗余

RRF 可能让前几条高度相似(都讲同一件事)。MMR(Maximal Marginal Relevance) 在「与 query 相关」和「与已选结果不重复」之间权衡:

# 示意,非源码(逻辑见 search/search_utils.py:1926-1930)
mmr = mmr_lambda * sim(query, doc) + (mmr_lambda - 1) * max_sim(doc, 其他候选)
# ↑ 越相关越高 ↑ 与别的越像,惩罚越大

mmr_lambda 调节两者权重。它先把所有候选的向量两两算相似度矩阵(search/search_utils.py:1915-1924),再算每个的 MMR 分。适合「想要覆盖面、不想一堆近似重复」的场景。

6. 节点距离重排:利用图结构

center_node_uuid 时可用 node_distance_reranker(search/search_utils.py:1798):按候选节点到中心节点的图距离排序,越近越靠前。实现是查 center -[:RELATES_TO]- n 的直接邻居给 1 分,够不到的给 inf,再按距离排(search/search_utils.py:1816-1848)。

用途:「围绕这个实体,跟它关系最近的事实优先」。基础 graphiti.search 在传了 center_node_uuid 时就自动切到这个配方(graphiti.py:1569-1571)。

7. 两个入口:search 与 search_

Graphiti 暴露两个检索 API:

API返回配置适合
search只返回 list[EntityEdge](事实)内部按有无 center 选 RRF 或 node_distance开箱即用
search_返回完整 SearchResults(点+边+episode+社区)任意 SearchConfig 配方进阶/可调

graphiti.py:1526(search)与 graphiti.py:1602(search_)。search_ 默认配方是 COMBINED_HYBRID_SEARCH_CROSS_ENCODER(graphiti.py:1606)。现成配方在 search/search_config_recipes.py(如 EDGE_HYBRID_SEARCH_RRF:111NODE_HYBRID_SEARCH_RRF:156)。

8. 检索也是去重的一部分

值得注意:检索不只服务于「用户查询」。[第 2 章]的事实去重里,resolve_extracted_edges 就直接调 search(...EDGE_HYBRID_SEARCH_RRF...) 来召回「可能重复/可能矛盾」的候选边(edge_operations.py:392-418)。所以这套检索引擎在写入路径里也被复用。

代码地图

主题文件符号
检索总入口graphiti_core/search/search.pysearch
边层检索graphiti_core/search/search.pyedge_search
点层检索graphiti_core/search/search.pynode_search
RRF 融合graphiti_core/search/search_utils.pyrrf
MMR 去冗graphiti_core/search/search_utils.pymaximal_marginal_relevance
节点距离重排graphiti_core/search/search_utils.pynode_distance_reranker
提及次数重排graphiti_core/search/search_utils.pyepisode_mentions_reranker
向量召回graphiti_core/search/search_utils.pynode_similarity_searchedge_similarity_search
全文召回graphiti_core/search/search_utils.pynode_fulltext_searchedge_fulltext_search
配置/配方graphiti_core/search/search_config.pysearch_config_recipes.pySearchConfigEDGE_HYBRID_SEARCH_RRF
简易/进阶入口graphiti_core/graphiti.pyGraphiti.searchGraphiti.search_