跳到主要内容

离线建索引:OpenIE 与知识图谱构建

本章讲什么: index(docs) 内部——文档如何一步步变成一张"实体 + 段落"的知识图谱,三种边分别是什么、权重怎么来,以及增量更新的省钱技巧。入口方法 HippoRAG.indexsrc/hipporag/HippoRAG.py:262)。

1. 这一步要解决的问题

检索前,得先有一张图。难点不在"存向量"(那是普通 RAG 都会的),而在怎么从纯文本里抽出"谁和谁有什么关系",并把它组织成一张能跑 PageRank 的图。HippoRAG 用 OpenIE(开放信息抽取) 解决前半,用 igraph 解决后半。

2. 建索引主线(七步)

index() 的骨架就是下面这条流水线(HippoRAG.py:262-335):

① 切块 _preprocess_docs 文档 → Chunk(默认:一篇文档=一块)
② 存段落向量 chunk_embedding_store.insert_strings
③ OpenIE 抽取 openie.batch_openie 每块 → 命名实体 + 三元组
④ 汇出实体/事实 extract_entity_nodes / flatten_facts
⑤ 编码实体+事实 entity_/fact_embedding_store.insert_strings
⑥ 连边 add_fact_edges → add_passage_edges → add_synonymy_edges
⑦ 落图 augment_graph → save_igraph(graph.pickle)

下面逐个讲有含量的步骤。

3. OpenIE:从文字到三元组

思路

OpenIE 分两步 LLM 调用,都在 OpenIE 类里(information_extraction/openie_openai.py):

  1. NER——先让大模型列出这段文字里的命名实体(OpenIE.ner, :45)。
  2. 三元组抽取——把上一步的实体列表塞回提示,让大模型输出 [主语, 谓语, 宾语] 三元组(OpenIE.triple_extraction, :81)。

为什么先 NER 再抽三元组,而不是一步到位?因为先框定实体,能约束大模型只在这些实体之间连关系,抽出的三元组更干净。提示里明确要求"每条三元组尽量含实体列表里的两个实体"(prompts/templates/triple_extraction.py:7-10)。

原理演示

一段输入和它抽出的东西长这样(提示模板里的真实 one-shot 示例,prompts/templates/ner.py + triple_extraction.py):

# 示意:OpenIE 两步的输入输出
passage = "Radio City is India's first private FM radio station, started on 3 July 2001."

# 第一步 NER →
entities = ["Radio City", "India", "3 July 2001"]

# 第二步 三元组抽取(条件在 entities 上)→
triples = [
["Radio City", "located in", "India"],
["Radio City", "is", "private FM radio station"],
["Radio City", "started on", "3 July 2001"],
]

重点看:三元组的主语和宾语,将来就是图里的实体节点;谓语只作为"事实"文本参与后续的事实检索,不单独成节点。

工程细节

  • 并发抽取batch_openieThreadPoolExecutor 先并发跑完所有 NER,再并发跑所有三元组抽取(openie_openai.py:157-205),并累加 token 数和缓存命中数打进度条。
  • JSON 容错:大模型输出被 length 截断时,用正则从半截 JSON 里把实体/三元组捞出来(_extract_ner_from_response, openie_openai.py:30fix_broken_generated_json)。
  • 抽取结果落盘复用:结果存成 openie_results_ner_<llm>.json,下次 load_existing_openie 按 chunk 的哈希 id 判断哪些块已抽过、只补新块(HippoRAG.py:1027 load_existing_openie)。这让重复实验不必重抽——OpenIE 是整条流水线里最贵的一步。

4. 三种节点与三种边(图的核心)

这是全项目最该记牢的结构。图里只有两类节点,但有三种边。

节点

节点类型是什么id 前缀来源
实体节点(phrase)三元组里的主语/宾语短语entity-extract_entity_nodes
段落节点(passage)一个文档块的全文chunk-切块后的每个 Chunk

节点 id 是内容的 MD5 哈希加前缀(compute_mdhash_id, utils/misc_utils.py:141)——同样的文本永远得到同一个 id,这是去重和增量更新的基石。

三种边

[实体A] ══事实边══ [实体B] 三元组 (A,谓语,B) → A、B 互连,权重=共现次数

段落边(权重1.0)

[段落P] ── 段落P 抽出过实体A → P 连到 A

