AI知识图谱RAG实践笔记
AI知识图谱RAG一旦进项目,好看的架构图就没那么管用了。
所以把一堆文档变成知识图谱,再用图谱做 RAG,到底能不能比传统方案好点,中间会踩哪些坑。
为什么想搞知识图谱 RAG
起因是个很实际的需求:公司内部存了上百份技术文档、产品说明、故障排查记录,搜索体验一直不太稳定。用向量检索有时能找到点相关内容,但经常出现三种尴尬情况:
- 找得到但连不上:每个单独的片段都对的,但拼在一起说不通完整故事
- 答非所问:语义相似但方向不对,比如问"如何修复 A 报错",结果返回的是"A 报错的常见原因"
- 缺乏上下文:知道某组件能用某种配置,但不知道这个配置会和别的什么冲突
一开始以为只是向量模型没调好,换了几种 embedding、调了 chunk 大小、加了 rerank,效果有提升但不解决根本问题。后来想想,这些文档本来就有内在关系——组件依赖、调用链、故障传导、版本变迁——但检索时把这些结构全丢了,只剩一个"语义相似度"打分。
知识图谱这个概念其实早就有,但以前觉得搭建图谱太重、数据录入成本高。现在有了大模型的提取能力,可以从文本里自动抽取实体和关系,成本就没那么吓人了。所以就想试试:能不能先把文档转成图谱,再做检索。
整体思路
先把整个流程画出来,这样后面说实现细节时容易对上号。
简单说就是两步:先把文档变成图谱,再用图谱检索上下文。第二步里,问题到答案之间还会做一次路径发现,找到和问题相关的实体链路,然后再把链路上的信息收集起来送入 LLM 生成答案。
文档到图谱:提取与构建
文档解析
文档解析是第一步,坑就先踩在这。我们的文档格式五花八门:Markdown、PDF、Confluence 导出 HTML、Word,还有些从其他系统转来的 JSON。一开始想把所有格式统一处理,后来发现不现实——每种格式都有奇怪的边界情况。
最后定了几个原则:
- 优先用 Markdown:如果原始文档支持 Markdown,就让它原样处理
- PDF 必须分段:不能按页,要按标题层级分段,避免一个 chunk 跨多个主题
- 少用复杂布局:有些文档用了大量表格、图表,OCR 或解析工具经常搞错结构
实际代码里用了一个分层解析器:先按文件扩展名选解析器,再按标题层级切分 chunk,每个 chunk 保持在 300–800 字之间。这样既保证语义完整,又不会太长导致后续提取噪声。
实体与关系提取
这是整个流程里最关键、也最折腾的地方。一开始想完全靠 LLM 自动提取,但很快发现问题:
- 实体识别不稳定:同一个东西,在不同文档里会被识别成不同粒度或不同命名
- 关系边界模糊:“A 使用 B 配置”,到底该提取成"使用"、“依赖”、“配置"还是其他关系
- 实体类型爆炸:不加约束时,模型会提取一堆细碎的类型,比如"系统参数"“环境变量"“配置项"等等
后来采用了半结构化的方案:先人工定义一个实体类型和关系类型的基线,再让 LLM 在这个约束下提取。比如实体类型限定为:服务、组件、配置、错误、API、产品、版本;关系类型限定为:调用、依赖、导致、修复、包含、兼容。
这样做的好处是图谱结构清晰,也方便后续检索;坏处是会漏掉一些确实重要但不在基线里的关系。最后选择了一个折中:每次提取后人工审查前 20 个结果,如果发现漏掉的重要关系类型就补进基线,跑几轮后基本稳定。
提取时用了一个带 Few-shot 的 prompt,同时要求 LLM 输出 JSON 格式:
def extract_entities_relations(chunk_text):
prompt = f"""
从以下文本中提取实体和关系,按照给定的类型约束输出 JSON。
文本:
{chunk_text}
实体类型:服务、组件、配置、错误、API、产品、版本
关系类型:调用、依赖、导致、修复、包含、兼容
输出格式:
{{
"entities": [{{"text": "...", "type": "..."}}],
"relations": [{{"source": "...", "target": "...", "relation": "..."}}]
}}
"""
response = llm.generate(prompt, temperature=0.1)
return parse_json(response)
注意这里把温度设得很低(0.1),因为提取任务要求稳定性比创造性重要。实际操作中还加了一层验证:如果 JSON 解析失败或者输出结构不对,就重试一次;再失败就人工介入。
图谱构建与存储
提取出来的实体和关系需要持久化存储。一开始考虑过 Neo4j,但搭建和维护成本有点高。后来选了个轻量方案:用 NetworkX 构建内存图谱,再序列化到文件。
import networkx as nx
def build_graph(extractions):
G = nx.DiGraph()
for extraction in extractions:
for entity in extraction["entities"]:
G.add_node(entity["text"], type=entity["type"])
for relation in extraction["relations"]:
source = relation["source"]
target = relation["target"]
rel_type = relation["relation"]
if G.has_node(source) and G.has_node(target):
if G.has_edge(source, target):
G[source][target]["relations"].append(rel_type)
else:
G.add_edge(source, target, relations=[rel_type])
return G
图谱构建时要注意去重和关系合并:同一个实体对之间可能存在多种关系,要存在一条边上,不要创建多条边。另外,实体文本要标准化:大小写、空格、常见缩写要统一,避免"Redis"和"redis"被当成两个节点。
实际存储时用 GEXF 格式:
import json
def save_graph(G, filepath):
# 转换为可序列化格式
graph_data = nx.node_link_data(G)
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(graph_data, f, ensure_ascii=False, indent=2)
这样后续加载时也方便,而且可以直接用 NetworkX 提供的各种算法做路径发现和子图提取。
图谱检索与答案生成
问题理解与实体识别
用户提问进来后,第一步是理解问题并提取其中的实体。这一步的准确度直接影响检索效果。
def extract_question_entities(question):
prompt = f"""
从以下问题中提取涉及的技术实体,只返回实体列表。
问题:{question}
实体类型:服务、组件、配置、错误、API、产品、版本
输出格式:["实体1", "实体2", ...]
"""
response = llm.generate(prompt, temperature=0)
entities = parse_list(response)
return entities
这里要注意:问题里可能包含别名、简称或者描述性表达,比如"那个缓存组件"指的是 Redis。LLM 有时能理解,但更多时候会误解。所以需要一个映射表,把常见别名标准化为图谱中的实体名。
图谱检索策略
图谱检索比向量检索复杂在:检索的目标不是"相似文本”,而是"相关路径”。我们用了三种策略组合:
- 直接关系检索:找到问题实体直接相连的节点和关系,适用于一跳就能覆盖的场景
- 多跳路径检索:从问题实体出发,找到 2–3 跳以内的所有路径,适用于需要追溯因果链的情况
- 子图扩展:先找到一个小核心子图,再根据边的类型和节点的度扩展到合适大小
实际代码里,先用直接关系检索快速筛选相关节点,再用多跳路径扩展上下文:
def retrieve_context(G, entities, max_hops=2):
context_nodes = set(entities)
for entity in entities:
if G.has_node(entity):
# 1-2 跳邻居
for node, distance in nx.single_source_shortest_path_length(
G, entity, cutoff=max_hops
).items():
context_nodes.add(node)
# 提取子图
subgraph = G.subgraph(context_nodes).copy()
return subgraph
路径发现时有个权衡:跳数太多会引入噪声,跳数太少又可能漏掉关键节点。我们通过实验发现,2 跳通常足够覆盖大部分场景,3 跳以上时噪声明显增加。
上下文收集与答案生成
检索到子图后,需要把它转换成文本形式送给 LLM。这里有个细节:图的遍历顺序会影响上下文的连贯性。我们用了两个技巧:
- 从问题实体开始,按 BFS 遍历,这样关系链条比较自然
- 对同一跳层的节点,按边的类型排序,比如"调用"关系排在"包含"关系前面
def graph_to_text(G, start_entities):
visited = set()
result = []
def bfs(start):
queue = [(start, 0)]
while queue:
node, depth = queue.pop(0)
if node in visited:
continue
visited.add(node)
node_type = G.nodes[node].get("type", "未知")
result.append(f"【{node_type}】{node}")
for neighbor in G.neighbors(node):
if neighbor not in visited:
edge_data = G[node][neighbor]
for rel in edge_data.get("relations", []):
result.append(f"→ {rel} → 【{G.nodes[neighbor].get('type', '未知')}】{neighbor}")
queue.append((neighbor, depth + 1))
for entity in start_entities:
bfs(entity)
return "\n".join(result)
生成答案时,把图谱上下文和原始问题一起送入 LLM:
def generate_answer(question, graph_context):
prompt = f"""
基于以下知识图谱上下文回答问题,只回答问题中的内容,不要编造。
问题:{question}
知识图谱上下文:
{graph_context}
答案:
"""
response = llm.generate(prompt, temperature=0.3)
return response
这里温度设成 0.3 是为了让回答有一点灵活性,但仍以事实为主。
踩过的坑
实体消歧
第一个大坑是实体消歧。比如"用户"这个实体,在不同文档里可能指"系统用户"“产品用户"“账号用户"等不同概念。提取时如果不区分,图谱里就混成一团了。
后来加了领域约束和上下文感知:提取时要求 LLM 判断实体在当前文档里的具体含义,然后在图谱里用"用户(系统)““用户(产品)“这样的命名区分。另外,还在存储时记录了实体的出现上下文,方便后续人工审查。
关系方向性
第二个坑是关系的方向性。比如"A 调用 B"和"B 被 A 调用"应该是同一个关系的正反方向,但提取时容易搞反,或者只提取了一个方向。
后来在图谱构建时统一了方向约定:大部分关系有明确方向,比如"调用"“导致"“修复”;少数关系是无向的,比如"兼容”。对于有向关系,存储时会同时保存正反两个方向的边,方便查询。
图谱规模爆炸
第三个坑是图谱规模。文档多了之后,实体和关系数量会线性增长,导致图谱查询变慢,而且噪声也多。
后来加了两个优化:
- 节点剪枝:删除度小于等于 1 的节点(只有一个连接的实体),这些通常是噪声
- 关系权重:给每条边赋一个权重,基于实体在文档中的共现频率,查询时按权重排序
def prune_graph(G, min_degree=2):
# 删除低度节点
low_degree_nodes = [node for node, degree in G.degree() if degree <= min_degree]
G.remove_nodes_from(low_degree_nodes)
return G
剪枝后图谱规模大概缩小了 30%,但查询质量基本没受影响。
冷启动与增量更新
最后一个坑是冷启动和增量更新。第一次构建图谱时,需要把所有历史文档跑一遍,耗时不短;后续文档更新时,如果每次都全量重建,成本太高。
后来改成了增量更新策略:
- 新文档:提取实体和关系,合并到现有图谱
- 旧文档修改:先从图谱里删除旧文档贡献的实体和关系(需要记录来源),再重新提取并合并
- 文档删除:清理相关实体和关系
为了支持增量更新,需要在图谱节点和边上记录来源信息:
G.add_node(entity, type=entity_type, sources=[doc_id])
G.add_edge(source, target, relations=[rel_type], sources=[doc_id])
删除文档时,先找出该文档贡献的所有节点和边,再清理度变为 0 的节点。
结果与对比
折腾了两个月后,终于把这个图谱 RAG 跑起来了。和传统向量检索对比,效果提升主要体现在几个方面:
准确度提升
用一个测试集对比两种方案的准确度(测试集包含 100 个问题,人工标注标准答案):
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
# 设置中文字体
plt.rcParams['font.sans-serif'] = ['DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
# 数据
methods = ['向量检索', '图谱RAG']
accuracy = [0.68, 0.82]
fig, ax = plt.subplots(figsize=(8, 5))
bars = ax.bar(methods, accuracy, color=['#6c757d', '#28a745'])
# 添加数值标签
for bar, acc in zip(bars, accuracy):
height = bar.get_height()
ax.text(bar.get_x() + bar.get_width()/2., height,
f'{acc:.0%}',
ha='center', va='bottom', fontsize=12)
ax.set_ylabel('准确度', fontsize=12)
ax.set_ylim(0, 1)
ax.set_title('图谱RAG vs 向量检索准确度对比', fontsize=14, pad=20)
ax.grid(axis='y', alpha=0.3)
plt.tight_layout()
plt.savefig('/tmp/accuracy_comparison.png', dpi=300, bbox_inches='tight')
plt.close()

图谱 RAG 在复杂问题上的优势更明显,尤其是需要跨文档连接或因果推理的问题。
响应时间
响应时间方面,图谱 RAG 略慢一些,但差距不大:
- 向量检索:约 200–400ms
- 图谱 RAG:约 300–500ms
主要额外开销在图谱路径发现和上下文收集上。如果图谱规模很大,可以考虑用图数据库(如 Neo4j)替代 NetworkX,查询效率会更高。
可解释性
图谱 RAG 的另一个优势是可解释性。每次回答时,可以把检索到的子图路径展示给用户,让他们知道答案是如何推导出来的。
比如问题"A 服务调用失败,可能是什么原因?",图谱 RAG 会展示一条路径:A 服务 → 调用 → B 组件 → 导致 → C 错误。这样用户可以顺着路径排查问题,而不只是拿到一个黑盒答案。
边界与后续
搞完一轮后,发现还有一些明显的问题:
- 长文档处理:超过 5000 字的长文档,提取质量明显下降,需要分段提取再合并
- 多语言文档:中英文混排的文档,实体提取会不一致,需要多语言模型
- 实时性:文档更新后图谱更新有延迟,做不到实时同步
- 图谱维护:长期运行后图谱会积累噪声,需要定期人工清理
后续计划做的事情:
- 探索图数据库替代方案,提升查询效率
- 完善实体消歧和关系标准化,减少人工干预
- 增加图谱的可视化界面,方便人工审查和维护
- 试试结合向量检索和图谱检索的混合方案
最后说句实在话:知识图谱 RAG 不是银弹。它比传统向量检索复杂不少,维护成本也更高。但在一些特定场景——尤其是需要理解和连接实体关系的问题上——它确实能带来明显提升。
就像开头说的,文档里的东西就像散在地上的积木,传统 RAG 帮你捡了几块,而图谱 RAG 试图告诉你这些积木原本拼成了什么样子。这个拼图过程有时很折腾,但拼出来的图确实比零散的积木有用。
至于值不值得折腾,就看你的问题是不是真的需要"看懂关系"了。
版权声明: 本文首发于 指尖魔法屋-AI知识图谱RAG实践笔记(https://blog.thinkmoon.cn/post/371-ai-knowledge-graph-rag-doc-graph-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。