一、问题背景
去年底我们给一个内部知识库做了个RAG问答系统,接的是公司文档中心,大概1.2万篇技术文档、产品手册和FAQ,总量约400MB纯文本。上线第一周,用户反馈就很直接:"答非所问"、"检索出来的内容跟问题没关系"、"明明文档里有,就是答不出来"。
我拉了200条真实用户query做了人工标注评测,初始版本的数据很难看:
- Top-3检索命中率:61%
- 回答准确率(人工判定):58%
- 平均首字延迟:1.8s,P95:2.3s
这个水平基本没法用。于是开始了一轮系统性优化,分三步走:chunk策略、embedding模型、rerank。下面按顺序记录整个过程,包括踩的坑。
二、环境与版本
先交代下技术栈,避免版本对不上:
- Python 3.10.12
- LangChain 0.1.16(只用了TextSplitter和部分工具,检索链路自己写的)
- 向量库:Milvus 2.3.4(HNSW索引,M=16,efConstruction=200)
- 初始embedding:OpenAI text-embedding-ada-002(1536维)
- 后续embedding:BAAI/bge-large-zh-v1.5(1024维),通过FlagEmbedding 1.2.10本地部署
- Rerank:BAAI/bge-reranker-base,同样本地部署
- LLM:GPT-3.5-turbo(后续部分场景切到Qwen-72B,不影响检索链路)
- 硬件:单卡A10 24G,embedding和rerank都跑在这张卡上
三、方案设计与优化过程
3.1 第一轮:chunk策略调整
初始chunk策略是拍脑袋定的:RecursiveCharacterTextSplitter(chunk_size=1024, chunk_overlap=0)。问题很明显——1024个字符对中文来说太长了,一段chunk里经常混着两三个主题,向量表示被"平均"掉了,检索时哪个主题都不像。而且overlap=0,跨段落的上下文直接断裂。
改成了 chunk_size=256, chunk_overlap=40(约15%重叠),中文按字符切,分隔符用 ["\n\n", "\n", "。", "!", "?", ";", ","]。同时给每个chunk在头部拼上文档标题和一级标题路径,比如 【产品手册/权限管理】...正文...,让chunk自带上下文。
from langchain.text_splitter import RecursiveCharacterTextSplitter
def build_chunks(doc_title: str, heading_path: str, text: str):
splitter = RecursiveCharacterTextSplitter(
chunk_size=256,
chunk_overlap=40,
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
length_function=len,
)
raw_chunks = splitter.split_text(text)
# 头部注入上下文,提升chunk的语义自包含性
prefix = f"【{doc_title}/{heading_path}】"
return [prefix + c for c in raw_chunks if len(c.strip()) > 20]
这一轮改完,Top-3命中率从61%到72%,提升明显但还不够。副作用是chunk数量从约4.8万涨到19万,Milvus检索延迟从12ms涨到35ms左右,可接受。
3.2 第二轮:embedding模型切换
ada-002对中文的语义区分度一般,尤其在技术术语密集的场景。我们做了个对比实验:同一批200条query,用ada-002和bge-large-zh-v1.5分别建库检索。bge在中文短query上的召回明显更好,且支持指令前缀(query侧加"为这个句子生成表示以用于检索相关文章:")。
切换时要注意:建库和查询必须用同一个模型、同一套归一化逻辑。bge输出需要L2归一化后用内积(IP)检索,等价于余弦相似度。Milvus里索引类型要从原来的L2改成IP。
from FlagEmbedding import FlagModel
import numpy as np
model = FlagModel(
"BAAI/bge-large-zh-v1.5",
query_instruction_for_retrieval="为这个句子生成表示以用于检索相关文章:",
use_fp16=True,
)
def embed_docs(texts):
vecs = model.encode(texts, batch_size=64, max_length=512)
return vecs / np.linalg.norm(vecs, axis=1, keepdims=True)
def embed_query(q: str):
vec = model.encode_queries([q])[0]
return vec / np.linalg.norm(vec)
切换后Top-3命中率从72%到78%。注意这一步重建了整个Milvus collection,19万条chunk在A10上编码约25分钟。
3.3 第三轮:引入rerank
到78%之后进入瓶颈。分析badcase发现,向量检索的"召回"其实还行——正确答案有89%落在Top-20里,但Top-3排序不准。这就是典型的召回够、排序不够,正是rerank的用武之地。
方案:Milvus先召回Top-20,用bge-reranker-base做cross-encoder精排,取Top-3送给LLM。rerank模型输入是(query, chunk)对,输出相关性分数,比双塔的向量内积精细得多。
from FlagEmbedding import FlagReranker
import pymilvus
reranker = FlagReranker("BAAI/bge-reranker-base", use_fp16=True)
def retrieve_with_rerank(query: str, top_k=3, recall_k=20):
q_vec = embed_query(query)
search_res = milvus_client.search(
collection_name="kb_chunks",
data=[q_vec.tolist()],
anns_field="embedding",
param={"metric_type": "IP", "params": {"ef": 64}},
limit=recall_k,
output_fields=["chunk_id", "text"],
)
candidates = [
{"chunk_id": h.entity.get("chunk_id"), "text": h.entity.get("text")}
for h in search_res[0]
]
pairs = [[query, c["text"]] for c in candidates]
scores = reranker.compute_score(pairs, normalize=True)
for c, s in zip(candidates, scores):
c["rerank_score"] = float(s)
candidates.sort(key=lambda x: x["rerank_score"], reverse=True)
return candidates[:top_k]
3.4 踩坑记录
几个值得说的坑:
-
rerank的max_length:bge-reranker-base默认512,但query+chunk拼起来容易超。超长会被截断,反而丢信息。我们把chunk控制在256 token以内后基本不触发截断,这也是第一轮chunk调小的额外收益。
-
rerank分数阈值:一开始想用绝对阈值过滤低质量结果,发现分数分布随query类型波动很大,固定阈值不可靠。最后改成只取Top-3不过滤,把"是否该拒答"交给LLM的prompt判断。
-
Milvus IP索引重建:切embedding时忘了改metric_type,检索结果乱序,排查了半天。建collection时metric_type和索引参数一定要跟embedding的归一化方式对齐。
-
GPU显存:embedding和rerank同时加载在A10上,fp16下约占用14G,批量rerank时batch_size别开太大,我们设的32,再大就OOM。
四、效果数据
同一批200条标注query,三轮优化对比:
| 指标 | 初始 | chunk优化 | +embedding切换 | +rerank |
|---|---|---|---|---|
| Top-3命中率 | 61% | 72% | 78% | 89% |
| Top-20召回率 | — | 84% | 89% | 89% |
| 回答准确率 | 58% | 68% | 74% | 82% |
| 检索P95延迟 | 12ms | 35ms | 48ms | 210ms |
| 端到端P95延迟 | 2.3s | 2.4s | 2.5s | 2.8s |
rerank让检索环节P95从48ms涨到210ms,但端到端只多了0.3s——因为LLM生成占了2s多的大头,rerank的绝对开销可以接受。如果对延迟极敏感,可以考虑rerank只对Top-10做,或者换更小的reranker。
五、总结
这轮优化的核心结论:
- chunk策略是第一优先级,比换模型便宜且收益大。中文场景256字符+15%重叠+标题注入是个不错的起点。
- embedding切换要看召回天花板。我们切换前先测了Top-20召回,确认向量侧还有空间才动手,避免盲目换模型。
- rerank解决的是排序问题,不是召回问题。引入前一定先确认"正确答案在不在召回集里",不在的话rerank救不了,得回去改召回。
- 每一步都要有标注评测集。200条query不多,但足以支撑判断,比拍脑袋强太多。
后续还想试的方向:query改写(多query召回融合)、按文档类型分collection检索、以及用更小的reranker(bge-reranker-v2-m3的量化版)压延迟。有进展再写一篇。