向量检索的关系盲点
前面几篇从各个角度优化了检索质量:更好的分块、Rerank 重排序、查询改写、CRAG 纠偏。但有一类问题,这些方法都帮不上多少忙:
需要横跨多个实体推理的问题。
举个例子:
"bge-large-zh-v1.5 和 bge-reranker-v2-m3 都来自哪个机构?各自在 RAG 中扮演什么角色?"
向量检索会找到提到 BAAI 或 bge 的文档片段------这没问题,两个模型都会出现。但检索的语义是"找相似",不是"找关系"。LLM 拿到的是两段分散的文本,需要自己去理解"它们都来自 BAAI"这个关联。
再复杂一点:
"从 RAG 到 CRAG,检索质量评估经历了哪些演进步骤?"
这个问题要求按技术演进顺序把 RAG → Rerank → Self-RAG → CRAG 串联起来。向量检索找到的是语义最相似的 top-4 文档,不保证是这条演进链上的节点。
Graph RAG 的切入点:把文档中的实体和关系显式提取出来,构建成知识图谱。检索时不是"找最像的文档",而是"从问题实体出发,沿关系边遍历图"------这天然适合多跳推理。
知识图谱的核心概念
知识图谱的基本单位是三元组:
(头实体,关系,尾实体)
例如:
sql
BAAI --[开发]--> bge-large-zh-v1.5
BAAI --[开发]--> bge-reranker-v2-m3
bge-large-zh-v1.5 --[用于]--> 向量检索
bge-reranker-v2-m3 --[用于]--> 重排序
把这些三元组组织成有向图(NetworkX DiGraph),"BAAI 开发了哪些模型"这个问题就变成了:找到 BAAI 节点,列出所有出边。
Graph RAG 的完整流程
css
构建阶段(离线):
文档 → LLM 提取三元组 → NetworkX 有向图
文档 → Embedding → ChromaDB 向量索引
查询阶段(在线):
问题
↓
LLM 提取问题实体(如 BAAI、bge-large-zh-v1.5)
↓
模糊匹配图节点(子串匹配)
↓
BFS 2跳遍历:seed_nodes → 邻居 → 邻居的邻居
↓
组装三元组上下文(图遍历结果 + top-2 向量文档补充)
↓
LLM 生成答案
关键设计:Graph RAG 不是纯图检索,而是图遍历上下文 + 向量检索的混合。纯图遍历有"实体边界"------如果问题中的实体没被提取进图,就会漏检;向量检索兜底覆盖这类盲区。
实现:三元组提取
先踩了一个坑
LangChain 提供了 LLMGraphTransformer,专门用于从文本提取图结构。我最初使用它:
python
from langchain_experimental.graph_transformers import LLMGraphTransformer
graph_transformer = LLMGraphTransformer(llm=llm)
graph_docs = graph_transformer.convert_to_graph_documents([doc])
结果 12 篇文档全部报错:
typescript
Invalid JSON: invalid number at line 1 column 2
[type=json_invalid, input_value='- Node: RAG (Retrieval-A...']
原因:LLMGraphTransformer 要求 LLM 返回严格的 JSON 格式,但 GLM-4-flash 返回的是文本列表(- Node: xxx),Pydantic 解析失败。
改用自定义 prompt
放弃 JSON,改用更宽松的分隔符格式------实体A | 关系 | 实体B,每行一个三元组,split("|") 解析,不依赖 JSON,容错性强:
python
TRIPLE_EXTRACT_PROMPT = ChatPromptTemplate.from_messages([
("system",
"从以下文本中提取实体和关系,输出三元组列表。\n"
"格式要求:每行一个三元组,格式严格为:实体A | 关系 | 实体B\n"
"规则:\n"
"- 实体用名词短语,不加括号或引号\n"
"- 关系用动词短语,如:使用、包含、由...提出、适用于、优于\n"
"- 每行只输出三元组,不要编号,不要解释,不要其他内容\n"
"- 每篇文档提取8-15个三元组\n\n"
"示例输出(格式参考):\n"
"RAG | 使用 | 向量检索\n"
"RAGAS | 由...提出 | Es等人\n"
"Chroma | 适用于 | 本地开发"),
("human", "文本:\n{text}"),
])
def extract_triples(text: str) -> list[tuple[str, str, str]]:
raw = triple_chain.invoke({"text": text})
triples = []
for line in raw.strip().splitlines():
parts = [p.strip() for p in line.split("|")]
if len(parts) == 3 and all(parts):
triples.append((parts[0], parts[1], parts[2]))
return triples
构建 NetworkX 图
python
KG = nx.DiGraph()
for doc in DOCUMENTS:
triples = extract_triples(doc.page_content)
for head, rel, tail in triples:
KG.add_node(head, source=doc.metadata["source"])
KG.add_node(tail, source=doc.metadata["source"])
KG.add_edge(head, tail, relation=rel)
12 篇文档最终提取出:176 个节点,139 条边。
实现:BFS 图遍历检索
python
def graph_retrieve(question: str, graph: nx.DiGraph, hops: int = 2):
# Step 1: 从问题提取实体
entities = extract_entities(question) # LLM 输出,每行一个实体
# Step 2: 模糊匹配图节点(子串匹配)
seed_nodes = []
for entity in entities:
entity_lower = entity.lower()
for node in graph.nodes:
if entity_lower in node.lower() or node.lower() in entity_lower:
seed_nodes.append(node)
if not seed_nodes:
return [] # 无匹配,退回向量检索兜底
# Step 3: BFS k 跳遍历
visited = set(seed_nodes)
frontier = set(seed_nodes)
for _ in range(hops):
next_frontier = set()
for node in frontier:
neighbors = set(graph.successors(node)) | set(graph.predecessors(node))
next_frontier |= neighbors - visited
visited |= next_frontier
frontier = next_frontier
# Step 4: 组装三元组文本作为上下文
triples = [
f"{u} --[{data['relation']}]--> {v}"
for u, v, data in graph.edges(data=True)
if u in visited or v in visited
]
return [Document(page_content=
f"[图谱实体]: {', '.join(list(visited)[:20])}\n\n"
f"[图谱关系]:\n" + "\n".join(triples[:40])
)]
BFS 2跳的含义:从种子实体出发,收集直接邻居(1跳)和邻居的邻居(2跳)。对"BAAI 开发的模型"这类问题,1跳就够;对"bge-large-zh-v1.5 和 bge-reranker-v2-m3 都来自哪里、各自用于什么",需要 2 跳才能连通 BAAI → 两个模型 → 各自的用途。
实验结果
测试集设计
8 条问题分两类:
| 类型 | 数量 | 代表问题 |
|---|---|---|
| 单跳事实题 | 2 | "RAGAS 包含哪四个指标?" |
| 多跳关系题 | 6 | "bge-large-zh-v1.5 和 bge-reranker-v2-m3 分别来自哪里、用于什么?" |
多跳题是 Graph RAG 的主场,也是向量检索的弱项。
RAGAS 指标对比
diff
======================================================================
RAGAS 指标对比(向量 RAG vs Graph RAG)
======================================================================
指标 向量 RAG Graph RAG 变化
──────────────────────────────────────────────────────
context_recall 0.812 0.750 ↓-0.062
context_precision 0.729 0.948 ↑+0.219 ◀
faithfulness 0.865 0.883 ↑+0.018
answer_relevancy 0.536 0.465 ↓-0.071
======================================================================
context_precision +0.219,这是本系列中向量检索和非向量方法之间最明显的精度差距之一。
结果解读
为什么 context_precision 大幅提升?
context_precision 衡量的是:送给 LLM 的文档中,有多少是真正相关的。
向量检索返回的是语义最相似的 top-4 文档片段------相似不等于精确。问"BAAI 开发了哪两个模型",向量检索会拉回所有提到 BAAI 或 BGE 的段落,里面有大量无关内容(模型维度、榜单排名、使用场景......)。
图遍历不同。它从 BAAI 节点出发,只走到直接相关的子节点,输出:
sql
BAAI --[开发]--> bge-large-zh-v1.5
BAAI --[开发]--> bge-reranker-v2-m3
bge-large-zh-v1.5 --[用于]--> Embedding 检索
bge-reranker-v2-m3 --[用于]--> 重排序
这 4 条三元组直接命中问题,没有噪声,context_precision 自然高。
为什么 context_recall 轻微下降?
图遍历有"实体边界":只能展开已被提取进图的实体的邻居。如果某个相关事实存在于文本中,但三元组提取步骤没有把对应实体提取进图,那条信息就永远找不到了。
向量检索依赖语义相似度,覆盖面更广,不要求事先结构化------没有显式的"边界"。这就是为什么 context_recall 向量检索更高:它的触达范围更宽,哪怕有点模糊。
这是精度-召回率的经典权衡。 Graph RAG 主动选择了精度侧:宁可少返回一些,但返回的都要精准。
为什么 answer_relevancy 下降?
图遍历上下文是结构化的三元组列表:
css
RAG --[使用]--> 向量检索
向量检索 --[实现于]--> ChromaDB
...
向量检索返回的是完整语义段落,语言更自然。LLM 从三元组推导自然语言答案,表达上不如直接从段落中提炼流畅------这导致 answer_relevancy 的评分略低。
这是图谱上下文格式的固有局限,可以通过在生成 prompt 里加一步"把三元组转化为连贯段落"来缓解。
适用场景与局限性
Graph RAG 最适合:
- 文档有复杂关系网络:技术文档、知识库、产品手册------实体之间有大量显式关系
- 问题需要跨实体推理:"X 和 Y 的关系是什么"、"Z 是哪个系列的一部分"
- 答案不在单一文档内:需要把多个文档的知识"拼"起来
需要权衡的地方:
- 三元组提取成本:每篇文档都要调用一次 LLM 提取三元组,构建阶段比向量索引贵
- 实体提取质量决定上限:如果 LLM 没能提取出关键实体,图遍历就会漏检
- 对 QA 场景收益有限:如果问题大多是单跳事实查询("X 是什么"),向量检索的 recall 优势更明显
- 答案流畅度:三元组格式的上下文会让 LLM 的答案略显生硬,需要 prompt 工程补偿
完整代码
代码已开源:
核心文件:
graph_rag.py--- 完整实现,含图谱构建、BFS检索、RAGAS评估
运行方式:
bash
git clone https://github.com/chendongqi/llm-in-action
cd 16-graph-rag
cp .env.example .env
pip install -r requirements.txt
python graph_rag.py
小结
本文实现了 Graph RAG,核心发现:
- context_precision +0.219,是图遍历"按需取边"的直接结果------从种子实体出发,只带回路径上的关系,不带无关内容
- context_recall 略降 -0.062,是图谱边界的代价------未提取进图的实体和关系是盲区,向量检索没有这个限制
- 实现中最重要的决策 :放弃
LLMGraphTransformer,改用自定义实体 | 关系 | 实体格式,解决了 GLM-4-flash 不稳定输出 JSON 的问题 - 混合策略是正确的:纯图遍历 + 向量检索补充,精度和召回率可以兼顾
从这个系列走到这里,一个规律越来越清晰:没有万能的 RAG 优化方法,每种方案都在某个维度得分,同时在另一个维度付出代价。向量检索召回全但精度不足,Rerank 提升精度但成本增加,Graph RAG 精度极高但召回有边界......选择哪种,取决于你的知识库结构和用户提问模式。