一、问题背景:一个"看起来能用"的RAG系统
三个月前我接手了公司内部知识库问答系统。数据源是约12000份产品文档、运维手册和FAQ,总量大概800MB纯文本。最初的实现非常"教科书":
- 分块:RecursiveCharacterTextSplitter,chunk_size=512,overlap=50
- 嵌入:OpenAI text-embedding-ada-002
- 向量库:Chroma 0.4.24,HNSW索引
- 检索:余弦相似度Top-5,直接拼进prompt
- 生成:gpt-3.5-turbo,temperature=0.2
上线第一周,测试集(200条人工标注问答对)给出的结果很难看:
| 指标 | 数值 |
|---|---|
| Top-5召回率 | 61% |
| 答案准确率 | 54% |
| 幻觉率(编造内容) | 23% |
| P95延迟 | 2.8s |
用户反馈集中在两点:一是"答非所问",二是"明明文档里有,它却说不知道"。前者是召回问题,后者往往是把相关文档切碎了导致语义不完整。这篇文章就按我实际优化的顺序,把每一步的动机、代码和前后数据摊开讲。
二、环境与版本
先把环境钉死,避免"我这里能跑"的扯皮:
Python 3.10.13
langchain 0.1.20
langchain-community 0.0.38
chromadb 0.4.24
sentence-transformers 2.7.0
FlagEmbedding 1.2.10
torch 2.2.1 (CUDA 12.1)
openai 1.30.1
嵌入和重排模型都跑在本地一张A10(24GB)上,生成仍走OpenAI API。选本地模型的原因很简单:12000份文档全量嵌入如果用API,一次重建成本不低,而且迭代chunk策略时要反复重嵌,本地模型迭代快得多。
三、方案设计与迭代路径
我没有一上来就上重排,而是按"成本从低到高、影响从大到小"的顺序推进:
- 第一步:改chunk策略。固定长度切分是万恶之源,先换成按语义/结构切。
- 第二步:换embedding模型。ada-002对中文和长文本的语义区分度一般,换bge-m3。
- 第三步:引入rerank。召回阶段放宽到Top-20,用cross-encoder精排取Top-5。
- 第四步:调参收尾。overlap、rerank阈值、prompt模板微调。
每一步我都单独跑测试集,记录指标,这样才能知道到底是谁在起作用。
四、核心实现
4.1 Chunk策略:从固定长度到语义分块
原来的512字符切分,经常把一张表格或一段步骤说明拦腰截断。我改成了"结构优先 + 语义兜底":
from langchain.text_splitter import RecursiveCharacterTextSplitter, MarkdownHeaderTextSplitter
# 1. 先按Markdown标题层级切,保留文档结构
headers_to_split_on = [
("#", "h1"),
("##", "h2"),
("###", "h3"),
]
md_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on,
strip_headers=False,
)
# 2. 对过长的section再做递归切分,chunk_size放大到1024,overlap提到200
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1024,
chunk_overlap=200,
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
length_function=len,
)
def build_chunks(raw_md: str):
sections = md_splitter.split_text(raw_md)
chunks = []
for sec in sections:
# 短section直接保留,避免碎片化
if len(sec.page_content) = score_threshold]
return filtered[:top_k_final]
这一步是提升最猛的:召回从78%直接到89%,答案准确率从68%跳到82%。代价是延迟,每次查询多了约180ms(20个pair在A10上),但用户几乎无感。
五、踩坑与优化
坑1:overlap设太大导致重复召回。 我一度把overlap提到300,结果Top-5里经常出现同一段内容的三四个切片,挤掉了其他相关文档。最后定在200,并在入库时对chunk做hash去重。
坑2:rerank的score_threshold不能拍脑袋。 一开始设0.5,很多正确答案被误杀;设0.2又放进来一堆噪声。我拿测试集画了score分布,正样本大多在0.35以上,负样本在0.3以下,最终定0.35。
坑3:bge-m3首次加载慢。 冷启动要十几秒,我们加了服务常驻,用FastAPI把embedding和rerank都包成独立服务,避免每次请求重新加载模型。
坑4:prompt里塞太多chunk反而降质。 Top-5已经够,塞Top-10会让模型注意力分散,幻觉率回升。这一点和很多论文结论一致。
六、效果数据
测试集200条,同一批问题,逐阶段对比:
| 阶段 | Top-5召回 | 答案准确率 | 幻觉率 | P95延迟 |
|---|---|---|---|---|
| 初始方案 | 61% | 54% | 23% | 2.8s |
| +语义分块 | 70% | 60% | 19% | 2.7s |
| +bge-m3 | 78% | 68% | 14% | 2.5s |
| +rerank | 89% | 82% | 7% | 2.7s |
| +阈值/prompt调优 | 89% | 84% | 5% | 2.6s |
最终端到端平均延迟1.2s(P50),P95 2.6s,比初始方案还略快——因为召回质量高,生成的token数反而少了。
七、总结
这次优化最大的体会是:RAG的效果瓶颈几乎永远在检索,而不是生成。 很多人一上来就换更大的LLM,但如果喂进去的上下文就是错的,再强的模型也只能编。按"分块 → 嵌入 → 重排"的顺序逐层优化,每一步都能量化收益,也最容易定位问题。
如果只能给一条建议:先把chunk策略和rerank做好,这两步的投入产出比远高于换模型。bge-m3 + bge-reranker-v2-m3这套组合在中文场景下目前性价比很高,本地部署成本可控,值得一试。