一、问题背景:一个"看起来能用"的RAG系统
去年底给公司内部客服团队做了一个知识库问答系统,数据源是大概1.2万篇产品文档、FAQ和工单记录,总共约1800万字。技术栈很简单:LangChain + Milvus + OpenAI兼容接口,向量模型用的 text-embedding-ada-002,切块策略是教科书式的 RecursiveCharacterTextSplitter,chunk_size=512、chunk_overlap=50。
上线两周后,运营同学给了一份人工抽检报告:200条真实问题里,只有126条回答正确,准确率63%。更尴尬的是,延迟还很高,P95首token要2.3s。问题主要集中在三类:
- 答案被切碎了:一个完整的产品参数表被从中间切开,模型只拿到半张表。
- 召回了相似但无关的文档:问"退款到账时间",召回了"退款申请流程"和"退货地址填写",就是不召回那张真正的到账时效表。
- 多语言查询翻车:有几条英文提问(海外同事),
ada-002对中英混合场景表现明显不如中文。
这篇文章就是把这套系统从63%拉到91%的完整记录。不吹不黑,每一步都有数据。
二、环境与版本
先把基线环境列清楚,方便对照:
| 组件 | 版本/配置 |
|---|---|
| Python | 3.11.6 |
| LangChain | 0.1.20 |
| Milvus | 2.4.1 (standalone) |
| Embedding | text-embedding-ada-002 (1536维) |
| LLM | Qwen2.5-72B-Instruct (vLLM 0.5.1部署) |
| 切块 | RecursiveCharacterTextSplitter, chunk_size=512, overlap=50 |
| 检索 | Milvus HNSW, top_k=5, metric=IP |
| 重排 | 无 |
评估集:200条人工标注QA,覆盖产品参数、流程、政策、故障排查四类。评估指标用 Top-5 召回率(正确答案所在chunk是否在前5)和端到端准确率(人工判定)。
基线数据:
- Top-5召回率:0.71
- 端到端准确率:0.63
- P95首token延迟:2.3s
三、方案设计:三步走
优化顺序很关键。我一开始想直接上rerank,但试了一下发现召回阶段压根没把正确chunk捞出来,重排再强也没用。所以顺序定为:
- 切块策略重构 —— 让正确的知识能被完整地切出来。
- Embedding模型切换 —— 换成检索能力更强的
bge-m3(本地部署,支持中英混合和长文本)。 - 引入Rerank —— 用
bge-reranker-v2-m3对Top-50做精排,输出Top-5。
这个顺序的好处是每一步的效果可以独立观测,方便归因。
四、核心实现
4.1 切块策略:从固定长度到语义+递归混合
原来的 RecursiveCharacterTextSplitter 只按分隔符递归,遇到Markdown表格就废了。我换成两级策略:
- 第一级用
MarkdownHeaderTextSplitter按标题切,保留结构; - 第二级对超长section做语义切块,用
SemanticChunker(基于embedding相似度断句),阈值设breakpoint_threshold_type="percentile",breakpoint_threshold_amount=95; - 对表格类内容单独走一个"整表保留"分支,不切。
from langchain_text_splitters import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
from langchain_experimental.text_splitter import SemanticChunker
from langchain_community.embeddings import HuggingFaceBgeEmbeddings
# 本地bge-m3,归一化后用于语义切块
embed_model = HuggingFaceBgeEmbeddings(
model_name="/models/bge-m3",
model_kwargs={"device": "cuda:0"},
encode_kwargs={"normalize_embeddings": True},
)
headers_to_split_on = [
("#", "h1"), ("##", "h2"), ("###", "h3"),
]
md_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on,
strip_headers=False,
)
semantic_splitter = SemanticChunker(
embed_model,
breakpoint_threshold_type="percentile",
breakpoint_threshold_amount=95,
)
fallback_splitter = RecursiveCharacterTextSplitter(
chunk_size=800, chunk_overlap=120,
separators=["\n\n", "\n", "。", ";", " ", ""],
)
def split_document(text: str):
chunks = []
for section in md_splitter.split_text(text):
# 表格整块保留
if "|" in section.page_content and section.page_content.count("|") > 6:
chunks.append(section)
continue
# 短section直接留
if len(section.page_content) <= 800:
chunks.append(section)
continue
# 长section走语义切块,失败则递归兜底
try:
sub_chunks = semantic_splitter.split_text(section.page_content)
except Exception:
sub_chunks = fallback_splitter.split_text(section.page_content)
for sc in sub_chunks:
sc = sc.strip()
if len(sc) < 30: # 丢掉噪音碎片
continue
chunks.append(sc)
return chunks
这一步改动后,chunk平均长度从512涨到约740,chunk总数从4.1万降到2.6万。别小看这个,检索池小了,噪声就少了。
4.2 Embedding切换:bge-m3本地部署
ada-002 有两个问题:中英混合查询表现一般,且走API有网络抖动,延迟不稳定。换成 bge-m3,1024维,本地A10一张卡跑,吞吐约1200 chunk/s(batch=64)。
from pymilvus import MilvusClient, DataType
from FlagEmbedding import BGEM3FlagModel
model = BGEM3FlagModel("/models/bge-m3", use_fp16=True)
def encode(texts, batch_size=64):
out = model.encode(
texts,
batch_size=batch_size,
max_length=1024,
return_dense=True,
return_sparse=False,
return_colbert_vecs=False,
)
return out["dense_vecs"].tolist()
client = MilvusClient(uri="http://milvus:19530")
# 建集合,1024维,HNSW,参数调优
schema = MilvusClient.create_schema(auto_id=False, enable_dynamic_field=True)
schema.add_field("id", DataType.VARCHAR, is_primary=True, max_length=64)
schema.add_field("vector", DataType.FLOAT_VECTOR, dim=1024)
schema.add_field("text", DataType.VARCHAR, max_length=8192)
schema.add_field("doc_id", DataType.VARCHAR, max_length=64)
index_params = client.prepare_index_params()
index_params.add_index(
field_name="vector",
index_type="HNSW",
metric_type="IP",
params={"M": 32, "efConstruction": 360},
)
client.create_collection("kb_v2", schema=schema, index_params=index_params)
HNSW的 M 从16提到32,efConstruction 从200提到360,检索时 ef=128。索引变大但召回明显更稳。
4.3 Rerank:bge-reranker-v2-m3
召回阶段 top_k=50,然后用reranker精排取前5。模型是 bge-reranker-v2-m3,同样是本地部署,单次50条打分约80ms(batch=50,A10)。
from FlagEmbedding import FlagReranker
reranker = FlagReranker("/models/bge-reranker-v2-m3", use_fp16=True)
def retrieve_and_rerank(query: str, top_k: int = 50, final_k: int = 5):
q_vec = encode([query])[0]
res = client.search(
collection_name="kb_v2",
data=[q_vec],
limit=top_k,
output_fields=["text", "doc_id"],
search_params={"metric_type": "IP", "params": {"ef": 128}},
)[0]
pairs = [[query, hit["entity"]["text"]] for hit in res]
scores = reranker.compute_score(pairs, normalize=True)
ranked = sorted(zip(res, scores), key=lambda x: x[1], reverse=True)
return [
{"text": h["entity"]["text"], "score": float(s), "doc_id": h["entity"]["doc_id"]}
for h, s in ranked[:final_k]
]
Prompt侧也顺手改了:把召回结果按score排序后拼进context,并加了"若context不含答案请明确说不知道"的约束,减少幻觉。
五、踩坑与优化
坑1:语义切块把短句切没了。 SemanticChunker 在FAQ这种短句密集的文档上会把"Q: xxx A: xxx"拆开。解决:对FAQ文档识别 Q:/A: 模式,按QA对切,不走语义切块。
坑2:bge-m3的 max_length。 默认8192,但实际文档chunk最长才1000多token,设1024能省一半显存,吞吐翻倍。别偷懒用默认值。
坑3:reranker batch太大反而慢。 一次跑50条,A10上显存吃满,反而触发swap。改成batch=16,总耗时从180ms降到80ms。
坑4:Milvus的 ef 参数。 ef 从64提到128,召回涨了3个点,延迟只多2ms。但提到256几乎没收益,别盲目加。
坑5:重排后分数阈值。 reranker分数低于0.3的基本是噪声,直接丢弃,让LLM说"没找到"。这一步把误答率降了不少。
六、效果数据
用同一份200条评估集,逐步迭代:
| 阶段 | Top-5召回率 | 端到端准确率 | P95首token延迟 |
|---|---|---|---|
| 基线 | 0.71 | 0.63 | 2.3s |
| + 切块重构 | 0.78 | 0.70 | 2.1s |
| + bge-m3 | 0.86 | 0.79 | 1.6s |
| + rerank | 0.94 | 0.91 | 1.1s |
几个值得说的点:
- 切块重构单独贡献了7个点召回,是最"便宜"的优化,没加任何模型。
- bge-m3替换后延迟反而降了,因为本地推理没有API网络往返,且1024维比1536维检索更快。
- rerank把召回从0.86推到0.94,同时因为top_k从5变成50再精排,LLM拿到的context质量高了,幻觉明显减少。
- 端到端准确率0.91里,剩下9%的失败主要是问题本身有歧义,或者知识库里确实没有对应内容。
成本方面:bge-m3 + reranker两张模型常驻A10,显存占用约14GB,单卡能扛。相比之前全走OpenAI API,月成本反而降了约40%。
七、总结
这次优化最大的体会是:RAG的效果瓶颈往往不在LLM,而在检索链路的前两步。切块决定了"知识有没有被正确表示",embedding决定了"能不能被找到",rerank决定了"找到的准不准"。三步缺一不可,但顺序上先解决切块,性价比最高。
几个可以直接抄走的建议:
- 别用固定长度切块,Markdown/表格/FAQ要分别处理。
- 中文场景优先试
bge-m3,本地部署延迟和成本都更可控。 - rerank必上,召回top_k开大(50左右),精排取5,效果和延迟能兼得。
- 一定要有评估集,哪怕是200条人工标注,否则你根本不知道改动是涨还是跌。
后续还想试的方向:混合检索(dense + sparse,bge-m3本身支持)、query改写、以及把reranker换成更小的 bge-reranker-base 看能不能保住效果再降延迟。有新数据再更。