一、问题背景:检索没坏,但答案就是不对

我们做的是一个内部客服知识库问答系统,语料大约1.2万篇文档,覆盖产品手册、FAQ、售后政策三大类,平均每篇800-1500字。技术栈是最朴素的RAG:LangChain 0.1.0 + Chroma 0.4.24 + OpenAI text-embedding-ada-002 + GPT-3.5-turbo。

上线两周后做了一次人工抽检,随机抽200条真实用户提问,逐条核对答案:

  • 完全正确:142条,71.0%
  • 检索到正确文档但答案跑偏:38条
  • 检索压根没召回正确文档:20条

有意思的是,63%的badcase属于第二类:正确文档其实在top10里,只是排在top5之外,被塞进prompt的上下文是错的。这说明问题不在"能不能召回",而在"排序质量"和"分块粒度"。

举一个真实case。用户问:"企业版到期后数据保留多久?"

正确文档在《企业版服务协议》的"第4.2节 数据保留"里,原文一句话:"企业版服务到期后,客户数据将保留90个自然日,期间可随时导出。"

但我们的512字符固定切分,把这句话和前面"第4.1节 计费周期"的内容切在了同一个chunk里,chunk整体主题是计费,embedding向量被计费语义主导,相似度排到了第7位。前面6个chunk全是各种"企业版价格""续费优惠"的文档。

这就是典型的"chunk语义污染"。

二、环境与版本

先交代下环境,避免复现时踩版本坑:

Python 3.11.6
langchain==0.1.0
langchain-community==0.0.13
chromadb==0.4.24
sentence-transformers==2.3.1
FlagEmbedding==1.2.10
torch==2.1.2+cu121
openai==1.10.0(仅用于生成,1.x SDK和0.x差异很大)

硬件:单卡A10 24G,部署在K8s里,Pod规格8C32G。

嵌入模型最初用的 OpenAI text-embedding-ada-002,走API,1536维。后来切到本地 bge-large-zh-v1.5,1024维。重排用 bge-reranker-large。

三、方案设计:三步走

整体思路是分层优化,不要一次全改,否则出问题不知道是哪一步的锅:

  1. 分块策略:从固定512字符 → 按Markdown标题层级递归分块,chunk尺寸动态调整
  2. 嵌入模型:text-embedding-ada-002 → bge-large-zh-v1.5(中文语料,本地模型免API费用和网络抖动)
  3. 重排:先召回top20,再用bge-reranker-large精排取top5

每一步单独测Recall@5和人工准确率,记录增量。

评估集:从真实日志里挖了300条问答对,人工标注了对应的正确文档ID(golden doc)。评估指标用Recall@5(正确文档是否在top5里)和MRR。

四、核心实现

4.1 分块策略调整

原来的固定切分代码(反面教材):

from langchain.text_splitter import CharacterTextSplitter

splitter = CharacterTextSplitter(
    separator="\n",
    chunk_size=512,
    chunk_overlap=50,
)
# 结果:一个chunk经常横跨多个小节,语义混杂

改成基于Markdown标题层级的递归分块。我们的文档大部分是Markdown,有明确的 # ## ### 层级。核心思路:优先在标题处切,标题内如果太长再按段落切,段落还长才按句子切。

import re
from typing import List

def split_by_markdown(text: str,
                       max_chunk: int = 800,
                       min_chunk: int = 150) -> List[dict]:
    """
    按 Markdown 标题层级递归分块。
    返回 [{content, header_path}],header_path 用于后续拼进 embedding 文本。
    """
    lines = text.split("\n")
    # 先按标题切出 section
    sections = []
    cur_header = []
    cur_lines = []

    header_re = re.compile(r"^(#{1,4})\s+(.*)$")

    for line in lines:
        m = header_re.match(line)
        if m:
            if cur_lines:
                sections.append({
                    "header": " > ".join(cur_header) if cur_header else "",
                    "body": "\n".join(cur_lines).strip()
                })
            level = len(m.group(1))
            title = m.group(2).strip()
            cur_header = cur_header[:level-1] + [title]
            cur_lines = []
        else:
            cur_lines.append(line)
    if cur_lines:
        sections.append({
            "header": " > ".join(cur_header) if cur_header else "",
            "body": "\n".join(cur_lines).strip()
        })

    # 再对每个 section 内部做二级切分
    chunks = []
    for sec in sections:
        body = sec["body"]
        header = sec["header"]
        if not body:
            continue
        if len(body) = min_chunk:
                        chunks.append({"content": buf, "header_path": header})
                    buf = p
            if buf and len(buf) >= min_chunk:
                chunks.append({"content": buf, "header_path": header})
    return chunks

关键点:把header_path拼进embedding文本。因为一个chunk的正文可能只有"保留90个自然日",脱离标题根本不知道在说什么。拼接格式:

def build_embed_text(chunk: dict) -> str:
    hp = chunk.get("header_path", "")
    content = chunk["content"]
    if hp:
        return f"【{hp}\n{content}"
    return content

这一步单独测的效果:Recall@5从0.71涨到0.78,MRR从0.52到0.61。涨幅超出预期,说明chunk语义完整性比什么都重要。

4.2 嵌入模型切换

从ada-002切到bge-large-zh-v1.5。原因有两个:一是中文语料ada-002的中文语义区分度一般,二是API调用有网络抖动和成本。

from sentence_transformers import SentenceTransformer
import torch

