一、问题背景:一个"能用但不好用"的RAG
去年底我接手了公司内部知识库问答系统的优化工作。系统基于RAG(检索增强生成)架构,服务约2000篇技术文档和产品手册,日均查询量3000+。
上线三个月后,用户反馈集中在两类问题:
- 答非所问:问"如何配置OAuth2.0的token过期时间",检索回来的却是"OAuth2.0简介"和"用户登录流程"这类泛泛的文档。
- 答案不完整:明明文档里有完整步骤,模型只答出一半,或者把两个版本的配置混在一起。
我拉了一周的badcase,做了一次离线评估,结果不太好看:
| 指标 | 数值 |
|---|---|
| Top-5 召回率 | 0.71 |
| Top-1 命中率 | 0.48 |
| 答案准确率(人工评估200条) | 62% |
| P99 延迟 | 1.8s |
这个水平属于"demo能跑,生产挨骂"。于是开始了为期三周的优化。
二、环境与版本
先交代一下技术栈,方便对照:
- Python 3.10.13
- LangChain 0.1.16
- 向量库:Milvus 2.3.4(standalone)
- LLM:GPT-4o-mini(当时为了成本)
- 初始Embedding:OpenAI text-embedding-ada-002(1536维)
- 初始chunk:RecursiveCharacterTextSplitter,chunk_size=512,overlap=50
- 检索:纯向量检索,Top-5
评估集是我自己标注的200条query-doc对,覆盖配置、故障排查、API使用三类场景。每次改动都跑同一套评估,保证可比性。
三、方案设计:分三步走
我没有一次性全改,而是拆成三个阶段,每阶段只动一个变量,方便定位收益来源:
阶段一:Chunk策略调整
- 从固定512改为按文档结构(Markdown标题)+ 语义分块
- chunk_size 调整为 800,overlap 提升到 150
- 对代码块、表格做特殊处理,不切断
阶段二:Embedding模型切换
- 从 text-embedding-ada-002 切到 BAAI/bge-m3
- 维度从1536变为1024
- 顺便把Milvus的索引从IVF_FLAT换成HNSW
阶段三:引入Rerank
- 检索阶段先召回Top-20
- 用 bge-reranker-v2-m3 重排,取Top-5送入LLM
四、核心实现
4.1 语义分块
固定长度分块最大的问题是会把一个完整的配置说明从中间切断。我改成了两级分块:先按Markdown标题切,再按语义切。
from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
from langchain_experimental.text_splitter import SemanticChunker
from langchain_community.embeddings import HuggingFaceBgeEmbeddings
# 第一级:按标题切
headers_to_split_on = [
("#", "h1"),
("##", "h2"),
("###", "h3"),
]
md_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on,
strip_headers=False,
)
# 第二级:语义切分,用bge-m3做embedding
embed_model = HuggingFaceBgeEmbeddings(
model_name="BAAI/bge-m3",
model_kwargs={"device": "cuda"},
encode_kwargs={"normalize_embeddings": True},
)
semantic_splitter = SemanticChunker(
embeddings=embed_model,
breakpoint_threshold_type="percentile",
breakpoint_threshold_amount=92, # 阈值调到92,避免切太碎
buffer_size=1,
)
def chunk_document(raw_text: str):
md_chunks = md_splitter.split_text(raw_text)
final_chunks = []
for c in md_chunks:
# 短块直接保留
if len(c.page_content) ".join([v for k, v in c.metadata.items() if v])
final_chunks.append({
"content": f"[{header_path}]\n{sc}",
"metadata": c.metadata,
})
return final_chunks
这里有个细节:breakpoint_threshold_amount 一开始我设的默认值95,切出来的chunk太碎,很多只有一两句话。调到92之后平均chunk长度从原来的420字符涨到约780字符,更符合"一个知识点一块"的直觉。
另外我把标题路径拼进了chunk内容里,这样embedding能感知到层级信息,对"XX模块下的YY配置"这类query效果明显。
4.2 切换Embedding与HNSW索引
bge-m3 是去年比较火的多语言embedding,1024维,支持长文本(8192 token),而且对中文友好。切换后重建了Milvus collection:
from pymilvus import CollectionSchema, FieldSchema, DataType, Collection, connections
connections.connect(host="localhost", port="19530")
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
FieldSchema(name="content", dtype=DataType.VARCHAR, max_length=8192),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1024),
FieldSchema(name="doc_id", dtype=DataType.VARCHAR, max_length=128),
]
schema = CollectionSchema(fields, description="kb_v2")
collection = Collection(name="kb_v2", schema=schema)
# HNSW索引,M=16, efConstruction=200
index_params = {
"metric_type": "COSINE",
"index_type": "HNSW",
"params": {"M": 16, "efConstruction": 200},
}
collection.create_index(field_name="embedding", index_params=index_params)
collection.load()
检索时 ef 设成64(搜索时参数),在2000条数据上基本感觉不到索引带来的延迟差异,但召回稳定性比IVF_FLAT好。
4.3 引入Rerank
这是收益最大的一步。向量检索本质是"语义相似",但query和doc的相关性判断是另一回事。Rerank用cross-encoder直接对(query, doc)对打分,精度高但慢,所以只对Top-20做重排。
from FlagEmbedding import FlagReranker
reranker = FlagReranker(
"BAAI/bge-reranker-v2-m3",
use_fp16=True, # 半精度,速度提升约40%
)
def retrieve_and_rerank(query: str, top_k: int = 5, recall_k: int = 20):
# 1. 向量召回
q_vec = embed_model.embed_query(query)
search_params = {"metric_type": "COSINE", "params": {"ef": 64}}
results = collection.search(
data=[q_vec],
anns_field="embedding",
param=search_params,
limit=recall_k,
output_fields=["content", "doc_id"],
)
candidates = [
{"content": hit.entity.get("content"), "score": hit.distance}
for hit in results[0]
]
# 2. Rerank
pairs = [[query, c["content"]] for c in candidates]
rerank_scores = reranker.compute_score(pairs, normalize=True)
for c, s in zip(candidates, rerank_scores):
c["rerank_score"] = s
candidates.sort(key=lambda x: x["rerank_score"], reverse=True)
return candidates[:top_k]
use_fp16=True 这个很关键。我一开始没开,20条重排要380ms;开了之后降到约220ms,精度几乎无损。
五、踩坑与优化
坑1:chunk overlap设太大导致重复召回
阶段一我一度把overlap设到200,结果Top-5里经常出现同一段内容的两个切片,浪费了名额。后来把overlap降到150,并且在入库时做了内容去重(用SimHash,汉明距离<3视为重复)。
坑2:bge-m3的query指令
bge系列模型建议query前加指令前缀。bge-m3虽然官方说可以不加以支持多语言,但我实测在中文query上,不加前缀的Top-1命中率比加了低约2个点。我加的是:
query = "为这个句子生成表示以用于检索相关文章:" + raw_query
坑3:Rerank把"关键词匹配但语义不相关"的排上来了
Rerank对字面匹配很敏感。有次query问"如何回滚版本",rerank把一篇讲"版本号命名规范"的排到了第一,因为它俩字面重合度高。解决办法是在rerank前做一次query改写,用LLM把query扩写成更完整的问句再检索。这一步我放在阶段三后期,收益约1.5个点。
坑4:延迟上升
三阶段全部上完后,P99从1.8s涨到2.4s。拆解一下:embedding从OpenAI API切到本地bge-m3,反而快了约150ms;HNSW比IVF_FLAT略慢但可忽略;rerank增加约220ms;query改写增加约300ms。总体增加600ms左右,用户端感知不明显(因为LLM生成本来就要1s+)。
六、效果数据
三阶段做完,同一套200条评估集的结果:
| 阶段 | Top-5召回 | Top-1命中 | 答案准确率 | P99延迟 |
|---|---|---|---|---|
| 初始 | 0.71 | 0.48 | 62% | 1.8s |
| +语义分块 | 0.78 | 0.55 | 68% | 1.85s |
| +bge-m3 | 0.83 | 0.61 | 74% | 1.75s |
| +rerank | 0.89 | 0.72 | 84% | 2.4s |
分阶段看收益:
- 分块策略:召回+7个点,主要来自"不再切断完整知识点",对故障排查类query提升最明显(+11个点)。
- Embedding切换:召回+5个点,中文语义匹配明显更好,尤其是同义词和专业术语。
- Rerank:召回+6个点,Top-1命中率提升最大(+11个点),因为重排主要解决"排序"问题而非"召回"问题。
线上灰度两周后,用户负反馈率从8.3%降到2.1%,"答非所问"类反馈下降最明显。
七、总结
这次优化下来,我的几点体会:
- Chunk策略是被低估的环节。很多人一上来就换模型,但其实分块没做好,再好的embedding也白搭。语义分块+标题路径注入,是性价比最高的一步。
- Embedding切换要看场景。ada-002在英文上不差,但中文技术文档场景下bge-m3确实更合适,而且本地部署省了API成本和网络延迟。
- Rerank是召回质量的"最后一道闸"。它不解决"召回不到"的问题,但能把"召回了但排后面"的救回来。前提是recall_k要够大,我设的20,实测再大收益递减,延迟线性增长。
- 每一步都要有评估集。没有评估集的优化就是玄学。我那200条评估集虽然不多,但足够定位问题、验证收益。
后续还打算试的方向:query改写用更小的模型(现在用GPT-4o-mini有点浪费)、混合检索(BM25+向量)、以及chunk级别的上下文压缩。等有结果再写一篇。
代码和评估脚本我整理在个人仓库里了,有需要的可以留言。