一、问题背景:检索结果“看着像”,答起来“全是坑”
我们做的是一个私有化部署的金融文档问答系统,知识库约2.1万篇PDF/Word,切分后约8.7万个chunk。上线两周后,运营反馈最尖锐的问题不是“答不出来”,而是“答非所问”——检索回来的片段跟问题关键词重合度很高,但语义上根本不对应。
我拉了一周日志,统计了三个核心指标:
- Answer Pass Rate(人工判定答案是否可接受):61%
- 首Token延迟(用户提问到流式输出第一个字):1.8s
- 无效生成率(LLM输出“抱歉,我无法从文档中找到相关信息”的占比):27%
问题基本锁定在检索质量上。当时用的方案是:text2vec-large-chinese + 固定256字重叠切分 + 余弦相似度Top5直接塞给LLM。这个组合在公开数据集上跑过RAGAS评测,分数还行,但一上真实业务数据就露馅。
二、环境与版本:别小看依赖库的坑
先交代一下当时的运行环境,方便你复现或者对比:
- Python 3.9.18
- langchain 0.1.12(注意,0.2.x的
Retriever接口变了,下面代码基于0.1.x) - chromadb 0.4.24(用的是PersistentClient模式)
- sentence-transformers 2.5.1
- FlagEmbedding 1.2.10(用于reranker)
- 本地GPU:单张A10(24G显存),推理时embedding batch_size=64
- LLM:Qwen-14B-Chat-Int4,vLLM启动,max_model_len=2048
这里提醒一句:如果你用langchain 0.2.x,Chroma.from_documents的embedding_function参数要换成embeddings,否则直接报TypeError。
三、方案设计:三步手术,每一步都有取舍
整个优化分了三个阶段,每个阶段独立验证效果,避免多个变量混在一起说不清楚。
第一步:chunk策略从“固定字数”改为“结构感知”
原来的256字切分是拍脑袋定的。金融文档里大量存在“3.2.1 风险缓释措施”这种三级标题,固定切分会把标题和正文截断,导致embedding向量丢失上下文。
我改用Markdown标题层级(#、##、###)作为切分边界,每个chunk包含从顶级标题到三级标题的完整路径,比如"## 信用风险 / ### 计量方法 / 内部评级法"作为chunk的前缀。如果没有标题,退回到512字硬切。
这样做的代价是chunk平均长度从256上升到966字,向量维度没变,但每个chunk的语义密度更高。副作用是检索时可能返回超长片段,需要在下游做截断(后面会讲)。
第二步:embedding模型从“通用中文”切到“检索专用”
text2vec-large-chinese是2022年的老将了,对长文本语义匹配明显力不从心。我换成了BAAI/bge-large-zh-v1.5,理由很简单:
- 它专门针对检索任务优化过(InfoNCE损失函数训练)
- 维度是1024,比text2vec的768多256维,表征能力更强
- 官方文档明确建议查询时加指令前缀“为这个句子生成表示以用于检索相关文章:”,这一点是text2vec没有的
切换后我重新计算了验证集上的余弦相似度分布,发现同类文档对的相似度从0.63提升到0.71,异类文档对从0.38下降到0.29。这意味着阈值可以压得更低,召回更全。
第三步:引入rerank,用交叉编码器纠正向量检索的“误匹配”
向量检索是双塔结构,query和document各自编码,最后算内积。这天然损失了交互信息。bge-reranker-base是交叉编码器,query和document拼接后一起过Transformer,精度高但速度慢。
我的策略是:向量召回Top20(粗排),然后reranker对20个候选打分,取Top3进入LLM。这样既保证召回率,又控制rerank的耗时在120ms左右(A10上,batch_size=10)。
四、核心实现:代码说话,直接可跑
下面这段是chunk重切的核心逻辑,用的langchain的RecursiveCharacterTextSplitter,但是加了自定义的MarkdownHeaderTextSplitter做前置处理。
```python
chunk_strategy.py
from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
from langchain.schema import Document
def smart_split(markdown_text: str) -> list[Document]:
# 步骤1:按Markdown标题层级切出粗粒度块
headers_to_split_on = [
("#", "H1"),
("##", "H2"),
("###", "H3"),
]
md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
md_chunks = md_splitter.split_text(markdown_text)
# 步骤2:对超过阈值的块,用递归切分器硬切,但保留标题前缀
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=100,
separators=["\n\n", "\n", "。", ";", " ", ""],
)
final_docs = []
for chunk in md_chunks:
# 把标题路径拼到内容前面,增强语义
prefix = " / ".join([chunk.metadata.get("H1",""), chunk.metadata.get("H2",""), chunk.metadata.get("H3","")])
prefix = prefix.strip(" / ")
if len(chunk.page_content) 0.5`才进入LLM。我最终设置的是0.45,因为业务容忍一点噪声,但能提高召回。
坑3:chunk变长导致LLM上下文溢出
改造后chunk平均966字,Top3拼起来可能超过2500字,而Qwen-14B的max_model_len只有2048。我的解决方案是:在送入LLM前,对每个chunk做一次“动态裁剪”——优先保留包含query关键词的句子,其余部分用“……”省略。用jieba.analyse.extract_tags提取query的关键词,然后按句子切分,包含关键词的句子保留,否则丢弃。
六、效果数据:用数字说话
在200条业务测试集上做了A/B对比(同一批问题,三个版本系统):
| 指标 | 原版(text2vec+256字) | 换bge+结构chunk | +rerank(最终版) |
|---|---|---|---|
| Answer Pass Rate | 61% | 72% | 84% |
| 首Token延迟 | 1.8s | 1.3s | 0.9s |
| 无效生成率 | 27% | 18% | 11% |
| 单次检索耗时(含rerank) | 210ms | 340ms | 460ms |
延迟下降的原因很直接:检索准确率高了,LLM不需要反复在无关上下文里“找答案”,生成步数变少。无效生成率从27%降到11%,意味着每100次问答减少了16次无意义的LLM推理,这部分省下的时间远超rerank增加的250ms。
检索成本变化:embedding模型参数从102M涨到326M(bge-large),单次向量化耗时从80ms涨到120ms;rerank对20个候选打分耗时约150ms。整体检索链路从210ms涨到460ms,但端到端首Token延迟反而从1.8s降到0.9s——因为LLM不需要在低质量上下文上“硬编”答案了。
七、总结:这套组合拳的适用边界
这套优化方案不是银弹。如果你做的是开放域问答(比如搜索引擎风格),chunk策略需要更激进的段落切分;如果知识库以短文本为主(比如FAQ条目),固定256字可能反而更好。但如果你跟我一样面临“专业文档+长尾问题”的场景,结构感知chunk + 检索专用embedding + 交叉编码rerank这个组合拳值得一试。
最后留个建议:所有参数(阈值、top_k、chunk_size)都别用默认值,拿500条标注数据跑一遍网格搜索,成本不高,但收益明显。我们的阈值从0.55调到0.42,Pass Rate直接涨了5个点。
有任何问题欢迎评论区交流,我会尽量回复。代码仓库在github.com/xxx/rag-optimize(脱敏处理),需要的自取。