一、问题背景:一个"能用但不好用"的RAG
去年底我给公司内部知识库搭了一套RAG问答系统,文档量约1.8万篇,涵盖产品手册、运维SOP、合同模板和会议纪要。第一版跑通得很快,LangChain + FAISS + bge-large-zh-v1.5 + Qwen2.5-7B-Instruct,两周上线。
上线后反馈很典型:
- 用户问"退款流程要几天到账",召回的却是"退款政策适用范围"这种标题相似但内容不相关的段落;
- 问"XX接口的限流阈值是多少",正确段落确实在Top10里,但排在Top6,而LLM只拿到Top5,直接答"文档未提及";
- 长文档(合同类,动辄两三万字)切出来的chunk语义断裂,一个条款被切成三段,每段都缺主语。
我拉了一个1200条的真实query测试集(人工标注gold chunk),基线数据如下:
| 指标 | 基线值 |
|---|---|
| Recall@5 | 0.71 |
| Recall@10 | 0.83 |
| Top-1 命中率 | 0.54 |
| 端到端答案准确率(人工评估) | 62% |
| P95 延迟 | 1.9s |
问题很清楚:召回质量是瓶颈,不是LLM。于是定了三轮优化:chunk策略 → embedding模型 → rerank。
二、环境与版本
Python 3.11.9
torch 2.3.1 (cu121)
transformers 4.44.2
langchain 0.2.16
langchain-community 0.2.16
faiss-cpu 1.8.0
sentence-transformers 3.0.1
FlagEmbedding 1.2.11
Qwen2.5-7B-Instruct (vllm 0.6.1 部署, tensor-parallel=1, A10 24G)
嵌入与重排模型:
- 基线:
BAAI/bge-large-zh-v1.5(1024维,512 max_length) - 切换后:
BAAI/bge-m3(1024维,8192 max_length,支持dense/sparse/colbert) - 重排:
BAAI/bge-reranker-v2-m3(cross-encoder,568M参数)
三、方案设计
整体链路改成三段式:
离线:文档解析 → 语义chunk → bge-m3 dense向量 → FAISS IndexFlatIP
在线:query → 向量召回 Top20 → bge-reranker 重排 → 取Top4 → 拼prompt → Qwen2.5
三个决策点说明一下:
-
chunk不用固定长度。改为"标题感知 + 递归分隔 + 语义合并":先按 Markdown 标题层级切大块,块内按
\n\n→\n→。递归切,目标 400 token,重叠 80 token;若某块切完 list[dict]:
"""标题感知 + 递归切分 + 小块合并。token 用字符数近似(中文≈1:1)。"""
base_splitter = RecursiveCharacterTextSplitter(
chunk_size=target,
chunk_overlap=overlap,
separators=["\n\n", "\n", "。", ";", ",", ""],
length_function=len,
)1. 按标题切大节,保留 heading 作为元数据前缀
sections, last_pos, last_title = [], 0, ""
for m in HEADING_RE.finditer(text):
if m.start() > last_pos:
sections.append((last_title, text[last_pos:m.start()]))
last_title, last_pos = m.group(2).strip(), m.end()
sections.append((last_title, text[last_pos:]))chunks = []
for title, body in sections:
if doc_type == "contract":
# 合同按"第X条"强制边界,避免条款被切断
parts = [p for p in CLAUSE_RE.split(body) if p and p.strip()]
units = ["".join(parts[i:i+2]) for i in range(0, len(parts), 2)]
else:
units = [body]for unit in units: for piece in base_splitter.split_text(unit): piece = piece.strip() if not piece: continue # 小块向前合并 if chunks and len(piece) 600 的候选截断到前 600 字符再送 rerank(cross-encoder 对尾部信息不敏感,实测分数变化 < 0.02)。最终 rerank 环节耗时从 2.1s 降到 0.5s。
坑3:chunk overlap 导致重复召回。 80 token 重叠让相邻 chunk 同时命中,Top4 里出现两段高度重复内容,浪费 context。加了一步简单去重:按 title + 前50字符 做 key,命中则保留 rerank 分数高的。
坑4:prompt 里 chunk 顺序。 早期按 rerank 分数降序拼 prompt,模型容易只盯第一条。改成按原文出现顺序(用 chunk 的 id 排序)拼接,并在每条前加 [文档i],答案准确率涨了约 3 个点——LLM 对"上下文连贯性"比"相关性排序"更敏感。
六、效果数据
同一测试集(1200 query,人工标注 gold chunk + 人工评估答案),三轮对比:
| 配置 | Recall@5 | Recall@10 | Top-1命中 | 答案准确率 | P95延迟 |
|---|---|---|---|---|---|
| 基线:512定长 + bge-large-zh-v1.5,无rerank | 0.71 | 0.83 | 0.54 | 62% | 1.9s |
| +语义chunk(400/80,标题前缀) | 0.76 | 0.86 | 0.59 | 68% | 1.9s |
| +切换 bge-m3(dense) | 0.81 | 0.90 | 0.66 | 74% | 2.0s |
| +bge-reranker-v2-m3(recall20→top4) | 0.89 | 0.94 | 0.78 | 84% | 2.6s |
几点解读:
- chunk 优化的收益被低估了。只改切分就 +5 个点 Recall@5,成本几乎为零,应该最先做。
- bge-m3 的增益主要来自长文档。按文档类型拆分看,合同类 Recall@5 从 0.62 → 0.79,产品手册只从 0.78 → 0.82。符合预期:长文档截断问题被 1024 长度缓解了。
- rerank 是性价比最高的一步:Recall@10 已有 0.90,reranker 把"在候选里但排不上"的那部分捞了回来,Top-1 命中 +12 个点。代价是 P95 +0.6s,业务方接受。
- 端到端准确率 84% 仍不是终点,剩余 badcase 里约一半是"文档本身没写",属于知识覆盖问题,不是检索问题。
七、总结
三条经验,按投入产出排序:
- 先动 chunk,再动模型。切分策略是零成本优化,固定长度切分在结构化文档上几乎必然吃亏。标题前缀、条款边界、小块合并这三招值得默认开启。
- rerank 不是银弹,前提是召回池够好。如果 Recall@20 只有 0.7,reranker 也救不回来。我们的顺序是先把 Recall@10 做到 0.90,再上 rerank,收益才明显。
- 延迟要算总账。embedding 换大模型、rerank 加 cross-encoder,每一步都加延迟。我们的做法是给每步设预算(召回 300ms / rerank 600ms / LLM 1.5s),超了就降 recall_k 或截断候选,而不是无脑加算力。
后续计划:把 bge-m3 的 sparse 分支接进来做 hybrid 检索(FAISS 不支持稀疏,考虑 Milvus 或 Qdrant),以及针对"文档没写"的 query 做拒答阈值调优。这两块有进展再写一篇。