一、问题背景:检索没坏,但答案就是不对
我们做的是一个内部客服知识库问答系统,语料大约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。
三、方案设计:三步走
整体思路是分层优化,不要一次全改,否则出问题不知道是哪一步的锅:
- 分块策略:从固定512字符 → 按Markdown标题层级递归分块,chunk尺寸动态调整
- 嵌入模型:text-embedding-ada-002 → bge-large-zh-v1.5(中文语料,本地模型免API费用和网络抖动)
- 重排:先召回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个点(因为模型倾向于引用有来源的内容,幻觉减少)。
七、总结
几个我认为最有价值的结论:
- 分块是RAG的地基。固定长度切分在结构化文档上是灾难。递归分块 + header_path拼接,单步涨7个点,成本几乎为零。
- 中文场景bge-large-zh-v1.5 > ada-002。不光是省钱,语义区分度确实更好,尤其对专业术语。但query侧instruction前缀别忘。
- rerank是性价比最高的精度提升手段。涨9个点Recall@5,代价是0.5s延迟。只要你的场景能接受2-3s延迟,闭眼上。
- embedding和rerank的输入处理可以不一样。embedding带标题上下文,rerank用纯正文,这个反直觉但实测有效。
- HNSW的ef_search容易被忽略。召回数量调到20以上时,ef_search不跟着调,召回率上不去。
下一步打算试两件事:一是用bge-m3做多向量混合检索(dense+sparse),二是把reranker蒸馏到base版本压延迟。有结果再写。
代码都跑在内部仓库,上面贴的是核心片段,去掉了业务耦合部分,直接抄能跑。有问题的评论区聊。