一、问题背景:为什么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的稀疏检索能力太弱。

三、方案设计:三步走的优化路线

核心思路是不碰生成模型,只改检索链路

  1. chunk策略重构:放弃固定RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50),改用MarkdownHeaderTextSplitter+RecursiveCharacterTextSplitter组合,先按标题层级切分,再对过长段落用语义边界(句号、分号)二次切分。

  2. Embedding模型升级text2vec-large-chinese(768维)换bge-large-zh-v1.5(1024维),并开启query_instruction前缀("为这个句子生成表示以用于检索相关文章:")。

  3. 引入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,千万别用固定长度切块,那是拿大炮打蚊子。