离线建索引:OpenIE 与知识图谱构建
本章讲什么:
index(docs)内部——文档如何一步步变成一张"实体 + 段落"的知识图谱,三种边分别是什么、权重怎么来,以及增量更新的省钱技巧。入口方法HippoRAG.index(src/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):
- NER——先让大模型列出这段文字里的命名实体(
OpenIE.ner,:45)。 - 三元组 抽取——把上一步的实体列表塞回提示,让大模型输出
[主语, 谓语, 宾语]三元组(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_openie用ThreadPoolExecutor先并发跑完所有 NER,再并发跑所有三元组抽取(openie_openai.py:157-205),并累加 token 数和缓存命中数打进度条。 - JSON 容错:大模型输出被
length截断时,用正则从半截 JSON 里把实体/三元组捞出来(_extract_ner_from_response,openie_openai.py:30;fix_broken_generated_json)。 - 抽取结果落盘复用:结果存成
openie_results_ner_<llm>.json,下次load_existing_openie按 chunk 的哈希 id 判断哪些块已抽过、只补新块(HippoRAG.py:1027load_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_edges(HippoRAG.py:872) |
| 段落边 | 段落 → 它抽出过的实体 | 固定 1.0 | add_passage_edges(HippoRAG.py:920) |
| 同义边 | 实体 ↔ 语义相近的实体 | 余弦相似度 | add_synonymy_edges(HippoRAG.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_knn(utils/embed_utils.py:6)做批量 KNN,参数 synonymy_edge_topk=2047、阈值 synonymy_edge_sim_threshold=0.8(config_utils.py:160-175)。
增量省钱的巧思
注释点明:只在"新插入的实体"和"全部实体"之间建同义边,而不是全体两两算(HippoRAG.py:990 附近注释)。这样增量加文档时,KNN 的开销只和"新增量"成正比,不会随图变大而爆炸。这是 HippoRAG 相比 GraphRAG 等"离线建图成本更低"的一个具体来源。
6. 存储:三个独立的向量库
段落、实体、事实各存一个 EmbeddingStore(HippoRAG.py:154-174),默认后端是本地 Parquet 文件(embedding_store.py:85 EmbeddingStore)。为什么分三个?因为在线检索时它们扮演不同角色:
| 向量库 | 存什么 | 检索时用来 |
|---|---|---|
fact_embedding_store | 三元组字符串 | 第①步:问题 vs 事实打分 |
chunk_embedding_store | 段落全文 | 向量检索(DPR)给段落打分 |
entity_embedding_store | 实体短语 | 建同义边;映射种子实体到图节点 |