为什么 SAG 敢不建全局知识图谱?这个问题比那 11.52 个点的 Recall 提升更值得拆。检索系统通常两条路:要么提前把图建好,要么干脆用向量相似度硬搜。SAG 两样都不选,等于在两条熟路之外自己开了一条。这篇我们把它那套“事件-实体索引”的骨架抽出来,写成一个四十来行的最小版本,跑一遍。
先拆名字。“事件-实体索引”听起来唬人,其实就一件事:把每个文本块当成一个语义完整的“事件”,给这个块抽一堆实体,然后按实体建倒排。它说的“事件”不是真去解析谓词结构,不做谓词归一化,不做关系抽取,就是“这块文本讲了件什么事”的粗粒度容器。每个块带着实体的集合,像一条超边——一头挂多个实体,另一头挂一整段原文。
最朴素的版本这样写:
class SAGIndex:
def __init__(self):
self.chunks = {} # chunk_id -> {"text": str, "entities": list}
self.entity_index = {} # entity -> set(chunk_id)
def add_chunk(self, chunk_id, text, entities):
self.chunks[chunk_id] = {"text": text, "entities": entities}
for e in entities:
self.entity_index.setdefault(e, set()).add(chunk_id)
def retrieve(self, query_entities, hops=2):
# 第一跳:query 里的实体直接命中
seeds = set()
for e in query_entities:
seeds |= self.entity_index.get(e, set())
frontier = set(seeds)
expanded = set(seeds)
for _ in range(hops):
next_frontier = set()
for cid in frontier:
# 能到这一步的 chunk,它的实体就是下一步的键
for e in self.chunks[cid]["entities"]:
next_frontier |= self.entity_index.get(e, set())
expanded |= next_frontier
frontier = next_frontier
return list(expanded) # 直接返回原始文本块 id
跑一遍。SAG 的查询时连接就是一次超边上的邻居扩展。关系被推迟到查询时才发生,靠共享实体当场把相关块串起来,建库阶段什么都不用预先织死。证据从头到尾是原始文本块,没有中间表示。
跟普通倒排的差别也在这里。普通倒排是 query 里的实体命中了谁就返回谁,结束。SAG 多一步:每个命中的块上再取它的实体,用这些实体再进去捞一轮。多出来的这一步,才是它那个“多跳推理优势随链长扩大”的来源。一条查询能沿着实体链在文档里往外走,而不是停在第一跳。
试个 MuSiQue 里的三跳问法。两个块:
- C1: 实体
[A, 电影节],文本是“B 导演在 A 电影节上介绍了自己的新片。” - C2: 实体
[B, 小说名],文本是“B 导演的电影改编自 C 写的小说。”
问“B 导演的电影改编自谁的小说”,query 实体 [B, 小说名]。第一跳命中 C1 和 C2。第二跳从 C1 的 A 和电影节往外走,找不到新东西;从 C2 的小说名也出不去。答案 C 在 C2 原文里,直接返回 C2 就行。
这个例子其实显不出长链优势。我一开始也想把它拉长一点,造一个工整的三跳样本,让答案必须从 C2 再往外跳一层才够得着。但没必要。SAG 和大多数图检索系统的差别,不在链能拉多长,在一个更底层的取舍:不拆三元组。
拆三元组的那套会做一件事:把 C2 里的“B 导演的电影改编自 C 写的小说”拆成 (B 电影, 改编自, C 小说),然后把 B 电影、C 小说各自建成节点。看起来没损失。但一落到多跳问答里就出问题。原文里“C 写的小说”这个完整语义,拆完之后变成 C 和小说两个独立实体,中间那层“谁写的、哪一部”就稀薄了。重新拼回来得再做一次链接,而那次链接的证据链已经断了。SAG 从头到尾不碰这个,原文块压着不动,链再长也不会把上下文拆散。
“超边”这两个字有误导性。我一开始也以为只是给图里多跳关系换了个新名字。翻完论文才确认,它这个超边不是图里的那种超边——没做实体归一化,也不要构建阶段知道关系类型,只靠“查询时共享实体”这一条规则来连块,连得多了自然形成一个查询范围内的动态邻域。这个和建库时定死关系类型的做法,隔了整整一代。
还有一件事。SAG 把证据保留为原始文本块,不只是工程洁癖。嵌入模型看一段原文,和看一个从图里拼回来的合成节点,能编码进去的语义不在一个量级。下游 LLM 拿到的是原封不动的文本块,不是丢了边角料的中间表示,端到端 QA 的性能才会跟着检索一起涨。
这个最小版本故意没做实体归一化。真实 SAG 里实体识别是个重活,长实体短实体都要投进同一个倒排,还得处理上下文里的指代。先放一边,够把骨架看透。
所以那 11.52 个点的差距,拆到这一层核心就一句话:不拆。不拆 n 元关系,不拆原文,不建全局图。查询时临时连,连出来的每一步都踩在原始证据上。这个判断算不上新鲜,但写成四十几行跑一遍,和读一百遍论文里 event-entity index 的示意图,懂的深度是两回事。
这块地我据过,往下还有的是。想再深一层,去翻论文里查询时邻居扩展的具体裁剪逻辑,看它怎么控制跳跃半径、怎么防止跟错了实体链、怎么决定每一跳该不该保留沿途的块。或者自己动手把这个最小版本接到一个白板 embedding 检索器前面,看它怎么跟向量路径配合。自己敲一遍,比看我的文字快。