企业知识库 RAG 从 62% 到 91%:分块策略、混合检索与重排序的调优全记录

一、为什么啃 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 道答错的题,失败原因非常集中:

  1. 答案被拦腰切断(约 15 题):比如"离职赔偿金计算规则"恰好被 500 字边界切成两半,检索只能召回一半;
  2. 关键词对不上(约 12 题):用户问"加班费怎么算",文档里写的是"加班工资的支付标准",向量检索把这两句话当成了不同的东西;
  3. 命中了但位置靠后(约 7 题):正确答案在第 6-8 位,被硬生生挤出了 Top-5;
  4. 剩下的是生成本身的问题。

看到这个归因,我心里就有数了: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% 的提升几乎全部来自检索链路的工程化,而不是换更强的生成模型。总结成一张"调优地图",面试时按这个顺序讲,逻辑非常清晰:

  1. 先立评测集(双指标:召回命中 + 端到端准确),否则一切调优都是玄学;
  2. 分块策略:固定字符切分切断了语义,尊重文档结构、句末切分(62% → 71%);
  3. 混合检索:向量负责语义、BM25 负责精确词,RRF 免调权重融合(71% → 79%);
  4. 两段式检索:轻量模型粗召回 + cross-encoder 精排,兼顾速度与精度(79% → 86%);
  5. query 改写 + 多粒度召回:解决"用户语言与文档语言不一致"和"粒度无最优解"(86% → 91%)。

给同样在做项目的人三个踩坑提醒:一是 评测集要留一部分"没见过的题" ,不然你会不自觉地把题出在答案附近,分数虚高;二是每改一处只动一个变量 ,我一开始同时改分块和检索方式,结果分数掉了都不知道是哪步造成的;三是 CPU 也能做,别用"没显卡"当借口------我的全程实验都在 16G 内存的笔记本上完成,慢一点而已。

现在面试官问我"这个项目最大的难点是什么",我会说:难的不是模型,而是让每一处改动都有数字证明它有效。这句话,比任何花哨的框架名都更能让面试官记住你。

相关推荐
烂蜻蜓1 小时前
Flask入门教程(二十六):Session API——用户会话状态管理
后端·python·flask
2603_965148113 小时前
家居百货蓝海:API挖掘高复购率生活小商品
大数据·服务器·人工智能·python·生活
阿童木写作7 小时前
跨境电商图片翻译工具推荐:批量图片翻译+视频字幕翻译+智能抠图
python·macos·音视频·xcode
cui_ruicheng7 小时前
LangChain 应用开发(十四):Agent 上下文与记忆机制
服务器·人工智能·python·langchain
circuitsosk8 小时前
任务规划器的三种范式对比:ReAct、Plan-and-Execute 与 Tree-of-Thought 在真实业务中的取舍
前端·javascript·python·react.js·llm·ai agent
shehuiyuelaiyuehao8 小时前
算法31,前缀和,可被k整除的子数组
数据结构·python·算法
宸津-代码粉碎机8 小时前
AI攻防战升级!基于Spring AI构建Java应用自动免疫安全体系
java·大数据·开发语言·人工智能·python·安全·spring
测试19988 小时前
如何用appium搭建Android自动化测试框架?
自动化测试·软件测试·python·测试工具·职场和发展·appium·测试用例
程序员大雄学编程8 小时前
微积分46. 无穷级数四大核心性质:从理论到实践的全方位解析
python·学习·微积分·学习工具