[实体A] ┄┄同义边┄┄ [实体A'] 向量相近的两个实体 → 互连,权重=相似度
边类型连谁和谁权重代码
事实边实体 ↔ 实体(同一三元组的主宾)该对实体的共现次数add_fact_edgesHippoRAG.py:872
段落边段落 → 它抽出过的实体固定 1.0add_passage_edgesHippoRAG.py:920
同义边实体 ↔ 语义相近的实体余弦相似度add_synonymy_edgesHippoRAG.py:964

三种边合起来存进一个中间字典 node_to_node_stats(键是节点对,值是权重),最后由 add_new_edges 一次性灌进 igraph(HippoRAG.py:1196)。图默认无向is_directed_graph=False)。

为什么要这三种边

  • 事实边让"同一句话里出现的实体"相连——这是多跳联想的主干道。
  • 段落边让"实体"能通到"含它的原文段落"——PPR 扩散到实体后,得能落回到该返回的段落。
  • 同义边弥补 OpenIE 的抽取噪声:"美国"和"United States"、"Leland Stanford"和"Stanford"可能抽成不同实体,同义边把它们缝合起来,避免图被割裂。

5. 同义边:用 KNN 缝合近义实体

思路

OpenIE 抽出的实体名千奇百怪。如果"斯坦福大学"和"Stanford University"是两个孤立节点,多跳就断了。add_synonymy_edges 的做法:对每个实体,用向量最近邻找出语义相近的实体,超过阈值就连一条边

关键实现

# 示意,非源码:同义边的核心判断(对照 HippoRAG.py:1002-1023)
for node, neighbors in knn_of_each_entity.items():
if len(re.sub('[^A-Za-z0-9]', '', entity)) <= 2:
continue # 太短的实体(如"a")跳过,噪声大
for nn, score in neighbors:
if score < 0.8: # synonymy_edge_sim_threshold
break # 邻居已按分降序,低于阈值就停
node_to_node_stats[(node, nn)] = score # 边权 = 相似度

真实实现用 retrieve_knnutils/embed_utils.py:6)做批量 KNN,参数 synonymy_edge_topk=2047、阈值 synonymy_edge_sim_threshold=0.8config_utils.py:160-175)。

增量省钱的巧思

注释点明:只在"新插入的实体"和"全部实体"之间建同义边,而不是全体两两算(HippoRAG.py:990 附近注释)。这样增量加文档时,KNN 的开销只和"新增量"成正比,不会随图变大而爆炸。这是 HippoRAG 相比 GraphRAG 等"离线建图成本更低"的一个具体来源。

6. 存储:三个独立的向量库

段落、实体、事实各存一个 EmbeddingStoreHippoRAG.py:154-174),默认后端是本地 Parquet 文件(embedding_store.py:85 EmbeddingStore)。为什么分三个?因为在线检索时它们扮演不同角色:

向量库存什么检索时用来
fact_embedding_store三元组字符串第①步:问题 vs 事实打分
chunk_embedding_store段落全文向量检索(DPR)给段落打分
entity_embedding_store实体短语建同义边;映射种子实体到图节点

工厂函数 get_embedding_storeembedding_store.py:224)按 vector_store_type 切换后端,除 Parquet 外还支持 Qdrant / Chroma / Milvus。

7. 增量与删除

  • 增量加:再调 index()load_existing_openie 和各 store 的 insert_strings 会自动跳过已存在的 id,只处理新内容;只有真有新块时才重跑同义边和存图(HippoRAG.py:330)。
  • 删除delete(docs)HippoRAG.py:337)会算出"只属于被删块"的三元组和实体(用 remove_sources_from_mapping 判断该实体/三元组是否还被其他未删块引用),把它们从各 store 和图里摘掉——被其他文档共享的实体不会被误删HippoRAG.py:380-397)。

8. 本章小结

建索引 = OpenIE 抽三元组 + 连成"两类节点、三种边"的无向加权图。记住三件事:

  1. 节点只有实体和段落两类;事实是边不是节点。
  2. 三种边(事实/段落/同义)各司其职,权重来源不同(共现数 / 1.0 / 相似度)。
  3. 一切靠内容哈希 id 去重,这让增量和删除都能"精准而非全量"。

下一章看这张图怎么在提问时被点亮。