一、问题背景:用户问“报销”,系统给“考勤”

我们做的是企业内部文档问答系统,底层是标准的RAG流程:文档切块 → embedding向量化 → 存入Milvus → 查询时向量召回Top-K → LLM生成答案。

线上跑了一个月,用户反馈最多的不是“答案不对”,而是“根本找不到对应文档”。我们拉取了近一周的线上query日志,随机抽了500条做人工标注,发现召回率(Top-5内包含正确答案的比例)只有61.2%。具体表现为三类问题:

  1. 语义混淆:用户问“加班餐费怎么报销”,系统召回的是“加班补贴标准”,因为都含有“加班”字样,但实质内容差很远。
  2. 专有名词衰减:比如“ERP系统应付模块操作手册”,embedding后跟“财务系统付款流程”的相似度反而更高。
  3. 长文本稀释:我们把整个章节(约2000-3000字)作为一个chunk,结果向量被平均化,跟query相关的部分被无关内容稀释了。

二、环境与版本

先交代一下当时的运行环境,方便你复现对比:

  • Python 3.10.12
  • LangChain 0.2.11(当时最新版)
  • Milvus 2.4.1(用于向量存储,collection用了HNSW索引,metric类型为IP)
  • 语言模型:Qwen-14B-Chat(通过vLLM 0.6.3部署)
  • 原embedding模型:BAAI/bge-large-zh-v1.5(1024维)
  • 替换后embedding模型:shibing624/text2vec-base-chinese-paraphrase(768维,后文说明为什么不用large)

离线评测集:从知识库中取2000个问题-文档对(由业务方手工标注),用Recall@5MRR@5作为指标。

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

我们决定按顺序优化,每一步都在同一个评测集上跑离线数据,避免多个变量同时变化导致无法归因。

Step 1:chunk策略调整。原方案是固定256字切分,无重叠。我们改成“语义标题滑动窗口”:先按Markdown标题拆成二级段落,再对每个段落内部如果超过512字,按句号切分并保留前后1个句子的重叠(约30-50字)。

Step 2:embedding模型切换。从bge-large-zh-v1.5(1024维)换到text2vec系列。为什么换?因为我们的测试集里包含大量如“应付暂估”“资产转固”这类财务专业词,评测后发现text2vec在这些词上的相似度分布更分散,而bge把所有跟“财务”沾边的query都挤到一个高密度区域。另外,我们最终线上用的是text2vec-base-chinese-paraphrase,不是large——因为large的推理延迟在CPU上要80ms,base只要25ms,而精度差距只有1.1%。

Step 3:引入rerank。粗排(embedding召回Top-50)之后,接一个交叉编码器BAAI/bge-reranker-base对50条重新打分,取Top-5。这个模型是单塔结构,query和document拼在一起输入,能建模细粒度交互。

四、核心实现:每一步的代码与关键参数

4.1 chunk策略:从固定长度到语义滑动窗口

原来代码很简单,split_text按字符数硬切,导致句子被切断。改成下面的逻辑:

from langchain.text_splitter import MarkdownHeaderTextSplitter
from langchain.text_splitter import RecursiveCharacterTextSplitter

# 1. 先按Markdown标题结构切一级
headers_to_split_on = [
    ("#", "H1"),
    ("##", "H2"),
    ("###", "H3"),
]
md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on, strip_headers=False)
md_chunks = md_splitter.split_text(markdown_doc)

# 2. 对每个标题块内部,用递归字符切分器做语义滑动
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,
    chunk_overlap=60,  # 保留约1-2个完整句子
    separators=["\n\n", "。", "!", "?", "\n", ";", ",", " ", ""],
    length_function=len,
)

final_chunks = []
for chunk in md_chunks:
    sub_chunks = text_splitter.split_text(chunk.page_content)
    for sub in sub_chunks:
        # 注入元数据:标题路径 + 原始文档ID
        final_chunks.append({
            "text": sub,
            "metadata": {
                "doc_id": "xxx",
                "header_h1": chunk.metadata.get("H1", ""),
                "header_h2": chunk.metadata.get("H2", ""),
                "header_h3": chunk.metadata.get("H3", ""),
            }
        })

关键参数chunk_size=512(不是越大越好,后面踩坑会细说),chunk_overlap=60。这里用句号、感叹号作为优先分隔符,保证语义完整性。

4.2 embedding替换与向量库迁移

原向量是1024维,换成text2vec后是768维,Milvus里的collection需要重建。我们写了迁移脚本,但注意一个坑——维度变了,索引必须重建,而且要用新的collection名,否则线上服务会短暂不可用。

from sentence_transformers import SentenceTransformer
from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType

# 加载新版embedding模型
model = SentenceTransformer("shibing624/text2vec-base-chinese-paraphrase")
print(model.encode(["测试"]).shape)  # (768,)

# 创建新collection
fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
    FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=2000),
]
schema = CollectionSchema(fields, "kb_v2")
collection = Collection("kb_chunk_v2", schema)

