一、为什么啃 RAG
之前面试时我发现一个尴尬的事实:会调 ChatGPT API 的人太多了,但能把自己做过的项目讲出"实验---对比---结论"的人很少。面试官最爱问的一句话是:"你这个准确率是怎么来的?提升是靠感觉还是靠实验?"
为了能答上这个问题,我花了两个月做了一个 RAG 项目:给一个公开的企业政策/FAQ 文档集搭检索问答系统。项目从最初的 62% 准确率,一步步调到了 91%。没有用付费 API,全部基于本地开源模型(我的笔记本 16G 内存,CPU 跑,慢但能跑)。这篇文章是我完整的调优记录,每一个百分点背后都有一张实验表和一段代码,希望能帮到同样在准备 AI 项目的人。
二、没有评测,调优就是玄学
动手调优前,我做的第一件事不是优化,而是造评测集。原因很简单:不量化,你根本不知道改动是变好还是变坏。
数据集:我从公开渠道整理了 286 篇中文企业文档(规章制度、产品手册、FAQ),清洗后约 40 万字。
评测集:我采用"检索命中 + 端到端回答"双指标,而不是只看一个:
- 检索指标:对 100 道测试问题,检查正确答案所在文档是否出现在 Top-5 召回里(命中率);
- 端到端指标:把召回内容拼给生成模型,人工(我自己+两个同学)对照标准答案打分------回答覆盖了标准答案要点记 1 分,否则 0 分。100 题的总分就是"准确率"。
python
def evaluate(retriever, questions, answers, top_k=5):
hit = 0 # Top-5 命中数
acc = 0 # 端到端答案准确数
for q, std in zip(questions, answers):
docs = retriever.search(q, top_k)
if any(std["doc_id"] in d["doc_id"] for d in docs):
hit += 1
reply = generate(docs, q)
acc += human_judge(reply, std["answer"]) # 对照要点打分
return hit / len(questions) * 100, acc / len(questions) * 100
这 100 道题我花了一个周末才标完,但后来每次改动都能立刻看到数字变化------这一步是整个调优能成立的地基。
三、第 1 版:最朴素的 RAG,62%
第一个版本没有任何技巧:固定 500 字切分、单路向量检索。当时的代码(也代表了很多入门教程的默认做法):
python
import jieba
from FlagEmbedding import FlagModel
embedder = FlagModel('BAAI/bge-small-zh-v1.5',
query_instruction_for_retrieval="为这个句子生成表示以用于检索相关文章:")
# 1. 固定按字符切分,每块 500 字
def naive_split(text, chunk_size=500):
return [text[i:i+chunk_size] for i in range(0, len(text), chunk_size)]
# 2. 全部文档向量化(存到内存列表)
chunks = []
for doc in docs:
for i, c in enumerate(naive_split(doc["text"])):
chunks.append({"doc_id": doc["id"], "text": c, "vec": embedder.encode(c)})
# 3. 余弦相似度检索(手写,不装 faiss 也能跑)
import numpy as np
def naive_search(query, top_k=5):
qv = embedder.encode_queries(query)
scores = [(c, float(c["vec"] @ qv)) for c in chunks]
scores.sort(key=lambda x: -x[1])
return [c for c, _ in scores[:top_k]]
跑完评测,结果很扎眼:Top-5 命中率 74%,端到端准确率 62%。我逐条看了 38 道答错的题,失败原因非常集中:
- 答案被拦腰切断(约 15 题):比如"离职赔偿金计算规则"恰好被 500 字边界切成两半,检索只能召回一半;
- 关键词对不上(约 12 题):用户问"加班费怎么算",文档里写的是"加班工资的支付标准",向量检索把这两句话当成了不同的东西;
- 命中了但位置靠后(约 7 题):正确答案在第 6-8 位,被硬生生挤出了 Top-5;
- 剩下的是生成本身的问题。
看到这个归因,我心里就有数了:62% 不是模型不行,是检索的输入(分块)和检索的方式(单路向量)不行。
四、第一次调优:分块策略,62% → 71%
第一个动手点是分块。我做了三组对比实验:
| 分块方式 | 说明 | Top-5 命中 | 准确率 |
|---|---|---|---|
| 固定 500 字 | 按字符硬切 | 74% | 62% |
| 固定 500 字 + 重叠 100 | 只加重叠 | 77% | 65% |
| 按段落切 + 句子聚合 | 先按文档结构切,再按句号聚合到 300~500 字 | 81% | 68% |
| 语义切分 | 按标题/段落层级组织,保留结构信息 | 84% | 71% |
结论很明确:固定字符切分切断了语义,重叠只是缓解,不是解药。正确做法是尊重文档结构------按段落切,段落太长再按句子(句号/问号/分号)聚合,并保留"这段属于哪个章节"的结构信息:
python
def structure_aware_split(text, max_chars=450):
"""按段落切分;长段落再按中文句号聚合到 max_chars 以内"""
blocks = []
for para in text.split("\n\n"): # 先按空行(段落)切
if len(para) <= max_chars:
blocks.append(para.strip())
continue
# 长段落:按句子切,贪心聚合,句末切分不切断语义
sentences = [s + "。" for s in para.split("。") if s.strip()]
buf = ""
for s in sentences:
if len(buf) + len(s) <= max_chars:
buf += s
else:
if buf: blocks.append(buf)
buf = s
if buf: blocks.append(buf)
return blocks
同时我把检索时也带上块的上文标题(比如"第三章 薪酬福利 > 加班工资"),embedding 效果明显更好。这一轮结束:命中率 84%,准确率 71%。
这一轮我学到的面试点:分块不是"选个数字",而是"让每块尽量是语义完整的单元";评测集能让你区分"分块问题"和"检索问题",而不是盲目调参。
五、第二次调优:混合检索,71% → 79%
71% 之后瓶颈换成了"关键词对不上"。bge 这类向量模型擅长语义,但对精确词(产品型号、政策条款号、人名)反而敏感度不够 ;而 BM25 这种传统关键词检索恰好相反------它不懂语义,但词必须原样出现就一定能命中。两者是互补关系,所以下一步是混合检索。
我的实现分三步:jieba 分词后用 BM25 打分,向量检索打分,再用 RRF(Reciprocal Rank Fusion) 把两个排名融合------不需要调权重,直接按排名融合,省去了调参的麻烦:
python
from rank_bm25 import BM25Okapi
tokenized = [list(jieba.cut(c["text"])) for c in chunks]
bm25 = BM25Okapi(tokenized)
def hybrid_search(query, top_k=5, k=60):
qv = embedder.encode_queries(query)
# 两路召回各自的排名
vec_rank = sorted(range(len(chunks)), key=lambda i: -(chunks[i]["vec"] @ qv))
bm_rank = sorted(range(len(chunks)), key=lambda i: bm25.get_scores(list(jieba.cut(query)))[i], reverse=True)
# RRF 融合:score = Σ 1/(k + rank)
rrf = {}
for r, idx in enumerate(vec_rank):
rrf[idx] = rrf.get(idx, 0) + 1 / (k + r + 1)
for r, idx in enumerate(bm_rank):
rrf[idx] = rrf.get(idx, 0) + 1 / (k + r + 1)
top = sorted(rrf, key=rrf.get, reverse=True)[:top_k]
return [chunks[i] for i in top]
跑完评测:命中率 88%,准确率 79%。之前"关键词对不上"的 12 题解决了 9 题------条款号、产品型号这类精确词,BM25 一抓一个准。
这一轮的面试点:向量检索不是万能的,混合检索的价值在于"语义召回 + 精确召回"两条腿走路;RRF 不用调权重,是工程上性价比极高的融合方案。
六、第三次调优:重排序,79% → 86%
79% 时我分析了剩下答错的题,发现一个新模式:Top-5 里其实有正确答案,但被排在了后面,生成模型被前面几个不相关内容带偏了。召回没问题,排序有问题。
这时引进了 reranker(重排序模型) 。召回阶段用 bge-small 这种轻量模型(快、便宜、召回广),精排阶段用 bge-reranker 这种 cross-encoder(把 query 和每篇文档拼起来一起过模型,精度高但慢)------先粗召回 20 篇,再精排取 5 篇,兼顾速度和质量:
python
from FlagEmbedding import FlagReranker
reranker = FlagReranker('BAAI/bge-reranker-v2-m3', use_fp16=False) # CPU 可跑
def rerank(query, candidates, top_k=5):
pairs = [[query, c["text"]] for c in candidates]
scores = reranker.compute_score(pairs) # cross-encoder 打分
paired = sorted(zip(candidates, scores), key=lambda x: -x[1])
return [c for c, _ in paired[:top_k]]
def final_search(query, recall_n=20):
coarse = hybrid_search(query, top_k=recall_n) # 粗召回 20 篇
return rerank(query, coarse, top_k=5) # 精排取 5 篇
注意这里把召回和精排的代码分层了:召回追求"别漏",精排追求"别错"。这一轮跑完:命中率 93%,准确率 86%。
CPU 上 reranker 一篇约 0.3 秒,100 道题评测跑了一下午,但值得。面试点:粗召回 + 精排的两段式检索是工业界标准架构,"召回负责上限、排序负责下限"这句话可以直接背下来用在面试里。
七、最后的打磨:query 改写与多粒度召回,86% → 91%
到 86% 后,剩下 14 道错题我一个个看,终于发现问题不在检索链路本身,而在用户怎么提问。比如用户问"我上周买的手机能退吗",文档里写的是"自签收之日起 7 日内,消费者有权退货"------口语问题里没有任何一个词能和文档对上,语义上它俩也确实"长得不像"。
解决办法是 query 改写:检索前先对用户问题做一次"规范化"。预算有限,我用规则+轻量 LLM 结合的方式:
python
def rewrite_query(question: str) -> str:
# 1. 口语映射成业务关键词(规则表,可维护)
rules = {"能退吗": "退货 条件", "怎么算工资": "工资 计算 标准",
"赔多少": "赔偿 金额 标准", "几天到": "时效 工作日"}
for k, v in rules.items():
if k in question:
question = question.replace(k, v)
# 2. 追加"提取核心名词"的轻量处理(jieba 取名词)
words = [w for w in jieba.cut(question) if len(w) > 1]
return question + " " + " ".join(words[:6])
同时做了最后一个改动:多粒度召回------同一段内容用 200 字和 600 字两种粒度各存一份,粗粒度保证语义完整、细粒度保证答案紧凑,两路都召回再合并去重。这两个改动叠加:
| 版本 | 关键改动 | Top-5 命中 | 准确率 |
|---|---|---|---|
| v1 | 固定 500 字 + 纯向量 | 74% | 62% |
| v2 | 结构感知分块 | 84% | 71% |
| v3 | + BM25 混合检索(RRF) | 88% | 79% |
| v4 | + reranker 粗召回精排 | 93% | 86% |
| v5 | + query 改写 + 多粒度召回 | 96% | 91% |
面试点:query 改写解决的是"用户语言 ≠ 文档语言"的根本矛盾;多粒度召回说明你理解"分块粒度本身没有最优解,那就都留着"。
八、复盘:这 29 个百分点,每一步都对应一个面试考点
项目做完回头看,62% → 91% 的提升几乎全部来自检索链路的工程化,而不是换更强的生成模型。总结成一张"调优地图",面试时按这个顺序讲,逻辑非常清晰:
- 先立评测集(双指标:召回命中 + 端到端准确),否则一切调优都是玄学;
- 分块策略:固定字符切分切断了语义,尊重文档结构、句末切分(62% → 71%);
- 混合检索:向量负责语义、BM25 负责精确词,RRF 免调权重融合(71% → 79%);
- 两段式检索:轻量模型粗召回 + cross-encoder 精排,兼顾速度与精度(79% → 86%);
- query 改写 + 多粒度召回:解决"用户语言与文档语言不一致"和"粒度无最优解"(86% → 91%)。
给同样在做项目的人三个踩坑提醒:一是 评测集要留一部分"没见过的题" ,不然你会不自觉地把题出在答案附近,分数虚高;二是每改一处只动一个变量 ,我一开始同时改分块和检索方式,结果分数掉了都不知道是哪步造成的;三是 CPU 也能做,别用"没显卡"当借口------我的全程实验都在 16G 内存的笔记本上完成,慢一点而已。
现在面试官问我"这个项目最大的难点是什么",我会说:难的不是模型,而是让每一处改动都有数字证明它有效。这句话,比任何花哨的框架名都更能让面试官记住你。