一、问题背景:为什么63%的召回率不可接受
我们做的是一个面向医院科室的辅助诊断问答系统,知识库包含约12万篇医学指南和药品说明书。线上反馈最集中的问题是:问“阿莫西林和头孢的抗菌谱区别”时,系统经常答非所问。
排查日志后发现,不是生成模型的问题,而是检索环节根本没把相关文档片段找回来。用标注好的500条测试集(问题-相关文档片段对)评估,基于BAAI/bge-large-zh-v1.5的纯向量检索命中率(top5包含正确答案)只有63%。
主要错误类型:
- 长文档被512字符硬切,跨段落的因果关系被切断(比如“禁忌症”和“不良反应”被分到两个chunk里)
- 专有名词(如“万古霉素耐药肠球菌”)在向量空间中匹配不到精确片段
- 相似度阈值定太高(0.75),导致部分相关文档被误杀
二、环境与版本:这套方案跑在什么配置上
Python 3.10.14
langchain 0.2.11
chromadb 0.5.3
sentence-transformers 3.0.1
bge-reranker-base (FlagEmbedding 1.2.10)
milvus 2.4.1 (pymilvus 2.4.4)
Embedding模型从text2vec-large-chinese换成了BAAI/bge-large-zh-v1.5(维度1024),重排器用的BAAI/bge-reranker-base。向量库从chromadb迁移到milvus,因为后续要支持BM25+向量的混合检索,chromadb的稀疏检索能力太弱。
三、方案设计:三步走的优化路线
核心思路是不碰生成模型,只改检索链路:
-
chunk策略重构:放弃固定
RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50),改用MarkdownHeaderTextSplitter+RecursiveCharacterTextSplitter组合,先按标题层级切分,再对过长段落用语义边界(句号、分号)二次切分。 -
Embedding模型升级:
text2vec-large-chinese(768维)换bge-large-zh-v1.5(1024维),并开启query_instruction前缀("为这个句子生成表示以用于检索相关文章:")。 -
引入Reranker重排:向量检索先粗召回top20,再用
bge-reranker-base对query和20个候选片段逐一打分,取top5。这一步是为了解决向量距离不直接等于相关性的问题。
四、核心实现:关键代码与参数细节
4.1 语义感知的chunk切分
from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
# 按markdown标题层级切分
headers_to_split_on = [
("#", "章节1"),
("##", "章节2"),
("###", "章节3"),
]
markdown_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
# 对每个块再按语义边界二次切分
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=200, # 从512降到200
chunk_overlap=30, # 重叠从50降到30
separators=["\n\n", "。", ";", "!", "?", "\n", " ", ""], # 中文优先按句末标点切
keep_separator=True
)
# 实际使用:先markdown切,再对超长块递归切
raw_docs = markdown_splitter.split_text(md_content)
chunks = []
for doc in raw_docs:
if len(doc.page_content) > 300:
chunks.extend(text_splitter.split_text(doc.page_content))
else:
chunks.append(doc)
关键参数调整:chunk_size从512降到200,chunk_overlap从50降到30。为什么?因为医学文本的“结论”往往集中在段落末尾,过大的chunk会把不相关的前置描述混进来,稀释语义。调参后发现平均chunk长度从512字符降到186字符,片段数从23万涨到41万——召回率反而提升了,因为每个片段更聚焦。
4.2 混合检索(BM25 + 向量)
from pymilvus import MilvusClient, model
# bge-large-zh-v1.5 embedding,dim=1024
embedding_fn = model.hybrid.HybridEmbeddingFunction(
dense=model.dense.SentenceTransformerEmbeddingFunction(
model_name="BAAI/bge-large-zh-v1.5",
device="cuda:0",
normalize_embeddings=True
),
sparse=model.sparse.BM25EmbeddingFunction(language="zh")
)
client = MilvusClient(uri="http://localhost:19530", db_name="medical")
# 检索时:向量top20 + BM25 top20,合并去重
query = "万古霉素耐药肠球菌的感染治疗方案"
dense_res = client.search(
collection_name="medical_docs",
data=[embedding_fn.encode_dense(query)],
limit=20,
output_fields=["text", "metadata"],
search_params={"metric_type": "IP", "params": {"nprobe": 16}}
)
sparse_res = client.search(
collection_name="medical_docs",
data=[embedding_fn.encode_sparse(query)],
limit=20,
output_fields=["text", "metadata"],
search_params={"metric_type": "BM25"}
)
# 合并后取并集,交给reranker
这里有个坑:BM25的权重不能太高。刚开始设成0.5/0.5,结果短查询(如“阿莫西林”)被BM25的精确词频主导,召回结果全是含“阿莫西林”字眼的泛泛内容,反而丢失了语义相关的“青霉素类抗菌谱”片段。最后调到0.3(BM25)/0.7(向量)才平衡。
4.3 Reranker重排逻辑
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-base", use_fp16=True, device="cuda:0")
def rerank(query, candidates, top_k=5):
# candidates 是 (text, metadata) 列表
pairs = [[query, cand[0]] for cand in candidates]
scores = reranker.compute_score(pairs, normalize=True)
scored = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return [item[0] for item in scored[:top_k]]
# 调用:混合检索返回约30个候选,重排取5
hybrid_hits = union_dense_sparse(dense_res, sparse_res)
final_hits = rerank(query, hybrid_hits, top_k=5)
Reranker的代价:bge-reranker-base在A100上推理30个pair大约耗时80ms,比向量检索(20ms)慢4倍,但比生成阶段(约2s)快得多,完全可接受。
五、踩坑与优化:三个没想到的问题
坑1:BGE模型的query指令不能乱加
刚开始以为所有模型都要加query_instruction,实测text2vec-large-chinese加了反而掉点(-3%),而bge-large-zh-v1.5必须加(+5%)。原因是text2vec训练时没设计指令前缀,加了会干扰语义。最后只对bge开启。
坑2:Reranker对长文本不友好
bge-reranker-base的max_seq_length是512 token,但我们的chunk最长有800字(约1500 token)。一开始直接塞进去,结果后半段被截断,相关段落反而被误杀。解决办法:对超长chunk先做滑窗切分,再对每个窗口独立计算得分,取最大值。
def safe_rerank(query, text, max_len=450, stride=200):
tokens = tokenizer.encode(text)
if len(tokens) <= max_len:
return reranker.compute_score([[query, text]], normalize=True)[0]
scores = []
for start in range(0, len(tokens), stride):
end = min(start + max_len, len(tokens))
chunk_text = tokenizer.decode(tokens[start:end])
scores.append(reranker.compute_score([[query, chunk_text]], normalize=True)[0])
return max(scores)
坑3:混合检索的分数归一化
BM25返回的是原始词频分数(0~30),向量返回的是余弦相似度(-1~1),直接合并排序会严重偏向BM25。必须分别做min-max归一化,再按权重加权。实测不归一化时,命中率只有71%,归一化后到89%。
六、效果数据:从63%到89%的每一步
| 方案 | top5命中率 | 平均检索耗时(ms) | 幻觉率(人工抽检100条) |
|---|---|---|---|
| 基线(512字符切块 + text2vec) | 63% | 12 | 28% |
| 换bge-large-zh-v1.5 | 71% | 18 | 24% |
| 语义切块(200字符) | 78% | 20 | 19% |
| + BM25混合检索(0.3/0.7) | 84% | 35 | 16% |
| + bge-reranker-base重排 | 89% | 115 | 12% |
额外发现:长尾实体(药物别名、通用名)的检索提升最明显。比如“对乙酰氨基酚”和“扑热息痛”在向量空间距离较远(0.6),但BM25能通过词频命中“扑热息痛”的原始文档,混检后召回率从52%提升至81%。
七、总结与遗留问题
这套方案已经在生产环境跑了2周,线上平均首响时间从1.8s增加到2.1s(reranker拖了后腿),但用户满意度评分从3.2提升到4.1。代价是显存占用:bge-large(1.3GB)+ reranker(1.1GB)在A10上勉强够用,如果并发超过10,需要切bge-base-zh或上量化。
遗留问题:跨段落推理还是没解决。比如“阿司匹林和布洛芬哪个对胃刺激小”,正确答案分散在两个不同章节,即使检索到了两个chunk,生成模型也拼不出对比结论。下一步准备试GraphRAG,用知识图谱把实体关系串起来。
如果你也在调RAG,建议先看一眼自己的知识库结构——如果文档是Markdown或HTML,千万别用固定长度切块,那是拿大炮打蚊子。