# 批量写入(伪代码)
for batch in doc_batches:
    vectors = model.encode(batch["texts"], normalize_embeddings=True)
    collection.insert([batch["ids"], vectors, batch["texts"]])

# 创建HNSW索引,重点:M值调到32,efConstruction设为200
index_params = {
    "index_type": "HNSW",
    "metric_type": "IP",
    "params": {"M": 32, "efConstruction": 200}
}
collection.create_index("embedding", index_params)

注意normalize_embeddings=True必须开,因为我们用的是IP内积,不是余弦距离。否则召回结果会乱。

4.3 rerank集成

粗排召回Top-50,然后用bge-reranker-base打分,取Top-5。这里用的是FlagEmbedding库:

from FlagEmbedding import FlagReranker

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

def rag_search(query, top_k=50, rerank_top=5):
    # 1. embedding召回
    q_vec = embed_model.encode(query, normalize_embeddings=True)
    results = collection.search(
        data=[q_vec], 
        anns_field="embedding",
        param={"metric_type": "IP", "params": {"ef": 200}},
        limit=top_k,
        output_fields=["text"]
    )
    hits = results[0]

    # 2. rerank精排
    pairs = [[query, hit.entity.get("text")] for hit in hits]
    scores = reranker.compute_score(pairs, normalize=True)

    # 3. 按新分数排序取前5
    scored = sorted(zip(hits, scores), key=lambda x: x[1], reverse=True)
    return [hit.entity.get("text") for hit, score in scored[:rerank_top]]

这里的ef参数是查询时HNSW的探索宽度,设成200能提高召回率,但延迟会增加约5ms,可接受。

五、踩坑与优化:你以为改完就完了?

坑1:chunk重叠率过高导致重复内容刷屏

第一次把chunk_overlap设成120,结果两个相邻chunk几乎一半内容重复,向量召回时同一个文档的内容占了Top-5的3个位置,答案冗余严重。后来改成重叠区间只包含“前一个句子的结尾 + 后一个句子的开头”,即大约60字,重复率就降到5%以下。

坑2:bge-large换text2vec后,Milvus旧collection没删干净

我们直接复用旧collection名,结果数据维度对不上,报错。后来强制删了旧collection,用新名字kb_chunk_v2。这里建议:embedding模型升级一定要换collection名,不要原地重建,便于灰度回滚。

坑3:rerank模型的batch推理显存溢出

在GPU上跑rerank,如果一次性把50条query-doc对全塞进去,12G显存会爆。改成每次处理16对,用循环累加。实际延迟影响不大,50条总共增加约180ms。

坑4:离线评测和线上效果不一致

离线Recall@5到了89%,但线上用户满意度只提升了10%。后来查了线上日志,发现很多用户query是“口语化”的,比如“钱多久能到账”,评测集里写的是“报销到账时间”。解决办法:我们在query侧加了一个轻量改写模块(基于规则+同义词表),把口语转成书面语再进向量检索。这一步又提升了3.2%的线上命中率。

六、效果数据:每个阶段的变化轨迹

我们在同一份2000条人工评测集上跑分,保证可对比性:

阶段 Recall@5 MRR@5 平均响应延迟
初始(固定256字chunk + bge-large) 61.2% 0.458 310ms
Step1(语义滑动chunk) 68.7% 0.512 325ms
Step2(换text2vec-base) 74.5% 0.567 305ms
Step3(引入rerank) 82.3% 0.634 495ms
最终(+口语改写) 89.1% 0.692 510ms

线上AB测试(连续7天,各50%流量):

  • 用户点“有帮助”按钮的比例:从31.4%提升到44.7%
  • 平均每query召回的文档数(去重后):从4.2个降低到2.8个,意味着答案更聚焦
  • 端到端响应时间:因为加了rerank,从800ms涨到1.2s,但用户可接受(p95 < 2.5s)

七、总结与建议

回顾整个优化过程,我想说三点:

  1. chunk策略是性价比最高的优化点。我们只改切分逻辑,没换模型,召回率就涨了7.5个点。不要迷信embedding模型,先检查你的chunk有没有切断关键实体。
  2. embedding模型不要盲追最新。bge-large-zh-v1.5很强,但它在垂直领域(财务/ERP)上可能不如一个在中文通用语料上训练得更充分的模型。你最好自己拉一个200条查询+人工标注的小数据集,跑一遍Recall@5再决定。
  3. rerank值得上,但要注意延迟预算。如果你的query量不大(<10 QPS),完全可以用cross-encoder做精排。如果QPS很高,建议把粗排Top-50缩减到Top-20,能省一半rerank时间,召回率损失不到1%。

最后提醒一句:向量库索引的efM参数对召回率影响很大,不要用默认值。我们实测M=16时Recall@5只有77%,调到M=32后直接到81%,代价是索引构建时间从3分钟变成11分钟,但这是一次性的,值得。