一、问题背景:检索结果“看着像”,答起来“全是坑”

我们做的是一个私有化部署的金融文档问答系统,知识库约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_documentsembedding_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,理由很简单:

  1. 它专门针对检索任务优化过(InfoNCE损失函数训练)
  2. 维度是1024,比text2vec的768多256维,表征能力更强
  3. 官方文档明确建议查询时加指令前缀“为这个句子生成表示以用于检索相关文章:”,这一点是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(脱敏处理),需要的自取。