RAG系统实战:从文档切片到检索优化,一次完整的工程复盘
1. 一个真实场景:合同审查的“最后一公里”问题
某金融科技公司需要处理年均超10万份的采购合同、保密协议和合规文件。此前基于关键词的ES检索方案,在“限制赔偿责任条款”这类高度语义化查询上,召回率不足40%。工程师们决定引入RAG(检索增强生成)架构,但很快发现:真正的瓶颈不在模型,而在文档进入向量库之前的“预处理”和查询进入模型之前的“再处理”。
2. 文档切片:不是简单的按字数切
最直接的教训是:按固定512 token切片,等于人为制造语义断层。一份合同中的“赔偿条款”可能横跨第3页到第5页,若被硬切成三段,检索时只能命中其中一段,LLM生成答案时必然丢失上下文。
工程方案是采用结构化感知切片:
- 先用正则和NLP规则识别合同中的章节标题(如“第X条 违约责任”)。
- 以章节为边界切分,若章节超过800 token,则按句子边界二次切分。
- 每个切片附带丰富的元数据:合同编号、章节路径、页码、合同类型(采购/保密/劳务)。
def semantic_chunk(text, max_tokens=800):
# 先用章节标题定位边界
sections = re.split(r'(第[一二三四五六七八九十百千]+条\s*[\u4e00-\u9fa5]+)', text)
chunks = []
for section in sections:
if not section.strip():
continue
if len(section) < max_tokens:
chunks.append(section)
else:
# 超过阈值,按句子边界二次切分
sentences = re.split(r'(?<=[。;])', section)
temp = ""
for sent in sentences:
if len(temp) + len(sent) < max_tokens:
temp += sent
else:
chunks.append(temp)
temp = sent
return chunks
关键点:切片后必须保留元数据。后续检索到的切片,才能通过元数据回溯到原始文档的精确位置,实现“引用溯源”。踩坑记录:最初未保留章节路径,导致LLM回答“根据第X条”时无法定位原文,审计不通过。
3. 检索优化:从“向量相似”到“混合召回+重排”
初版系统只用embedding余弦相似度,结果:查询“违约金上限”时,返回了“赔偿计算方式”的切片,但漏掉了合同中更关键的“限额赔偿条款”。原因是纯向量检索对数字和专有名词不敏感。
解决方案是混合检索架构:
- BM25关键词召回:捕捉精确数字、合同编号、法条引用。
- 向量语义召回:捕捉同义改写。
- RRF(Reciprocal Rank Fusion)融合:将两者结果按排名倒数加权合并。
- 重排(Rerank):使用bge-reranker-base对融合后的Top 50重新打分,取Top 5送入LLM。
from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer, CrossEncoder
# BM25召回
bm25 = BM25Okapi(tokenized_corpus)
bm25_docs = bm25.get_top_n(query_tokens, corpus, n=20)
# 向量召回
embedder = SentenceTransformer('BAAI/bge-large-zh-v1.5')
vec_docs = embedder.encode(corpus, query, top_k=20)
# RRF融合
def rrf(rank_list, k=60):
score = {}
for doc_id, rank in enumerate(rank_list):
score[doc_id] = score.get(doc_id, 0) + 1/(k+rank)
return sorted(score.items(), key=lambda x: -x[1])[:50]
# 重排
reranker = CrossEncoder('BAAI/bge-reranker-base')
rerank_scores = reranker.predict([(query, doc) for doc in top50])
final_docs = [doc for _, doc in sorted(zip(rerank_scores, top50), reverse=True)][:5]
关键点:重排模型是性价比最高的优化点。只加一个cross-encoder,检索精度从62%提升到81%(内部测试集,1000条真实合同查询)。踩坑:RRF的k值不是越大越好,实测k=60比k=100稳定性更好,且BM25的词干化处理不适合中文,需使用jieba分词。
4. 生成环节:上下文压缩与幻觉抑制
检索到Top 5切片后,直接拼接送入LLM,常导致两个问题:超长上下文稀释关键信息、模型“自由发挥”编造条款。
工程做法是先压缩、后生成:
- 用LLM对Top 5切片做摘要,提取“与查询相关的条款原文”。
- 生成时注入system prompt:“仅依据以下合同条款回答,若条款中无相关内容,必须回答‘未找到相关条款’,禁止推测。”
compress_prompt = f"""提取以下合同片段中与'{query}'相关的条款原文,保留数字和百分比,删除无关描述。若无关,输出'无'。
片段1: {...} 片段2: {...}"""
response = llm.chat(compress_prompt)
# 将压缩结果作为最终生成的上下文
关键点:压缩步骤额外增加一次LLM调用,但显著降低幻觉率(从12%降至3%)。踩坑:压缩时务必保留原文的条款编号,否则后续引用失效。实测中,使用GLM-4-Flash做压缩,单次调用成本约0.002元,可接受。
5. 性能与成本:缓存与异步流水线
初版系统每次查询调用3次LLM(压缩+生成+可能的重试),p95延迟达8秒。优化后降至2.1秒:
- 语义缓存:对查询做embedding,在向量库中查找相似度>0.95的历史查询,直接返回缓存结果。
- 流水线并行:BM25与向量检索并行执行,RRF融合等待两者完成。
- 流式输出:生成阶段使用SSE流式返回,首token延迟降低至400ms。
关键点:缓存命中率在合同审查场景约30%(相同或相似条款反复查询),直接节省30%的LLM调用成本。
6. 踩坑总结与下一步行动
核心收获:
- 切片的粒度由文档结构决定,而非模型窗口。
- 混合检索+重排是当前性价比最高的检索方案,纯向量方案在专业领域(法律、医疗)不可用。
- 上下文压缩是抑制幻觉的关键工程手段,而非依赖prompt。
下一步行动建议:将检索评估(Retrieval Evaluation)接入CI/CD,每次调整切片或检索参数时,自动跑一遍1000条测试集,防止回归。推荐使用Ragas框架或自建评估集。
对于正在构建RAG系统的团队,建议按此顺序落地:先跑通结构化切片与混合检索,再评估是否需要重排,最后再考虑压缩与缓存。不要一开始就追求完美,先用最小闭环验证检索质量,再逐步优化生成环节。
本文关键词:RAG、混合检索、RRF、重排、上下文压缩