一、问题背景:一个"看起来能用"的RAG系统
去年底我们把一个内部技术知识库接上了RAG问答,语料是约12万篇技术文档(Markdown为主,含少量PDF转出来的文本),平均每篇1800字。第一版做得非常"标准":LangChain 0.1.x + text-embedding-ada-002 + Chroma,chunk_size=512、chunk_overlap=50,top_k=5 直接塞进 prompt。
上线后很快被用户教做人。运营侧收集了两周的badcase,问题集中在三类:
- 答案碎片化:很多文档的关键结论在一个小节末尾,512定长切分正好把它切断,召回的chunk只有半句话。
- 语义漂移:用户问"XX服务超时怎么排查",召回的是"XX服务部署配置",字面相似但意图不同。ada-002 对中文技术术语的区分度一般。
- Top5里有效chunk排在第4、第5位:模型注意力被前几条无关内容稀释,回答开始"编"。
我们定了个朴素的评估口径:人工标注了500条query的ground truth chunk,算 Recall@5 和人工抽检的答案准确率。基线数据:
| 指标 | 基线值 |
|---|---|
| Recall@5 | 0.71 |
| 答案准确率(人工抽检200条) | 68% |
| 答非所问占比 | 23% |
| 端到端 P95 延迟 | 2.1s |
目标很明确:Recall@5 拉到 0.85+,准确率 80%+,延迟别涨太多。
二、环境与版本
先把环境钉死,避免"优化优化着环境变了":
- Python 3.10.13
- langchain 0.1.20 / langchain-community 0.0.38
- 向量库:Milvus 2.4.4(从Chroma迁过来,主要是要HNSW参数可控 + 支持稀疏向量)
- Embedding:text-embedding-ada-002(基线)→ BAAI/bge-m3(优化后)
- Reranker:BAAI/bge-reranker-v2-m3
- 推理:torch 2.2.2 + CUDA 12.1,单卡 A10 24G
- 评估脚本自研,ground truth 500条,指标用 Recall@k + MRR
三、方案设计:三步走,每步单独评估
我们的原则是一次只动一个变量,每步都跑一遍500条评估集,避免"三个一起改,好了不知道谁的功劳,坏了不知道谁背锅"。
- 阶段一:chunk策略。固定切分 → 结构化切分(按Markdown标题层级)+ 父子块(child 256 token用于检索,parent 1024 token用于喂LLM)。
- 阶段二:embedding切换。ada-002 → bge-m3,顺带把Milvus索引从 IVF_FLAT 换成 HNSW(M=16, efConstruction=200)。
- 阶段三:引入rerank。召回top 30 → bge-reranker-v2-m3 重排 → 取top 5。
四、核心实现
4.1 阶段一:结构化 + 父子块切分
核心思路:检索用小块(精度高),生成用大块(上下文完整)。用 MarkdownHeaderTextSplitter 先按标题切,再对超长段落做二次切分,并保留 parent_id 映射。
from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
from langchain_core.documents import Document
import hashlib
def build_parent_child_chunks(md_text: str, doc_id: str,
child_size: int = 256,
child_overlap: int = 32,
parent_size: int = 1024):
"""返回 (child_chunks, parent_map),child 带 parent_id 元数据"""
headers = [("#", "h1"), ("##", "h2"), ("###", "h3")]
md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers)
sections = md_splitter.split_text(md_text)
rec_splitter = RecursiveCharacterTextSplitter(
chunk_size=child_size, chunk_overlap=child_overlap,
separators=["\n\n", "\n", "。", ";", " ", ""]
)
children, parent_map = [], {}
for sec in sections:
# parent:整节内容(超长再按 parent_size 切)
parent_text = sec.page_content
pid = hashlib.md5(f"{doc_id}:{parent_text[:64]}".encode()).hexdigest()[:16]
parent_map[pid] = parent_text
for sub in rec_splitter.split_text(parent_text):
children.append(Document(
page_content=sub,
metadata={**sec.metadata, "doc_id": doc_id, "parent_id": pid}
))
return children, parent_map
这里有两个细节值得说:
child_overlap从50降到32,因为结构化切分后语义边界本来就清楚,overlap太大反而让相邻child高度重复,检索时互相挤占top位置。- parent 用整节而不是固定窗口,是因为技术文档的小节通常自包含,跨节拼接反而引入噪音。实测 parent 平均 900 token 左右,正好。
入库时 child 进向量库,parent 存 Redis(key=parent_id)。检索命中 child 后,按 parent_id 去重取 parent 喂给LLM。
4.2 阶段二:embedding 切换 + HNSW
bge-m3 输出1024维,比ada-002的1536维小,存储和检索都更省。用 FlagEmbedding 批量编码,注意 normalize_embeddings=True,因为后面用内积(IP)度量。
from FlagEmbedding import BGEM3FlagModel
from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType, utility
model = BGEM3FlagModel("BAAI/bge-m3", use_fp16=True, device="cuda:0")
def encode(texts, batch_size=64):
out = model.encode(texts, batch_size=batch_size, max_length=512,
return_dense=True, return_sparse=False)
return out["dense_vecs"] # 已归一化
# Milvus HNSW 索引
connections.connect(uri="http://milvus:19530")
fields = [
FieldSchema("id", DataType.INT64, is_primary=True, auto_id=True),
FieldSchema("parent_id", DataType.VARCHAR, max_length=32),
FieldSchema("text", DataType.VARCHAR, max_length=2048),
FieldSchema("vector", DataType.FLOAT_VECTOR, dim=1024),
]
coll = Collection("tech_docs_v2", CollectionSchema(fields))
coll.create_index("vector", {
"index_type": "HNSW", "metric_type": "IP",
"params": {"M": 16, "efConstruction": 200}
})
coll.load()
查询侧小坑:bge-m3 官方建议 query 加指令前缀,但只对短query有效。我们的query平均12个字,加了 "为这个句子生成表示以用于检索相关文章:" 前缀后 Recall@5 涨了约1.5个点,成本为零,加了。
4.3 阶段三:rerank
召回top 30,用 bge-reranker-v2-m3 做 cross-encoder 重排。30这个数是压测出来的:10条时Recall提升不明显,50条时P95延迟多400ms,30是拐点。
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=True)
def rerank(query, candidates, top_n=5):
pairs = [[query, c["text"]] for c in candidates]
scores = reranker.compute_score(pairs, normalize=True)
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return [c for c, s in ranked[:top_n]]
五、踩坑与优化
坑1:父子块去重顺序错了。 一开始先取top5 child再映射parent,结果5个child经常来自同一个parent,最后只剩1-2个不同parent,上下文反而变少。改成先取top30 child → 按parent_id去重 → 取top5 parent,Recall@5直接多了3个点。
坑2:HNSW 的 ef 没调。 Milvus 默认 ef=64,top_k=30时召回质量打折。建collection时把 search_params={"ef": 128} 显式传进去,召回率涨了约2个点,延迟只多十几毫秒。
坑3:rerank 的 batch 和 max_length。 一次把30个pair塞进去,A10上显存直接飙到21G。改成 batch_size=8、max_length=512 后稳定在9G,P95重排耗时约280ms。另外 normalize=True 一定要开,不然分数尺度不一致,阈值没法卡。
坑4:评估集泄露。 早期调参时反复在500条评估集上看指标,看到后面已经开始"过拟合评估集"了。后来切了200条做dev、300条做test,调参只看dev。
六、效果数据
同一套500条评估集(300 test),逐步累加:
| 阶段 | Recall@5 | MRR | 答案准确率 | P95延迟 |
|---|---|---|---|---|
| 基线(512定长 + ada-002) | 0.71 | 0.58 | 68% | 2.1s |
| + 结构化父子块 | 0.78 | 0.65 | 74% | 2.2s |
| + bge-m3 + HNSW | 0.84 | 0.72 | 80% | 2.3s |
| + bge-reranker-v2-m3 | 0.89 | 0.79 | 86% | 2.6s |
几个观察:
- chunk策略是性价比最高的一步,零推理成本,纯工程改动,涨了7个点。
- embedding切换涨了6个点,但要重建全量索引(12万篇,A10上编码约40分钟),得安排好窗口。
- rerank涨了5个点,代价是P95多了300ms。对问答场景完全值得,对搜索联想场景就不合适了。
- 答非所问占比从23%降到9%,这是用户感知最明显的。
七、总结
回头看,这次优化没有用什么新潮的东西,就是把三个环节各自做扎实:切分尊重文档结构、embedding选对中文场景的模型、召回后加一层cross-encoder重排。三个动作叠加,Recall@5从0.71到0.89。
如果重来一次,我会先做chunk再做embedding——因为chunk策略决定了检索的上限,embedding和rerank都是在更好的候选集上做文章。另外,评估集一定要提前切分dev/test,不然调参调到后面全是自欺欺人。
后续打算试两个方向:一是bge-m3的稀疏向量做混合检索(dense+sparse),二是把rerank的top_k做成按query类型动态调整。有进展再写一篇。