一、问题背景:一个"能跑但不好用"的RAG
三个月前接手公司内部知识库问答项目。语料是约2000篇技术文档、产品手册和FAQ,平均每篇1500字,中文为主,夹杂代码块和表格。用户是内部客服和售前,问题偏具体,比如"XX产品的退款流程要几天"、"YY接口报403怎么处理"。
第一版系统很朴素,典型的三段式:
- 文档按固定长度切分,
chunk_size=512,overlap=50 - embedding 用 OpenAI 的
text-embedding-ada-002 - 向量库用 FAISS,Top-K=5,直接拼进 prompt 给 GPT-3.5-turbo
上线一周,反馈很一致:"答非所问"、"明明文档里有却检索不到"、"回答里两个版本的信息混在一起"。
我随手抽了100条真实问题做了个离线评测,结果很难看:
| 指标 | 数值 |
|---|---|
| Recall@5 | 0.61 |
| MRR | 0.48 |
| 端到端答案准确率(人工判定) | 52% |
| P95 延迟 | 1.2s |
召回率0.61意味着近四成问题,正确文档压根没进候选集,后面LLM再强也救不回来。于是开始系统性优化。
二、环境与版本
先把环境钉死,方便复现:
Python 3.10.13
torch 2.1.2 + cu121
transformers 4.36.2
sentence-transformers 2.3.1
FlagEmbedding 1.2.10
faiss-cpu 1.7.4
langchain 0.1.0
openai 1.6.1
硬件:一台 A10 24G 的推理机,embedding 和 reranker 都跑在 GPU 上。向量库规模不大,2000篇文档切完约 1.8 万 chunk,FAISS 用 IndexFlatIP 就够,没必要上 IVF。
三、方案设计:三步走
优化的思路很明确,针对三个不同的失效环节:
- 切分环节:固定长度切分把语义切碎了,表格和代码块被拦腰截断。改成按 Markdown 标题层级 + 语义段落混合切分。
- 召回环节:
text-embedding-ada-002在中文短查询上表现一般,且走 API 有网络抖动。换成中文优化过的bge-large-zh-v1.5。 - 排序环节:向量召回是双塔模型,query 和 doc 不交互,Top-5 里经常混入语义相近但答非所问的块。引入 cross-encoder 做 rerank。
每一步都单独评测,避免"一起改完不知道是谁的功劳"。
四、核心实现
4.1 Chunk 策略改造
原来的 RecursiveCharacterTextSplitter 对中文和 Markdown 都不友好。我写了个基于标题层级的切分器,核心逻辑:优先在 ##/### 处切,单块超过 800 字再按段落二次切,表格和代码块整体保留。
import re
from typing import List
def split_by_markdown(text: str, max_len: int = 800, min_len: int = 120) -> List[str]:
# 按二级/三级标题切分,保留标题作为上下文
pattern = re.compile(r'(?=^#{2,3}\s)', re.MULTILINE)
raw_blocks = [b.strip() for b in pattern.split(text) if b.strip()]
chunks = []
for block in raw_blocks:
if len(block) = min_len:
chunks.append(block)
continue
# 超长块按空行段落聚合
paras = [p for p in block.split("\n\n") if p.strip()]
buf = ""
for p in paras:
# 代码块/表格不拆
if p.startswith("```") or p.startswith("|"):
if buf:
chunks.append(buf.strip()); buf = ""
chunks.append(p.strip())
continue
if len(buf) + len(p) + 2 = min_len]
同时在每个 chunk 前面拼上所属标题路径(如 产品A > 退款 > 时效),给 embedding 更多上下文。这一步单独做完,Recall@5 从 0.61 涨到 0.68。
4.2 Embedding 切换
从 API 换到本地 bge-large-zh-v1.5。这个模型对中文语义匹配明显更好,而且 query 侧要加指令前缀 为这个句子生成表示以用于检索相关文章:,doc 侧不加,这是官方推荐的非对称检索用法。
from sentence_transformers import SentenceTransformer
import numpy as np
import faiss
model = SentenceTransformer("BAAI/bge-large-zh-v1.5", device="cuda")
model.max_seq_length = 512
QUERY_INSTRUCTION = "为这个句子生成表示以用于检索相关文章:"
def encode_docs(texts):
return model.encode(texts, batch_size=64, normalize_embeddings=True,
show_progress_bar=True)
def encode_query(q):
return model.encode([QUERY_INSTRUCTION + q], normalize_embeddings=True)[0]
# 建索引
doc_vecs = encode_docs(all_chunks).astype("float32")
index = faiss.IndexFlatIP(doc_vecs.shape[1])
index.add(doc_vecs)
def search(query, top_k=20):
qv = encode_query(query).astype("float32").reshape(1, -1)
scores, idx = index.search(qv, top_k)
return [(all_chunks[i], float(s)) for s, i in zip(scores[0], idx[0])]
这里检索 Top-K 我特意开到 20,为后面的 rerank 留候选空间。这一步单独做完,Recall@5 到 0.74,Recall@20 已经到 0.88。
4.3 引入 Rerank
向量召回的双塔结构决定了 query 和 doc 没有交互,Top-5 里常有"看起来像"的噪声。用 bge-reranker-large 做 cross-encoder 重排,把 Top-20 精排后取 Top-5。
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-large", use_fp16=True)
def rerank(query, candidates, top_n=5):
pairs = [[query, doc] for doc, _ in candidates]
scores = reranker.compute_score(pairs, normalize=True)
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return [(doc, score) for (doc, _), score in ranked[:top_n]]
def rag_retrieve(query):
cands = search(query, top_k=20)
return rerank(query, cands, top_n=5)
这一步是收益最大的。Recall@5 从 0.74 直接跳到 0.89,MRR 从 0.55 到 0.81。
五、踩坑与优化
坑1:bge 指令前缀搞反。 一开始 query 和 doc 都加了前缀,效果反而掉了3个点。官方文档明确写了这是非对称检索,只加在 query 侧。
坑2:reranker 拖慢延迟。 Top-20 全量重排,P95 从 1.2s 涨到 3.4s。后来做了两件事:一是 use_fp16=True,二是候选从 20 降到 15(Recall@15 已经 0.86,够用),P95 回落到 2.1s。
坑3:chunk 太碎导致上下文缺失。 min_len 设成 120 之前,产生了大量一句话的碎片,rerank 时分数虚高但没有信息量。加上标题路径前缀后缓解很多。
坑4:FAISS 归一化。 用 IndexFlatIP 必须对向量做 L2 归一化,等价于余弦相似度。忘了归一化那版,检索结果完全是乱的,排查了半天。
六、效果数据
三轮迭代的数据汇总(同一份100题评测集):
| 阶段 | Recall@5 | MRR | 答案准确率 | P95延迟 |
|---|---|---|---|---|
| 基线(512切分 + ada-002) | 0.61 | 0.48 | 52% | 1.2s |
| + 语义/标题切分 | 0.68 | 0.53 | 58% | 1.2s |
| + bge-large-zh-v1.5 | 0.74 | 0.55 | 65% | 1.5s |
| + bge-reranker-large | 0.89 | 0.81 | 81% | 2.1s |
延迟从1.2s到2.1s,换来准确率29个点的提升,对内部工具来说完全值得。如果对延迟敏感,可以把 reranker 换成 bge-reranker-base,实测准确率只掉2个点,延迟能压到1.6s。
七、总结
这轮优化没有用任何花哨的技巧,就是老老实实把三个环节分别做好:切分贴合文档结构、embedding 用中文专优模型、排序用 cross-encoder 精排。三者的收益是叠加的,其中 rerank 贡献最大。
几点经验:
- 先做评测集再优化。100条真实问题,人工标注正确 chunk,是所有决策的依据。没有它,改动全是拍脑袋。
- 每一步单独评测。三个环节耦合严重,一起改根本不知道谁有效。
- Recall@K 和最终准确率要分开看。召回是天花板,rerank 是在天花板下做精挑。
- 别迷信大模型 API。本地 bge 系列在中文检索上不比 ada-002 差,还省了网络延迟和费用。
下一步打算试试 query 改写(HyDE)和混合检索(BM25 + 向量),看能不能把 Recall@5 推到 0.92 以上。有兴趣的可以一起交流。