一、问题背景:用户说“你根本没听懂我在问什么”

我们的项目是一个医疗问诊辅助系统,知识库里有3000多篇药品说明书和临床指南,总文本量约800MB。上线初期,用户满意度只有68%。翻看日志,大量反馈集中在“答非所问”——比如用户问“阿莫西林和头孢能一起吃吗”,系统答非所问地推荐了阿莫西林胶囊的用法用量。

最开始我怀疑是LLM生成的问题,但把召回的上下文打印出来一看,发现召回片段里压根没有“相互作用”相关的句子,全是药品成分和适应症的堆砌。问题出在召回,不是生成。

根因分析:
1. chunk太大(512字符):一个chunk里混入了多段语义,向量化后语义被“平均化”,相似度区分度下降。
2. Embedding模型不匹配:BGE-large-zh-v1.5在通用语料上表现不错,但医疗术语(如“药物相互作用”“药代动力学”)的向量空间区分度不够。
3. 无重排机制:Top5结果直接拼进Prompt,没有做精细的相关性排序,导致不相关片段混入。

二、环境与版本

  • Python 3.10.12
  • langchain 0.2.11
  • langchain-openai 0.1.14
  • chromadb 0.5.3(本地向量库)
  • sentence-transformers 2.7.0
  • 文本切分:langchain_text_splitters 0.2.2
  • 向量模型:BGE-large-zh-v1.5 → text2vec-large-chinese(切换)
  • 重排模型:bge-reranker-base(FlagEmbedding 1.2.10)
  • 测试集:人工标注的200条真实问诊问题,每条有唯一正确答案的文档片段ID

三、方案设计:三步走,每步单独验证

我不喜欢一口气改所有东西,那样出了问题没法定位。所以分三步:

  1. 第一步:调整chunk策略。固定BGE-large-zh-v1.5,对比512字符 vs 256字符 vs 128字符。
  2. 第二步:切换Embedding模型。在最优chunk策略下,对比BGE-large-zh-v1.5 vs text2vec-large-chinese。
  3. 第三步:引入reranker。在最优chunk+最优Embedding基础上,加bge-reranker-base重排Top20。

评估指标:
- Recall@5:正确答案是否在Top5内
- Precision@1:第一名是否是正确答案
- 检索耗时:单次查询的p95耗时

四、核心实现:代码与参数

4.1 chunk策略调整

最初用的LangChain的RecursiveCharacterTextSplitter,参数如下:

from langchain_text_splitters import RecursiveCharacterTextSplitter

# 初始配置:512字符,重叠64
splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,
    chunk_overlap=64,
    separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", ";", ";", ",", ","]
)

# 调优后配置:256字符,重叠16
splitter_256 = RecursiveCharacterTextSplitter(
    chunk_size=256,
    chunk_overlap=16,  # 重叠率从12.5%降到6.25%
    separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", ";", ";", ",", ","]
)

关键调整点:
- chunk_size从512降到256。128也试了,但Recall@5反而下降(后面踩坑部分细说)。
- 重叠率从64降到16。试过128重叠,发现召回结果大量重复,Top5里有3个是同一段内容的变体,浪费了名额。

4.2 Embedding模型切换

BGE-large-zh-v1.5的向量维度是1024,text2vec-large-chinese是1024,所以向量库不用重建索引结构,只需重新embedding所有chunk。

from sentence_transformers import SentenceTransformer
import chromadb
from tqdm import tqdm

# 加载text2vec模型
model = SentenceTransformer("GanymedeNil/text2vec-large-chinese")

# 重新embedding并写入chroma
client = chromadb.PersistentClient(path="./db_medical_v2")
collection = client.get_or_create_collection(
    name="medical_docs",
    metadata={"hnsw:space": "cosine"}
)

# 假设chunks是上面splitter切分后的文本列表
for i, text in enumerate(tqdm(chunks, desc="Embedding")):
    vector = model.encode(text, normalize_embeddings=True).tolist()
    collection.add(
        ids=[f"chunk_{i}"],
        embeddings=[vector],
        documents=[text],
        metadatas=[{"source": f"doc_{i // 10}"}]
    )

注意点: normalize_embeddings=True必须开,否则cosine相似度计算会有偏差。另外,text2vec-large-chinese在GPU上编码速度大约是每千条8秒(batch_size=32, RTX 3090),3000个文档切出的1.2万个chunk,全量编码大概2分钟。

