数据截至 (上游 commit c412a093a820)
跨模态知识图谱:从段文本到实体网络,再反查回片段
本章讲实体通道的两端:索引期怎么把段文本抽成图谱(
extract_entities),查询期怎么从命中实体反查回视频段(_find_most_related_segments_from_entities)。图谱抽取整体复用了微软 GraphRAG 的范式(prompt.py:2注明)。
4.1 实体抽取:靠「分隔符协议」让 LLM 输出可解析
它要解决的小问题: 让 LLM 从一段文本里抽出结构化的实体和关系,又不依赖脆弱的 JSON。
思路: 用一套自定义分隔符当输出格式,比 JSON 更抗模型「话痨」。提示词(prompt.py:9)要求模型输出形如:
("entity"<|>实体名<|>类型<|>描述)##
("relationship"<|>源<|>目标<|>关系描述<|>强度分)##
<|COMPLETE|>
其中 <|> 是 tuple 分隔、## 是记录分隔、<|COMPLETE|> 是结束符(prompt.py:138-140)。解析时按这些标记切开(_op.py:405、split_string_by_multi_markers),再正则抠出括号内内容(_op.py:413)。默认实体类型只有四类:organization/person/geo/event(prompt.py:137)。
4.2 Gleaning:多轮「还有没漏的吗」补抽
它要解决的小问题: LLM 一次抽取往往漏实体。
思路(_op.py:390): 抽完第一遍后,带着历史对话再追问「上次漏了很多实体,按同样格式补上」(entiti_continue_extraction,prompt.py:127),最多补 entity_extract_max_gleaning=1 轮(videorag.py:99)。中间还会问一句「还有没有遗漏?答 YES/NO」(entiti_if_loop_extraction,prompt.py:132),答非 yes 就提前停(_op.py:402)。
# 示意,源自 _op.py:390 — 带历史多轮补抽,结果累加
for i in range(entity_extract_max_gleaning):
glean = await use_llm_func(continue_prompt, history_messages=history)
final_result += glean # 补抽的实体也算进来
if 模型回答不再有遗漏: break
所有 chunk 的抽取是 asyncio.gather 并发跑的(_op.py:448)。
4.3 合并与摘要:同名实体如何归一
同一个实体可能在多个 chunk 被抽到,描述各异。_merge_nodes_then_upsert(_op.py:252)做归并:
- 类型:取出现次数最多的那个 type(
Counter投票,_op.py:270)。 - 描述:所有描述去重后用
<SEP>拼接(GRAPH_FIELD_SEP,prompt.py:6)。 - 来源:把所有
source_id(即 chunk key)用<SEP>拼起来——这是反查片段的关键线索。 - 过长则摘要:描述 token 超
entity_summary_to_max_tokens=500(videorag.py:100)就调便宜模型压缩(_handle_entity_relation_summary,_op.py:181)。
边的合并(_merge_edges_then_upsert,_op.py:299)类似,但权重是累加的(_op.py:321)——同一对实体被多次关联 ,关系越强。图是无向图:合并时对端点排序去重(_op.py:459)。最后实体名+描述拼起来也写进实体向量库(_op.py:476),供查询时语义召回。
4.4 反查:从实体回到视频段(实体通道的「最后一公里」)
查询命中若干实体后,要落回视频段。_find_most_related_segments_from_entities(_op.py:487)的链路:
命中实体 node_datas
├─ 每个实体的 source_id(拆 <SEP>) → 它来自哪些文字 chunk
├─ 取每个实体的一跳邻居 get_node_edges → 邻居也带各自的 source chunk
└─ 给每个 chunk 算「relation_counts」
= 该 chunk 同时出现在「实体邻居的源 chunk」里的次数(共现强度)
└─ 按 relation_counts 降序取 top_k(=retrieval_topk_chunks=2)
└─ 收集这些 chunk 的 video_segment_id → 得到相关视频段集合
核心打分逻辑(_op.py:519):一个 chunk 如果同时被「命中实体的多个一跳邻居」引用,说明它在这个实体的关系网里很中心,得分高:
# 示意,源自 _op.py:519 — 用一跳邻居的源 chunk 共现给候选 chunk 打分
for e in this_edges: # 该实体的每条边
if e[1] in 邻居源chunk表 and c_id in 邻居源chunk表[e[1]]:
relation_counts += 1 # 邻居也引用了这个 chunk → 加分
最后从高分 chunk 的 video_segment_id 收集出片段集合(_op.py:540)——这就是实体通道交给主流程的「相关片段集 A」。
这一步把「文字图谱」和「视频段」缝在了一起: 图谱里全是文字实体,但每个实体记得自己来自哪个 chunk,而 chunk 在索引期(01 章 2.3 节)记了自己由哪些视频段组成。一条 source_id → video_segment_id 的链路就把语义检索的结果落回了画面。
代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 实体抽取主流程 | videorag/_op.py | extract_entities |
| 单条实体解析 | videorag/_op.py | _handle_single_entity_extraction |
| 单条关系解析 | videorag/_op.py | _handle_single_relationship_extraction |
| 节点合并/摘要 | videorag/_op.py | _merge_nodes_then_upsert |
| 边合并(权重累加) | videorag/_op.py | _merge_edges_then_upsert |
| 描述过长摘要 | videorag/_op.py | _handle_entity_relation_summary |
| 实体反查片段 | videorag/_op.py | _find_most_related_segments_from_entities |
| 抽取/补抽提示词 | videorag/prompt.py | PROMPTS["entity_extraction"], PROMPTS["entiti_continue_extraction"] |
| 图存储(默认) | videorag/_storage/gdb_networkx.py | NetworkXStorage |