AI知识图谱RAG实践笔记

AI知识图谱RAG一旦进项目,好看的架构图就没那么管用了。

所以把一堆文档变成知识图谱,再用图谱做 RAG,到底能不能比传统方案好点,中间会踩哪些坑。

为什么想搞知识图谱 RAG

起因是个很实际的需求:公司内部存了上百份技术文档、产品说明、故障排查记录,搜索体验一直不太稳定。用向量检索有时能找到点相关内容,但经常出现三种尴尬情况:

  • 找得到但连不上:每个单独的片段都对的,但拼在一起说不通完整故事
  • 答非所问:语义相似但方向不对,比如问"如何修复 A 报错",结果返回的是"A 报错的常见原因"
  • 缺乏上下文:知道某组件能用某种配置,但不知道这个配置会和别的什么冲突

一开始以为只是向量模型没调好,换了几种 embedding、调了 chunk 大小、加了 rerank,效果有提升但不解决根本问题。后来想想,这些文档本来就有内在关系——组件依赖、调用链、故障传导、版本变迁——但检索时把这些结构全丢了,只剩一个"语义相似度"打分。

知识图谱这个概念其实早就有,但以前觉得搭建图谱太重、数据录入成本高。现在有了大模型的提取能力,可以从文本里自动抽取实体和关系,成本就没那么吓人了。所以就想试试:能不能先把文档转成图谱,再做检索。

整体思路

先把整个流程画出来,这样后面说实现细节时容易对上号。

graph LR A[文档集合] --> B[文档解析] B --> C[实体关系提取] C --> D[图谱构建] D --> E[图谱存储] E --> F[问题理解] F --> G[实体识别] G --> H[图谱检索] H --> I[路径发现] I --> J[上下文收集] J --> K[答案生成]

简单说就是两步:先把文档变成图谱,再用图谱检索上下文。第二步里,问题到答案之间还会做一次路径发现,找到和问题相关的实体链路,然后再把链路上的信息收集起来送入 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 有时能理解,但更多时候会误解。所以需要一个映射表,把常见别名标准化为图谱中的实体名。

图谱检索策略

图谱检索比向量检索复杂在:检索的目标不是"相似文本”,而是"相关路径”。我们用了三种策略组合:

  1. 直接关系检索:找到问题实体直接相连的节点和关系,适用于一跳就能覆盖的场景
  2. 多跳路径检索:从问题实体出发,找到 2–3 跳以内的所有路径,适用于需要追溯因果链的情况
  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 在复杂问题上的优势更明显,尤其是需要跨文档连接或因果推理的问题。

响应时间

响应时间方面,图谱 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/) 转载或引用必须申明原指尖魔法屋来源及源地址!