4.3 引入Reranker

重排我用的是FlagEmbedding库的bge-reranker-base。先召回Top20,再重排取Top5。

from FlagEmbedding import FlagReranker

reranker = FlagReranker("BAAI/bge-reranker-base", use_fp16=True)

def retrieve_with_rerank(query, collection, top_k=20, rerank_top_k=5):
    # 1. 向量检索Top20
    results = collection.query(
        query_embeddings=[model.encode(query, normalize_embeddings=True).tolist()],
        n_results=top_k,
        include=["documents", "metadatas", "distances"]
    )

    # 2. 构造pair进行rerank
    pairs = [[query, doc] for doc in results["documents"][0]]
    scores = reranker.compute_score(pairs)

    # 3. 按重排分数取Top5
    sorted_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:rerank_top_k]

    final_docs = [results["documents"][0][i] for i in sorted_idx]
    return final_docs

耗时注意: bge-reranker-base在CPU上跑20个pair大概耗时50ms,GPU上约10ms。如果并发高,建议单独部署一个rerank服务,我用的是FastAPI包了一层。

五、踩坑与优化

5.1 坑1:chunk_size=128反而更差

我一开始以为chunk越小越好,试了128。结果Recall@5从64.3%降到58.7%。分析发现:

  • 128字符的chunk太碎,一个完整的“药物相互作用”段落被切成两三块,每块都缺上下文。
  • 向量化时,短文本的语义表达能力弱,容易产生语义偏移。

结论: chunk_size=256是当前语料的最优值,既保留了完整语义单元,又不会混入太多无关内容。

5.2 坑2:重叠率设太高导致召回重复

我试过chunk_overlap=128(重叠率25%),结果Top5里经常出现3个重复片段——因为同一个长句被两个chunk各包含一半,向量相似度都很高,挤占了其他有效结果的位置。

优化: 重叠率降到6%左右(16字符),只保留跨句边界的关键连接词。

5.3 坑3:text2vec在混合语料上需要归一化

text2vec-large-chinese在纯中文语料上效果很好,但我们的文档里有大量英文药物名(如Acetaminophen)。直接编码时,中英文混合文本的向量分布不均匀。后来在编码前加了一个简单的预处理:text.replace("\n", " ").strip(),并确保所有文本统一用小写。这步提升了大约2个百分点的Recall@5。

六、效果数据:三步优化后的对比

配置 Recall@5 Precision@1 P95检索耗时
初始(512chunk + BGE + 无rerank) 64.3% 38.7% 120ms
+ chunk=256 71.5% 44.2% 115ms
+ text2vec-large-chinese 83.4% 55.8% 118ms
+ bge-reranker-base 91.2% 67.4% 165ms(含rerank)

额外观察:
- 召回率提升后,LLM生成阶段的效果也有明显改善。用户满意度从68%升到82%(回访200名用户)。
- 检索耗时从120ms升到165ms,但还在可接受范围内。如果未来量大,可以上GPU推理rerank,预计能压回130ms以内。

最终方案上线配置:
- chunk_size=256, chunk_overlap=16
- embedding模型:text2vec-large-chinese(1024维,cosine距离)
- 召回Top20,rerank取Top5
- rerank模型:bge-reranker-base(FP16)

七、总结与建议

这次优化花了三个工作日,收益是实打实的:召回率提升27个百分点,首答准确率翻倍。几点经验供参考:

  1. 先别急着换模型,先调chunk。 chunk粒度对召回的影响往往被低估,而且调chunk成本最低——只需要重新切分和embedding,不用换模型。
  2. Embedding模型选择要结合语料领域。 通用模型在垂直场景下大概率不是最优解。如果预算允许,可以用领域语料做微调,但这次我们只是换模型就提升了12个百分点。
  3. Rerank是性价比最高的“短平快”优化。 只要你的向量检索能召回足够多的候选(比如Top20),加一个reranker就能显著提升Top5质量。bge-reranker-base只有约400MB,部署成本低。
  4. 评估集要人工标注。 别用正则或者LLM自动生成标准答案,你无法保证自动生成的就是“正确”的召回目标。

下一步计划:将chunk策略改为按语义段落切分(比如用LLM识别边界),以及尝试用ColBERT做后期交互的召回。等有结果了再写一篇。