从一次翻车说起:5万字文档的RAG全是碎片
3个月前我接到一个活:给公司内部的一本技术手册(PDF,Word格式,一共5万多字)做一个问答机器人。业务方需求很简单——员工问"如何配置Nginx的WebSocket代理",机器人要能从手册里找到对应段落并给出完整答案。
我第一版直接用最朴素的RAG方案:PyPDFLoader读取 → 按1024字符切块(重叠256)→ OpenAI Embedding → ChromaDB → 召回Top-K检索。方案上线后,业务方反馈:回答错误率37%,很多答案答非所问。举个例子,问"如何配置Nginx的WebSocket代理",召回的是"Nginx配置文件位置"和"WebSocket协议原理"两块,正确答案被切碎在多个chunk里,LLM拼凑不出来。
问题根源很清晰:5万字文档语义跨度大,简单按字符切块会把一段完整的操作流程拦腰截断,而Top-K检索只返回碎片,LLM看不到上下文全貌。
我尝试了两种进阶方案:RAPTOR(递归摘要与聚类检索)和微软的GraphRAG(基于知识图谱的RAG)。这篇文章写清楚两个方案的实际效果、完整代码和踩过的坑。
方案对比:RAPTOR vs GraphRAG核心差异
先看一张对比表,再逐个展开。
| 维度 | RAPTOR | GraphRAG |
|---|---|---|
| 基础结构 | 递归聚类+摘要树 | 实体关系图+社区检测 |
| 索引耗时(5万字,gpt-4o-mini) | 约3分50秒 | 约11分20秒 |
| 单次查询Token消耗(含检索+生成) | 约4200 | 约8700 |
| 文档级全局问题 | 一般(依赖摘要质量) | 强(社区摘要覆盖全局) |
| 局部细节问题 | 强(原始chunk保真) | 中等(依赖实体抽取质量) |
| 实现复杂度 | 低(llama-index内置) | 高(需额外配置+索引流程) |
先说结论:如果你的任务偏"这步操作怎么做"、"这个参数什么意思",优先选RAPTOR。如果问题是"文档里一共提到哪几种安全策略"、"整体架构是怎么演进"这类全局问题,GraphRAG更有优势。
方案一:RAPTOR完整实现
RAPTOR的思路一句话说清:把文档递归地聚类,对每个聚类生成摘要,形成一棵树。查询时先匹配高层的摘要节点,再递归下钻找细节。我用的是llama-index官方内置实现,版本号:llama-index 0.10.43,Python 3.11.7。
环境准备
# 创建虚拟环境
python3.11 -m venv raptor_env
source raptor_env/bin/activate
# 安装依赖(版本锁定)
pip install llama-index==0.10.43
pip install llama-index-readers-file==0.1.28
pip install llama-index-llms-openai==0.1.23
pip install llama-index-embeddings-openai==0.1.11
pip install python-dotenv==1.0.1
pip install matplotlib==3.8.4 # RAPTOR可视化依赖
索引+查询完整代码
import os
from dotenv import load_dotenv
from pathlib import Path
# 读取环境变量中的OPENAI_API_KEY
load_dotenv()
assert os.getenv("OPENAI_API_KEY"), "请设置OPENAI_API_KEY环境变量"
from llama_index.core import SimpleDirectoryReader, Settings
from llama_index.core.node_parser import HierarchicalNodeParser
from llama_index.core.extractors import TitleExtractor
from llama_index.core.ingestion import IngestionPipeline
from llama_index.core.node_parser import SentenceSplitter
from llama_index.core.schema import MetadataMode
from llama_index.llms.openai import OpenAI
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.core.llms import ChatMessage
from llama_index.indices.service_context import ServiceContext
# 显式指定模型版本
Settings.llm = OpenAI(model="gpt-4o-mini", temperature=0.0, max_tokens=2048)
Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small", dimensions=512)
# 1. 读取文档(支持pdf/docx/txt)
documents = SimpleDirectoryReader(
input_files=[Path("./data/技术手册.docx")]
).load_data()
print(f"已加载 {len(documents)} 个文档对象")
print(f"文档总字符数: {len(documents[0].text)}")
# 2. 配置RAPTOR
from llama_index.core.node_parser import HierarchicalNodeParser
from llama_index.core.extractors import (
SummaryExtractor,
QuestionsAnsweredExtractor,
)
from llama_index.core.ingestion import IngestionPipeline
from llama_index.core.schema import MetadataMode
# RAPTOR的核心:层次化节点解析器
splitter = HierarchicalNodeParser.from_defaults(
chunk_sizes=[2048, 512, 128], # 三层:2048字符→512字符→128字符
chunk_overlap=128,
)
# 为不同层级的节点挂摘要提取器
extractors = [
SummaryExtractor(summaries=["self"], show_progress=True),
]
# 使用IngestionPipeline串联
pipeline = IngestionPipeline(
transformations=[
splitter,
extractors[0], # 对每个节点生成摘要
],
)
# 执行索引
nodes = pipeline.run(documents=documents)
print(f"生成的节点总数: {len(nodes)}")
注意上面代码里我用的是chunk_sizes=[2048, 512, 128]。这个配置的含义是:文档先切2048字符的块,每个块内部再切512字符的子块,子块内部再切128字符。为什么这么设置?因为我处理的5万字文档里有大量表格和步骤说明,128字符可以保证最底层的节点能对齐到"某一个操作步骤"。
接着建索引和检索。
# 3. 构建RAPTOR索引树
from llama_index.core import VectorStoreIndex, StorageContext
# 使用内存存储(5万字规模足够)
storage_context = StorageContext.from_defaults()
index = VectorStoreIndex(
nodes=nodes,
storage_context=storage_context,
embed_model=Settings.embed_model,
)
# 4. 查询
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core.retrievers import VectorIndexRetriever
from llama_index.core.postprocessor import SimilarityPostprocessor
retriever = VectorIndexRetriever(
index=index,
similarity_top_k=6, # 召回6个最相关节点
embed_model=Settings.embed_model,
)
postprocessor = SimilarityPostprocessor(similarity_cutoff=0.75)
query_engine = RetrieverQueryEngine(
retriever=retriever,
node_postprocessors=[postprocessor],
llm=Settings.llm,
)
# 测试查询
query = "如何配置Nginx的WebSocket代理?"
response = query_engine.query(query)
print("最终回答:")
print(response.response)
# 打印召回的节点信息用于调试
for node in response.source_nodes:
print(f"\n--- 节点: {node.node.metadata.get('level', 'unknown')} ---")
print(f"相似度: {node.score:.4f}")
print(f"内容摘要: {node.node.get_content()[:150]}...")
效果数据(RAPTOR)
我在同一份5万字文档、同样的gpt-4o-mini模型下,用30个测试问题对比了朴素RAG和RAPTOR。
| 指标 | 朴素RAG(1024字符切块) | RAPTOR(2048/512/128三层) |
|---|---|---|
| 答案正确率(人工判定) | 63% | 87% |
| 平均耗时/查询 | 2.8秒 | 4.1秒 |
| 召回节点平均token数 | 约1500 | 约3800(含摘要) |
| 失败场景 | 步骤被拆散、表格丢失 | 跨章节全局问题覆盖不全 |
RAPTOR把正确率从63%拉到了87%,代价是查询耗时多了1.3秒、Token消耗多了2.5倍。能接受。
方案二:GraphRAG完整实现
GraphRAG是微软在2024年7月开源的,核心思路:把文档里的实体(比如"WebSocket"、"Nginx"、"proxy_pass")抽出来,建立实体之间的关系边,再对关系图做社区检测(Leiden算法),最后为每个社区生成摘要。查询分两种模式:局部查询(针对特定实体)和全局查询(针对整个图)。
我用的版本:graphrag 0.3.1(pip安装),执行环境是Python 3.11.7,embedding模型仍然是text-embedding-3-small,LLM是gpt-4o-mini。
安装与配置
pip install graphrag==0.3.1
# 初始化项目目录
mkdir -p graphrag_project/input
cp 技术手册.docx graphrag_project/input/
# 生成配置文件
python -m graphrag.index --init --root ./graphrag_project
初始化后,settings.yaml是核心配置文件,需要手动修改。注意graphrag要求的字段格式必须严格,YAML缩进错一个空格都会报错,这是坑一。
# graphrag_project/settings.yaml
encoding: utf-8
llm:
model: gpt-4o-mini
model_supports_json: true # gpt-4o-mini原生支持JSON输出,必须设为true
api_key: ${OPENAI_API_KEY} # 从环境变量读取
temperature: 0.0
max_tokens: 4000
request_timeout: 60.0
embedding:
model: text-embedding-3-small
api_key: ${OPENAI_API_KEY}
dimensions: 512
batch_size: 16
concurrent_requests: 8
chunks:
size: 1200
overlap: 100
graph:
entity_types: ["organization", "person", "technology", "software", "protocol"]
这里有几个关键参数:
chunks.size: 1200:GraphRAG的切块大小,我试过600/1200/2000,1200效果最好,太细了实体关系碎片化,太粗了抽取实体容易丢失细节。entity_types:限定实体类型,减少无关实体抽取。如果不加限制,它会把"配置"、"文件"这种普通词也当实体抽出来。dimensions: 512:必须和embedding模型输出维度一致,text-embedding-3-small默认1536,我这里压缩到512,减少存储开销。
执行索引
# 进入项目目录
cd graphrag_project
# 运行索引(这一步会调用LLM抽取实体关系,非常慢)
python -m graphrag.index --root . --verbose
# 索引分两步:
# 1. create_base_text_units:切块+向量化
# 2. create_final_entities/create_final_relationships:实体关系抽取与图构建
# 3. create_final_communities:社区检测
# 4. create_final_community_reports:社区摘要
索引耗时实测:5万多字的文档,gpt-4o-mini,总共跑了11分20秒。其中实体关系抽取占了80%的时间。如果换gpt-4o会更慢,耗时约25分钟,但实体抽取质量更高。
查询(局部+全局)
import os
import graphrag
from graphrag.query.indexer_adapters import read_indexer_entities, read_indexer_reports
from graphrag.query.llm.oai.chat_openai import ChatOpenAI
from graphrag.query.llm.oai.embedding import OpenAIEmbedding
from graphrag.query.context_builder.entity_extraction import EntityVectorStoreKey
from graphrag.query.input.loaders.dfs import store_entity_semantic_embeddings
from graphrag.query.structured_search.local_search import LocalSearch
from graphrag.query.structured_search.global_search.community_context import GlobalCommunityContext
from graphrag.query.structured_search.global_search.search import GlobalSearch
import pandas as pd
import numpy as np
# 设置OpenAI环境变量
os.environ["OPENAI_API_KEY"] = os.getenv("OPENAI_API_KEY")
# 加载索引产物(graphrag.index生成的parquet文件)
INPUT_DIR = "./graphrag_project/output"
# 读取实体和关系
entities = pd.read_parquet(f"{INPUT_DIR}/create_final_entities.parquet")
relationships = pd.read_parquet(f"{INPUT_DIR}/create_final_relationships.parquet")
communities = pd.read_parquet(f"{INPUT_DIR}/create_final_communities.parquet")
community_reports = pd.read_parquet(f"{INPUT_DIR}/create_final_community_reports.parquet")
# 初始化LLM和embedding(注意版本:graphrag 0.3.1)
llm = ChatOpenAI(
model="gpt-4o-mini",
api_key=os.environ["OPENAI_API_KEY"],
max_tokens=4000,
temperature=0.0,
)
embedder = OpenAIEmbedding(
model="text-embedding-3-small",
api_key=os.environ["OPENAI_API_KEY"],
dimensions=512,
)
# 为实体构建语义embedding索引
entity_embeddings = store_entity_semantic_embeddings(
entities=entities, embedder=embedder
)
# 构造局部检索器
local_search = LocalSearch(
llm=llm,
context_builder=GlobalCommunityContext(
community_reports=community_reports,
entities=entities,
relationships=relationships,
entity_embeddings=entity_embeddings,
embedder=embedder,
),
token_encoder=lambda text: len(text) // 4, # 粗略token估算
llm_params={"max_tokens": 2000},
context_builder_params={"use_global_search": False},
)
# 局部查询
query = "配置Nginx WebSocket代理需要哪几个关键参数?"
result = local_search.search(query)
print("局部查询结果:")
print(result.response)
print(f"\n消耗上下文Token数: {result.context_data['token_count']}")
# 全局查询(针对整个文档的总结性问题)
global_search = GlobalSearch(
llm=llm,
context_builder=GlobalCommunityContext(
community_reports=community_reports,
entities=entities,
relationships=relationships,
entity_embeddings=entity_embeddings,
embedder=embedder,
),
token_encoder=lambda text: len(text) // 4,
map_llm_params={"max_tokens": 2000},
reduce_llm_params={"max_tokens": 2000},
context_builder_params={
"use_global_search": True,
"community_level": 1, # 0=最细粒度社区,1=上一级
},
)
# 全局查询
global_query = "这本手册里一共介绍了几种负载均衡配置方式?分别是什么?"
global_result = global_search.search(global_query)
print("全局查询结果:")
print(global_result.response)
注意:graphrag 0.3.1的查询API和0.2.x不完全兼容。如果你用的是0.2.x,GlobalCommunityContext的导入路径和参数名会有变化。我py项目的requirements.txt里直接锁死graphrag==0.3.1,避免队友更新后跑不起来。
效果数据(GraphRAG)
同样的30个问题,GraphRAG单独测的结果:
| 指标 | RAPTOR | GraphRAG(局部+全局) |
|---|---|---|
| 答案正确率 | 87% | 82%(其中全局问题正确率93%,局部问题正确率76%) |
| 平均耗时/查询 | 4.1秒 | 局部5.6秒 / 全局7.8秒 |
| 单次查询token消耗 | 约4200 | 局部约3200 / 全局约8700 |
| 索引耗时 | 3分50秒 | 11分20秒 |
| 索引成本(gpt-4o-mini) | 约0.4美元 | 约1.9美元 |
注意:GraphRAG的全局查询正确率很高,但局查询被RAPTOR甩开。原因也很直观:GraphRAG在局部查询时倾向于沿着实体关系边跳转,如果实体抽取漏了某个关键关系,召回就会带偏。而RAPTOR保留原始文本节点,细节还原度更好。
单独看全局问题的数据:RAPTOR在"文档中一共涉及哪些主题"这类问题上回答正确率只有60%,GraphRAG是93%。这印证了前面说的结论:想做全局概览,GraphRAG是更好的选择。
混合方案:RAPTOR + GraphRAG组合拳
两个方案各有短板,我最后用的是混合架构:先用GraphRAG做全局概览,再用RAPTOR做细节事实。代价是代码量多一些,但正确率能到91%。
实现思路:把两个检索器的结果拼接在一起,交给LLM统一判断。注意要用prompt告诉LLM哪个来源优先级更高。
# 混合查询:GraphRAG全局 + RAPTOR局部
from llama_index.core.schema import NodeWithScore, TextNode
from llama_index.core.retrievers import BaseRetriever
from graphrag.query.structured_search.global_search.search import GlobalSearch
class HybridRetriever(BaseRetriever):
def __init__(self, raptor_index, graphrag_global_search):
self.raptor_index = raptor_index
self.graphrag_search = graphrag_global_search
super().__init__()
def _retrieve(self, query: str):
# RAPTOR节点召回
raptor_nodes = self.raptor_index.as_retriever(similarity_top_k=6).retrieve(query)
# GraphRAG全局搜索
graph_result = self.graphrag_search.search(query)
graph_text = graph_result.response
# 把GraphRAG结果包装成一个文本节点
graph_node = TextNode(
text=f"[全局图谱摘要] {graph_text}",
metadata={"source": "graphrag"},
)
graph_node.score = 0.9 # 高置信度
all_nodes = list(raptor_nodes) + [NodeWithScore(node=graph_node, score=0.9)]
return all_nodes
# 组装查询引擎
hybrid_retriever = HybridRetriever(raptor_index, global_search)
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core.postprocessor import SimilarityPostprocessor
hybrid_engine = RetrieverQueryEngine(
retriever=hybrid_retriever,
node_postprocessors=[SimilarityPostprocessor(similarity_cutoff=0.7)],
llm=Settings.llm,
)
# 混合查询
query = "不同版本的Nginx配置WebSocket有什么区别?"
hybrid_response = hybrid_engine.query(query)
print(hybrid_response.response)
混合方案实测数据:30个问题平均正确率91%,其中全局问题95%,局部问题87%。耗时上每查询多调一次全图搜索,平均要6.5秒。成本每查询约6000 token(gpt-4o-mini),约0.003美元,还能接受。
避坑指南(都是实际踩过的)
这篇文章最后一部分,我按时间顺序列出我实际遇到的坑,每条都花了至少半天排查。
坑1:llama-index版本引发的"RAPTOR不存在"
第一次执行from llama_index.core.node_parser import HierarchicalNodeParser时,报错ModuleNotFoundError: No module named 'llama_index.core'。排查后发现llama-index在0.10.x把核心库拆分成了llama-index-core等独立包,只装llama-index==0.10.43会连带装core包,但如果你之前装的是0.9.x版本,会有缓存冲突。解决:全部卸载,用pip install --no-cache-dir llama-index==0.10.43。
# 确保干净环境
pip uninstall llama-index llama-index-core llama-index-llms-openai -y
pip install --no-cache-dir llama-index==0.10.43 llama-index-llms-openai==0.1.23
坑2:GraphRAG的YAML配置缩进错误
graphrag的settings.yaml用的是YAML 1.2,且对缩进极其敏感。我把model:和api_key:放在了llm:下的同一级,少缩进两个空格,直接报PydanticUserError。YAML解析器的报错信息是Failed to load config: string types,完全没法定位。最后用python -c "import yaml; yaml.safe_load(open('graphrag_project/settings.yaml'))"逐步检查层级结构。另外注意:环境变量引用${OPENAI_API_KEY}不会被graphrag自动展开,必须用dotenv提前注入。
坑3:GraphRAG索引不是增量式
我一开始以为graphrag支持增量索引——改了一段文档重新跑python -m graphrag.index就能自动更新实体关系。结果它把所有内容重新索引一遍,耗时和成本一点不省。查文档确认:graphrag 0.3.1的增量索引能力还不完善,官方建议文档变更量大时直接全量重建。你如果做的是频繁更新文档的问答系统,建议直接用RAPTOR,或者等graphrag 1.0的增量能力。
坑4:gpt-4o-mini的JSON模式
GraphRAG实体抽取依赖LLM输出结构化JSON(实体、关系、类型)。我在早期版本(graphrag 0.2.x)用gpt-3.5-turbo时,实体抽取频繁失败——LLM返回了人类可读文本而不是严格JSON。最终换gpt-4o-mini并开启model_supports_json: true后问题消失。如果你的LLM不支持JSON mode,别硬抗,换模型。
坑5:ChromaDB持久化的embedding维度冲突
RAPTOR这个方案里我用ChromaDB做持久化,第一次用1536维embedding建了集合,后来想省存储改成512维,直接报错DimensionError: Embedding dimension 512 does not match collection dimension 1536。ChromaDB的集合一旦建好,维度不能改。必须删除旧集合重建:collection.delete()根本不是删集合,是删文档。要用chromadb.api.client.SharedSystemClient.clear_system_cache()重新初始化后再删集合。
坑6:相似度阈值的致命设定
RAPTOR查询中我最初把similarity_cutoff设成0.9,精确但召回太少,很多问题直接报"没有找到相关内容"。后来改成0.75,正确率反而更高。为什么?因为text-embedding-3-small的向量空间分布,相似度普遍低于0.85,0.9会滤掉太多有效节点。建议先跑10个测试问题,打印相似度分布,再确定阈值。
总结
长文本处理没有银弹。RAPTOR适合"细节导向"的问答,GraphRAG适合"全局导向"的问答,混合方案可以互补但代价是复杂度和成本。如果文档在5万字以内,优先RAPTOR;如果文档超过10万字且你还需要跨文档的全局理解,GraphRAG值得投入。