一、问题背景:一个"能用但不好用"的知识库
我们内部有一个技术文档知识库,大约12000篇Markdown文档,总字数约1800万。产品需求是让员工用自然语言提问,系统基于文档回答并给出引用来源。第一版RAG系统两周就上线了,用的是最朴素的方案:固定长度切块 + OpenAI embedding + 向量相似度检索 + GPT-4生成。
上线后用户反馈集中在三点:
- 问"XX接口的超时重试配置在哪",检索到的却是另一篇提到"重试"的无关文档;
- 跨段落的问题(比如配置项在A段、示例在B段)经常只召回一半;
- 同一个问题换个问法,召回结果波动很大。
我们建了一个200条的人工标注评测集(每条标注了ground truth文档ID),跑出来的基线数据很难看:
| 指标 | 基线值 |
|---|---|
| Recall@5 | 61.5% |
| Recall@10 | 71.0% |
| MRR | 0.48 |
| 答案准确率(人工评估) | 54% |
| P99 延迟 | 0.9s |
问题定位下来,核心在三个环节:分块把语义切碎了、embedding对中文技术术语不敏感、纯向量检索缺乏精排能力。下面按优化顺序逐个说。
二、环境与版本
先把环境交代清楚,避免版本差异导致结果不可复现:
- Python 3.10.13
- LangChain 0.1.20(用了它的splitter和retriever抽象)
- sentence-transformers 2.7.0
- FlagEmbedding 1.2.10(BGE reranker)
- Milvus 2.4.0(向量库,HNSW索引,M=16,efConstruction=200)
- OpenAI API(gpt-4-0613 用于生成)
- 硬件:单卡 A10 24GB,检索服务与生成服务分离部署
评测脚本固定随机种子,每次改动只动一个变量,保证对比公平。
三、方案设计:三阶段递进优化
整体思路是先把"原料"弄干净,再换"更好的尺子",最后加"精排裁判"。
阶段一:分块策略调整。 从固定512字符切块,改为基于Markdown标题层级的语义分块,再对超长块做滑窗切分,块间保留15%重叠。
阶段二:Embedding模型切换。 从 text-embedding-ada-002(1536维)切到 BAAI/bge-m3(1024维),它支持多语言、长文本(8192 token),对中文技术语料明显更友好。
阶段三:引入Rerank。 向量检索先召回Top-50,再用 bge-reranker-v2-m3 交叉编码器精排到Top-5。
每一阶段都单独跑评测,最后再叠加,这样能看清每个改动的边际收益。
四、核心实现
4.1 语义分块
固定切块最大的问题是把"配置项说明"和它的"示例代码"切到两个块里。改用Markdown结构感知的分块:
from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
def semantic_chunk(md_text: str, max_chunk: int = 800, overlap: int = 120):
# 按标题层级先切
headers = [("#", "h1"), ("##", "h2"), ("###", "h3")]
md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers)
sections = md_splitter.split_text(md_text)
# 对超长 section 做滑窗二次切分,带重叠
rec = RecursiveCharacterTextSplitter(
chunk_size=max_chunk,
chunk_overlap=overlap,
separators=["\n\n", "\n", "。", ";", " ", ""],
)
chunks = []
for sec in sections:
title_path = " > ".join([v for k, v in sec.metadata.items() if v])
if len(sec.page_content) 超时重试 > 参数说明`)拼进块文本。这一步对召回提升非常明显,因为用户提问往往带层级语境。
### 4.2 BGE-M3 嵌入与重排
```python
from FlagEmbedding import BGEM3FlagModel, FlagReranker
from pymilvus import Collection
embed_model = BGEM3FlagModel("BAAI/bge-m3", use_fp16=True)
reranker = FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=True)
def embed_and_insert(chunks, collection: Collection):
texts = [c["text"] for c in chunks]
# BGE-M3 返回 dense 向量,normalize 后做内积等价余弦
embs = embed_model.encode(texts, batch_size=16,
max_length=1024)["dense_vecs"]
collection.insert([texts, embs.tolist()])
collection.flush()
def retrieve_and_rerank(query: str, collection: Collection,
top_k: int = 50, final_k: int = 5):
q_vec = embed_model.encode([query], max_length=512)["dense_vecs"][0]
res = collection.search(
data=[q_vec.tolist()],
anns_field="embedding",
param={"metric_type": "IP", "params": {"ef": 128}},
limit=top_k,
output_fields=["text"],
)
candidates = [hit.entity.get("text") for hit in res[0]]
# 交叉编码器精排
pairs = [[query, c] for c in candidates]
scores = reranker.compute_score(pairs, normalize=True)
ranked = sorted(zip(candidates, scores), key=lambda x: -x[1])
return ranked[:final_k]
注意 BGE-M3 建议 query 侧加指令前缀不需要,但检索侧向量务必 normalize,否则 IP 和 cosine 结果不一致。
五、踩坑与优化
坑1:分块重叠过高导致重复召回。 一开始 overlap 设了30%,结果Top-5里经常出现两个几乎一样的块,浪费名额。降到15%后,Recall@5反而涨了2个点。
坑2:BGE-M3 的 max_length。 默认 8192,但文档块平均才600字符,设成 8192 会拖慢编码且无收益。我们把 max_length 设成 1024,编码吞吐提升约40%,效果无损。
坑3:Reranker 的 batch 与延迟。 bge-reranker-v2-m3 对50个候选逐对打分,单次约180ms(A10)。为了压P99,我们把 top_k 从50降到30,Recall只掉0.8%,但延迟降到约110ms。另外用 fp16 推理。
坑4:Milvus 索引参数。 HNSW 的 ef 检索参数从64提到128,Recall@10提升约1.5%,延迟增加可忽略。
坑5:生成阶段的引用丢失。 精排后Top-5顺序变了,但引用编号要跟着重排,否则用户点引用跳到错误文档。我们让 rerank 输出时保留原始 doc_id 映射。
六、效果数据
每一阶段单独评测(200条评测集,同一硬件):
| 阶段 | Recall@5 | Recall@10 | MRR | 答案准确率 | P99延迟 |
|---|---|---|---|---|---|
| 基线(512固定块+ada-002) | 61.5% | 71.0% | 0.48 | 54% | 0.9s |
| +语义分块 | 70.0% | 79.5% | 0.57 | 63% | 0.9s |
| +BGE-M3 | 78.5% | 86.0% | 0.66 | 71% | 1.0s |
| +Rerank(top50) | 89.5% | 92.5% | 0.79 | 82% | 1.4s |
| +Rerank(top30调优) | 88.7% | 92.0% | 0.78 | 81.5% | 1.2s |
几个观察:
- 语义分块是性价比最高的一步,零额外推理成本,Recall@5 直接涨8.5个点。
- 换 embedding 模型收益稳定,BGE-M3 对中文技术语料的语义区分度明显好于 ada-002。
- Rerank 是召回率飞跃的关键,Top-5 Recall 从78.5%跳到89.5%,因为交叉编码器能捕捉 query 和文档的细粒度交互,这是双塔向量做不到的。
- 延迟与效果的权衡:最终线上用 top_k=30,牺牲0.8% Recall 换200ms延迟,用户感知更流畅。
线上灰度两周后,用户"答非所问"的反馈下降了约70%。
七、总结
RAG 优化没有银弹,但有一个清晰的优先级:分块 > 嵌入模型 > 重排 > 生成。分块决定了召回上限,是地基;嵌入模型是尺子;重排是裁判;生成只是最后的表达。很多团队一上来就折腾 prompt 和生成模型,其实检索侧没做好,生成再强也是巧妇难为无米之炊。
后续我们还在做两件事:一是引入 BM25 混合检索(BGE-M3 本身支持 sparse 向量,可以省一个组件),二是对评测集做分层分析,看哪类问题仍然是短板。等有结论再写一篇。
如果这篇对你有帮助,欢迎在评论区交流你们的 chunk 策略和 rerank 选型。