一、问题背景:为什么我的RAG系统像“人工智障”
上个月接手一个内部技术文档问答项目,基于LangChain + ChromaDB + OpenAI的经典组合搭建。初始版本上线后,用户反馈“答非所问”的比例高达40%。最典型的情况是:用户问“如何配置JWT过期时间”,系统返回的是“JWT介绍”或“OAuth2.0流程”——检索到的片段根本不在点上。
我拉取了连续7天的日志,统计了500条真实问答记录,发现三个核心问题:
1. 召回率低:Top-5相关文档命中率仅61%,很多关键信息根本没被检索到。
2. 片段不完整:即使召回了,chunk切分导致上下文断裂,答案生成时缺乏关键参数。
3. 排序不合理:最相关的文档经常排在第三、第四位,被生成器忽略。
这篇文章记录了我如何通过三步优化——调整chunk策略、切换embedding模型、引入rerank重排——将系统准确率从0.61提升到0.89的完整过程。
二、环境与版本:技术栈明细
先交代基础环境,方便读者对照复现:
# requirements.txt 核心依赖
langchain==0.1.12
langchain-openai==0.0.8
chromadb==0.4.24
sentence-transformers==2.7.0
rerankers==0.3.0
bge-reranker-base==1.0.0
paddleocr==2.7.3 # 用于PDF文档解析
硬件:单张T4 GPU(16GB显存),用于embedding和rerank模型推理。
数据集:1200篇技术文档(Java/Spring Boot/K8s/MySQL),共约80万字符。人工标注了200对问答对作为评测集。
评估方法:对每个问题,人工判断Top-5检索结果中是否包含正确答案(召回率),以及生成答案是否准确(准确率)。
三、方案设计:三步走的优化路径
我最初的方案是“一刀切”的:所有文档按固定512字符切分,使用OpenAI text-embedding-ada-002,不做重排。优化后改为:
- chunk策略:从固定大小改为按语义段落切分 + 重叠窗口,并针对代码块做特殊处理。
- embedding模型:从闭源ada-002换成开源的BAAI/bge-large-zh-v1.5,中文场景表现更好。
- rerank重排:增加bge-reranker-base作为第二阶段的精排模型,对召回的20个候选重新排序,取Top-5。
整体流程变为:
文档 → 段落切分 → chunk构建 → embedding → 向量库
查询 → embedding → 粗排召回Top-20 → rerank精排Top-5 → LLM生成
四、核心实现:chunk策略调整
4.1 初始方案的问题
最初用RecursiveCharacterTextSplitter,chunk_size=512,overlap=50。问题在于:
- 技术文档中的代码块经常被拦腰截断,导致语法不完整。
- 一个完整的概念描述(如“JWT包含三部分:Header、Payload、Signature”)可能被切到两个chunk里。
4.2 优化后的切分方案
我自定义了一个TechDocSplitter,核心逻辑是:
from langchain.text_splitter import RecursiveCharacterTextSplitter
import re
class TechDocSplitter:
def __init__(self, chunk_size=400, overlap=80):
self.chunk_size = chunk_size
self.overlap = overlap
def split(self, text):
# 1. 先按代码块分隔
code_blocks = re.split(r'(```\w*\n.*?```)', text, flags=re.DOTALL)
chunks = []
for block in code_blocks:
if block.startswith('```'):
# 代码块单独成chunk,不切分
chunks.append(block)
else:
# 文本部分用递归切分,但按段落优先
splitter = RecursiveCharacterTextSplitter(
chunk_size=self.chunk_size,
chunk_overlap=self.overlap,
separators=["\n\n", "\n", "。", ";", ",", " ", ""]
)
chunks.extend(splitter.split_text(block))
# 2. 合并过小的chunk(<50字符)
merged = []
temp = ""
for c in chunks:
if len(temp) + len(c) < self.chunk_size:
temp += c
else:
if temp:
merged.append(temp)
temp = c
if temp:
merged.append(temp)
return merged
关键改动:
- 代码块(用``包裹的)**整体保留**,不切分。
- 文本部分按\n\n(段落)优先切分,保证语义完整性。
- 设置min_chunk_size=50`,避免碎片化。
效果对比:
| 切分策略 | Top-5召回率 | 答案准确率 |
|---|---|---|
| 固定512字符 | 61% | 52% |
| 段落切分+重叠80 | 68% | 59% |
| 段落切分+代码块保留 | 74% | 65% |
召回率提升了13个百分点,主要来自代码块不再断裂。
五、核心实现:embedding模型切换
5.1 为什么换掉ada-002
在中文技术文档场景下,text-embedding-ada-002有两个明显的短板:
- 对中文专业术语(如“熔断降级”、“分布式事务”)理解不佳。
- 向量维度1536,检索速度慢,存储开销大。
我测试了4款模型,用200个问题的平均召回率做基准:
| embedding模型 | 维度 | Top-5召回率 | 推理耗时(ms/句) |
|---|---|---|---|
| text-embedding-ada-002 | 1536 | 74% | 12 |
| BAAI/bge-small-zh-v1.5 | 512 | 78% | 3 |
| BAAI/bge-base-zh-v1.5 | 768 | 81% | 6 |
| BAAI/bge-large-zh-v1.5 | 1024 | 85% | 15 |
5.2 切换代码
from sentence_transformers import SentenceTransformer
# 加载BGE模型
model = SentenceTransformer("BAAI/bge-large-zh-v1.5")
# 注意:BGE模型需要加query指令前缀
query_prefix = "为检索到的文档生成表示:" # 查询时加上
def embed_documents(docs):
return model.encode(docs, normalize_embeddings=True)
def embed_query(query):
# 查询向量需要加前缀,这是BGE的推荐用法
return model.encode(query_prefix + query, normalize_embeddings=True)
踩坑记录:
- 必须加query前缀:不加前缀的话,召回率直接跌回70%。这是BGE模型特有的训练方式。
- 要归一化:normalize_embeddings=True,否则后期计算余弦相似度会出问题。
- 显存占用:large模型需要约4GB显存,如果线上环境不足,可以选择base版本(768维)。
效果数据:切换BGE-large后,召回率从74%提升到85%,首篇命中率(正确答案排在第一位)提升了22%。
六、核心实现:rerank重排机制
6.1 为什么需要rerank
embedding模型做的是“语义相似度”粗排,但经常出现“语义相近但实际不相关”的情况。比如查询“JWT过期时间配置”,向量相似度高的可能是“JWT介绍”或“JWT刷新机制”。这时需要一个更精细的交叉编码器来重排。
6.2 实现方案
我选择bge-reranker-base,采用交叉编码器结构,将查询和文档拼接后输入模型,输出相关性得分。
from rerankers import CrossEncoderReranker
# 初始化reranker
reranker = CrossEncoderReranker(
model_name="BAAI/bge-reranker-base",
batch_size=32,
device="cuda"
)
def rerank_query(query, candidates, top_k=5):
"""
candidates: 粗排召回的文档列表
返回重排后的Top-k文档
"""
# 重排器返回排序后的文档和分数
reranked = reranker.rank(query=query, docs=candidates)
# 取Top-k
top_docs = [r.document for r in reranked[:top_k]]
top_scores = [r.score for r in reranked[:top_k]]
return top_docs, top_scores
6.3 关键参数调优
- 粗排数量:我测试了10/20/30/50,发现粗排取20效果最好。太少会让好文档漏掉,太多会拖慢rerank速度。
- batch_size:配合T4 16GB显存,batch_size=32比较稳。
- rerank耗时:每轮查询约80ms(20个候选),完全可以接受。
6.4 效果对比
| 配置 | Top-5召回率 | 答案准确率 | 首篇准确率 |
|---|---|---|---|
| 仅embedding | 85% | 72% | 58% |
| embedding + rerank | 89% | 84% | 76% |
最明显的变化是首篇准确率从58%提升到76%——这意味着答案生成器拿到正确上下文的概率大幅提升,直接反映到最终答案的准确性上。
七、踩坑与优化:三个隐藏的坑
-
ChromaDB集合重建:切换embedding模型后,必须删除旧集合重建,否则会混入不同维度的向量导致崩溃。我踩过这个坑,报错信息是
Dimension mismatch。 -
rerank模型输入长度:bge-reranker最大输入长度512 token,超长会被截断。长文档需要先做chunk再rerank,不能直接塞整篇。
-
生成模型的temperature:经过优化后,检索质量上来了,生成器的temperature建议从0.7降到0.3。之前检索差的时候,低温度会导致答案过于死板;现在检索准了,低温度能明显减少幻觉。
八、总结与建议
最终系统上线运行两周,实测数据:
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| Top-5召回率 | 61% | 89% | +45.9% |
| 答案准确率 | 52% | 84% | +61.5% |
| 首篇准确率 | 38% | 76% | +100% |
| 单次查询耗时 | 1.2s | 1.5s | +25% |
结论:耗时只增加了0.3秒,但准确率几乎翻倍,完全值得。
给读者的三点建议:
1. 先优化chunk,再换embedding:chunk切分的优化成本最低、收益最明显。
2. 中文场景优先考虑BGE系列:在技术文档场景下,bge-large的表现全面优于ada-002。
3. rerank是最后的点睛之笔:但如果前面两步没做好,rerank的增益会大打折扣。
如果你的RAG系统也遇到了“答非所问”的问题,建议按照这个路径逐步排查。有任何问题欢迎在评论区交流。