class BGEEmbedder:
    def __init__(self,
                 model_path: str = "BAAI/bge-large-zh-v1.5",
                 device: str = "cuda",
                 batch_size: int = 32,
                 max_length: int = 512):
        self.model = SentenceTransformer(model_path, device=device)
        self.model.max_seq_length = max_length
        self.batch_size = batch_size

    @torch.no_grad()
    def encode(self, texts, is_query: bool = False):
        # bge 系列官方建议:查询侧加 instruction 前缀,文档侧不加
        if is_query:
            texts = [f"为这个句子生成表示以用于检索相关文章:{t}" for t in texts]
        emb = self.model.encode(
            texts,
            batch_size=self.batch_size,
            normalize_embeddings=True,  # 用内积等价于余弦
            show_progress_bar=False,
        )
        return emb

注意query侧要加instruction前缀,这是bge系列的官方推荐。我们一开始没加,Recall@5只有0.74,加了之后0.79。文档侧不加前缀。

配置上,max_length设512(bge-large支持到512),normalize_embeddings=True后用内积检索。

Chroma的collection创建时指定距离度量:

collection = client.create_collection(
    name="kb_v2",
    metadata={"hnsw:space": "ip"}  # 内积
)

4.3 引入Rerank

召回阶段用bge-large-zh-v1.5取top20,然后用bge-reranker-large精排取top5。

from FlagEmbedding import FlagReranker

class Reranker:
    def __init__(self,
                 model_path: str = "BAAI/bge-reranker-large",
                 use_fp16: bool = True,
                 max_length: int = 512):
        self.reranker = FlagReranker(model_path, use_fp16=use_fp16)
        self.max_length = max_length

    def rerank(self, query: str, docs: list, top_k: int = 5):
        """
        docs: [{"content":..., "header_path":..., "score":...}, ...]
        """
        pairs = [[query, d["content"]] for d in docs]
        scores = self.reranker.compute_score(
            pairs,
            normalize=True,
            max_length=self.max_length,
        )
        for d, s in zip(docs, scores):
            d["rerank_score"] = float(s)
        docs.sort(key=lambda x: x["rerank_score"], reverse=True)
        return docs[:top_k]

这里有个关键点:rerank的输入应该用纯content还是带header_path的文本?我们实测发现,rerank阶段用带header_path的文本(即【标题】\n正文)效果反而比纯content差0.5个点。原因是reranker本身是query-doc相关性判断,加了标题反而引入噪声。所以embedding时带header,rerank时用纯content。这个细节值得记一下。

五、踩坑与优化

坑1:bge-reranker-large显存占用。

FP16下模型权重约1.3G,但batch推理时如果一次把20个pair全塞进去,max_length 512,显存会飙到6G+。我们一开始batch没控制,直接OOM。后来改成batch_size=8分片推理。

坑2:rerank延迟。

top20重排,单次P95延迟约380ms(A10,FP16,batch8)。这个延迟在客服场景可以接受,但如果是C端高并发就得上更小的reranker(如bge-reranker-base)或蒸馏。

坑3:chunk太小导致召回碎片化。

一开始min_chunk设了100,有些FAQ答案就一句话,切出来一堆碎片,rerank时互相竞争。后来min_chunk提到150,且对短FAQ做特殊处理:如果某section整体小于150且父section有内容,合并上去。

坑4:Chroma的HNSW参数没调。

默认ef_construction=100,ef_search=10。top20召回时ef_search太小会漏召。调到ef_search=64后Recall@20从0.86到0.91。

六、效果数据

评估集300条,各阶段对比:

阶段 Recall@5 Recall@20 MRR 人工准确率 P95延迟
基线(512固定切分 + ada-002) 0.71 0.82 0.52 71.0% 1.8s
+ 递归分块(带header_path) 0.78 0.85 0.61 76.5% 1.9s
+ bge-large-zh-v1.5 0.79 0.86 0.63 78.0% 2.0s
+ HNSW ef_search=64 0.80 0.91 0.65 79.0% 2.1s
+ bge-reranker-large 0.89 0.91 0.78 88.5% 2.6s

最终Recall@5从0.71到0.89(+18个点),人工准确率71%到88.5%(+17.5个点)。P95延迟涨了0.8s,主要来自rerank。

生成侧prompt也做了小调整:把top5的chunk按rerank_score排序,并在每个chunk前标注来源标题,让GPT-3.5能引用来源。这个改动人肉评估准确率又加了约2个点(因为模型倾向于引用有来源的内容,幻觉减少)。

七、总结

几个我认为最有价值的结论:

  1. 分块是RAG的地基。固定长度切分在结构化文档上是灾难。递归分块 + header_path拼接,单步涨7个点,成本几乎为零。
  2. 中文场景bge-large-zh-v1.5 > ada-002。不光是省钱,语义区分度确实更好,尤其对专业术语。但query侧instruction前缀别忘。
  3. rerank是性价比最高的精度提升手段。涨9个点Recall@5,代价是0.5s延迟。只要你的场景能接受2-3s延迟,闭眼上。
  4. embedding和rerank的输入处理可以不一样。embedding带标题上下文,rerank用纯正文,这个反直觉但实测有效。
  5. HNSW的ef_search容易被忽略。召回数量调到20以上时,ef_search不跟着调,召回率上不去。

下一步打算试两件事:一是用bge-m3做多向量混合检索(dense+sparse),二是把reranker蒸馏到base版本压延迟。有结果再写。

代码都跑在内部仓库,上面贴的是核心片段,去掉了业务耦合部分,直接抄能跑。有问题的评论区聊。