一聚教程网:一个值得你收藏的教程网站

最新下载

热门教程

为 Agent 构建知识库:拆解 RAG 分块、混合检索和重排链路

时间:2026-09-13 16:18:01 编辑:袖梨 来源:一聚教程网

当 Agent 需要回答企业制度、内部文档或私有资料中的问题时,只依赖模型参数和公网工具显然不够。要让回答建立在可检索、可追溯的材料之上,需要把文档处理成一条稳定的 RAG 链路。接下来将从解析和分块开始,逐步讨论向量化、混合召回与重排,并分析每个环节对最终检索质量的影响。

给 Agent 接上知识库:RAG 检索链路——分块策略、混合检索与重排

摘要:承接前三篇的手写 ReAct Agent、Function Calling 重构与多轮历史管理,本文进入 RAG 上半场——文档解析、固定 vs 语义分块(两种共存可切换)、Embedding 选型与 Chroma 入库、向量 + BM25 混合检索(RRF 排名融合)、Rerank 精排。全程不用 RAG 框架,五站链路手写,每站都给实测数据。

背景

上篇结尾留的钩子:记忆管住了,模型还是不知道你公司的知识。多轮 Agent 的知识来源只有两处——模型参数(训练截止日期之后的事一无所知,企业内部文档从未见过)和工具返回(搜索到的是公网内容)。"我们公司年假几天"这种问题,两头都够不着。

RAG(Retrieval-Augmented Generation)的解法一句话:检索在先,生成在后。把文档切块存进向量库,用户提问时先检索出相关块,再把块作为上下文交给模型"照着材料回答"。模型从"记忆者"降级为"阅读理解者"——这是幻觉率下降的根本原因,也是答案可溯源的前提。

本篇是上半场,五站链路:解析 → 分块 → 向量化 → 检索 → 重排,到"检索出相关块"为止。生成与引用溯源是下半场(下一篇)的事。

问题:五站链路,每一站都在做交易

  1. 解析:垃圾进,垃圾出。解析质量决定后面所有环节的上限——分块在烂文本上切不出语义边界,检索在烂文本上召回不出相关块;
  2. 分块:块太大,向量被稀释(一段话讲三件事,哪个都检索不准);块太小,语义不完整(命中的是半句话,答案残缺)。策略决定在哪些边界上切;
  3. 向量化:选型三要素——语言覆盖(中文场景选中文强的模型)、维度(精度 vs 成本)、输入上限(块超长会被静默截断);
  4. 检索:向量懂语义不懂精确词(查"SA-2024"可能被带偏),BM25 懂精确词不懂同义("怎么请假"匹配不到"年假申请")——互补,所以要混合;
  5. 重排:cross-encoder 精度高但贵,只能给漏斗收窄后的 top 几十条用——和推荐系统"召回-排序"两段式同一个思想。

实现

1. 解析:三种格式,三种坑

Markdown 最友好:纯文本加少量标记,策略是"删标记、留内容、保结构"——标题去井号但独立成行(它是后面语义分块的锚点)。用正则按顺序处理:先多行结构(代码块围栏)再行内标记(粗体/链接),顺序反了代码块里的 ** 会被误伤。

PDF 最难搞:格式信息是给人眼看的,不是给机器的。pypdf 逐页提文能拿到"能拿到的部分",但双栏阅读顺序不保证、表格碎成一地、句子中间硬换行;扫描件(图片型)一个字都提不到,需要 OCR。逐页保留页码标记,检索命中时元数据能告诉你"在第几页"。

网页难在中间:正文埋在导航/广告/脚本里,核心动作是"剥离"而不是"提取"。不能用正则删 HTML——属性里可以有任意 > <,嵌套结构正则处理不了(对自由格式用正则,这是手写 ReAct 时代就交过的学费)。BeautifulSoup 建真正的 DOM 树再删节点:script/style/nav/header/footer 整棵子树 decompose(),然后优先取 <main>/<article> 语义标签。

2. 分块:两种策略共存,用数据说话

先写对照组——固定长度硬切加重叠:

def chunk_fixed(text, chunk_size=300, overlap=40):
    chunks, start = [], 0
    while start < len(text):
        chunks.append(text[start:start + chunk_size].strip())
        if start + chunk_size >= len(text):
            break
        start += chunk_size - overlap
    return chunks

overlap 防止关键词跨在切点上两边都丢,但固定分块的所有问题它都不解决。再写语义分块:识别标题/段落/句子三级边界,贪心合并到目标长度——标题永远跟着它管辖的正文(新语境第一个句子进来前先给旧块封口),段落边界处块已填够 40% 就收口,单句超长退化为硬切兜底。

拿一份 1546 字符的技术文档,同一目标长度 150 字,实测:

固定长度  块数 14  长度 min/avg/max = 115/147/150
语义分块  块数 13  长度 min/avg/max = 46/108/149

[事故一] 固定分块:5 个标题'贴着块尾'(正文在下一块)、0 个标题被切成两半
[事故二] 固定分块有 12 块以'半句话'结尾
[对照]   语义分块:13/13 块携带标题路径;半句结尾 3 个(固定分块 12 个)

固定分块的两类事故肉眼可见:孤儿标题(标题混进上一节的尾巴,它自己的正文整体落在下一块——检索命中这块时只有标题没有答案);半句截断(命中块以『…回答问题",不负责"记忆材料"。n这带来』结尾,缺主语或谓语,生成必然残缺)。

为什么两种策略都保留、做成 /chunk 开关?没有评测数据之前,"哪种更好"是信仰问题。工程答案是把变量做成开关,用评测集跑出"准确率 X% → Y%"再说——这是下半场的活,但开关必须这周就埋好。

一个实现细节:标题识别只能靠启发式(短行、独占一行、无句末标点、上一行为空),因为 PDF 解析出来根本没有井号——格式信息丢失后,启发式是不得不付的税。必要条件必须是"上一行为空",否则软换行中文文档里不足 25 字的段中续行会被误杀成标题。

3. 向量化:DeepSeek 没有 embedding 接口怎么办

现实约束:主力对话模型 DeepSeek 只有 /chat/completions,没有 /embeddings。解法是 OpenAI 兼容生态:SiliconFlow(bge 系列,有免费额度)/ 智谱 / 阿里百炼都暴露 OpenAI 兼容的 embeddings 端点,openai SDK 换个 base_url 直接用,代码零改动。

选型之后是两个容易漏的点:

bge 系列查询侧要加指令前缀。检索场景下查询拼上"为这个句子生成表示以用于检索相关文章:",文档侧不加。这是模型训练方式决定的,不加会损失几个点的召回。代码里按模型名含不含 bge 自动判断。

无 key 也要能跑通全链路。本地哈希嵌入兜底:中文按字符 bigram(不引分词依赖,"SA-2024"这种编号分词器会切碎、bigram 原样保留——Lucene CJK 分析器同款思路)、哈希到定维、sqrt(TF) 加权、L2 归一化。它不懂语义,但分块/入库/两路检索/融合/重排的行为全是真的——零成本验证 80% 的链路,换真模型只改配置不动代码。"管道先通,质量后好",调试复杂系统的基本功。

def _stable_hash(token: str) -> int:
    # md5 取模做桶号。为什么不用内置 hash():它每个进程加盐,
    # 今天入库的向量明天重启就查不出来——持久化系统最忌讳的坑
    return int(hashlib.md5(token.encode("utf-8")).hexdigest(), 16) % _HASH_DIM

4. 入库:三个静默坑,全都不报错

Chroma 是教学/原型期的事实标准:内嵌持久化、零服务部署。但三个坑全靠注释救:

  1. 默认距离是 L2 不是余弦。建 collection 必须显式 metadata={"hnsw:space": "cosine"},否则相似度排序完全不同——且不报错,静默错;
  2. id 必须稳定唯一{source}:{strategy}:{index} 规则加 upsert 幂等覆盖,重跑入库不会重复堆叠;
  3. 元数据是引用溯源的地基source(给人看的文档名)、path(可重取的原始路径/URL)、heading(标题路径)、index(块号)入库时就存好——下半场的 [1][2] 引用标注全部从这里来。元数据要在入库前设计好,检索阶段只是把它带出来。

sourcepath 必须分开存,这是踩过才知道的:重启后 /chunk 切策略要重建索引,如果只存了展示名,拿"公司制度手册"去找文件必然 404。

5. 混合检索:两套分数不可通约,RRF 只用排名

向量检索(bi-encoder 双塔)和 BM25 各自跑一路,问题在融合:余弦相似度 ∈ [0,1],BM25 分数无上界(取决于语料统计)——直接加权平均在数学上就不成立0.7 * cosine + 0.3 * bm25 这种写法,换个语料 BM25 分数整体膨胀一倍,权重就名存实亡。

标准解法是 RRF(Reciprocal Rank Fusion):只用排名不用分数,每路第 r 名贡献 1/(60+r)

for hits in (vector_hits, bm25_hits):
    for rank, (_, doc, meta) in enumerate(hits, 1):
        rrf[doc] = rrf.get(doc, 0.0) + 1.0 / (RRF_K + rank)

对分数分布完全鲁棒,工业界默认融合方案。这是"放弃精确性换取稳健性"的典型工程决策——和多轮历史管理里用近似 token 计数同一哲学:你要判断的是量级和排序,不是小数点后两位。

另一个架构决策:BM25 索引每次从 Chroma 全量块重建(向量库是唯一事实源),绝不另存一份数据——两路索引建在不同数据上是最隐蔽的一类 bug。顺带一提,BM25 和哈希兜底嵌入共用同一个 bigram 分词器:三路(向量兜底/BM25/伪重排)同步升级,不会出现"向量库用分词、BM25 用 bigram"的错位。

6. 重排:放在漏斗最后的道理

向量检索是双塔结构:查询和文档分别独立编码再算相似度——快,可以离线预计算,但两段文本从未"见过面",精度有天花板。Rerank 用 cross-encoder:把查询和文档拼一起送进模型做交叉注意力,精度显著更高,但每个 (查询, 文档) 对都要过一次模型——全库精排等于每次查询跑一遍 O(N) 推理。

所以链路位置是固定的:粗排管"别漏"(两路并行从全库收 20 条),精排管"排对"(从 20 条挑 5 条)

rerank 不在 OpenAI SDK 协议里(非标接口),自己发 HTTP 请求——这本身就是一课。

运行效果

真模型(SiliconFlow bge-large-zh-v1.5 + bge-reranker-v2-m3),三份测试文档入库(27 块)。

实验一:同义改写,向量的主场。查询「想休息几天走什么流程」——与语料零共享词,哈希兜底时这题命中一片乱块,真 bge 直接三种假全中:

1. 0.4844  ⟨年假⟩  入职满 1 年不满 3 年的员工,每年享有 5 天带薪年假……
2. 0.4758  ⟨病假⟩  病假需在当日上午 10 点前通过 OA 或企业微信报备……
3. 0.4554  ⟨事假⟩  事假为无薪假,单次不超过 3 个工作日……

注意分数咬得很紧(0.48/0.47/0.45)——这就是 bi-encoder 的天花板:知道都和"休假"有关,分不出谁最该答。

**实验二:精确词,看每一级怎么改变候选集。**查询「笔记本电脑怎么申请高配」:

环节Top-1与第 2 名差距解读
BM25⟨笔记本电脑⟩ 20.696 倍关键词一击命中,但 2、3 名是讲 BM25 概念的元数据块(噪音)
混合粗排 RRF⟨笔记本电脑⟩ 0.0328几乎无差距双塔分不出"答问题的块"和"讲概念的块"
Rerank⟨笔记本电脑⟩ 0.9173350 倍(0.0026)cross-encoder 读懂"怎么申请"是操作性问题,置信度断崖领先

粗排把目标捞进了 top-20(别漏),精排给出了 350 倍的区分度(排对)——每级的价值用数字说清楚了。

**实验三:同一查询只换分块策略。**查询「年假按照工龄怎么算」,检索模式和 rerank 全不动:

策略Top-1rerank 分数命中块形态
semantic⟨年假⟩0.2974完整自洽的语义单元,一问一答严丝合缝
fixed⟨hr-policy⟩0.1462从文档导语开始,"文档说明+假期制度+年假+病假"四个话题混一块;top-3 开头是『及以上医院出具的证明』——「二级甲」三个字在前一块里

固定分块的"向量稀释"直接体现在精排置信度腰斩:块里一半内容与问题无关,信号被摊薄。这就是分块策略对比实验的定性版——量化它(自建评测集跑准确率)是下半场的活。

踩坑记录

按痛的程度排序:

  1. Chroma 默认 L2 不是余弦。不显式声明 cosine 空间,相似度排序完全不同且不报错。静默错比崩溃恶劣十倍——崩溃逼你修,静默错陪你上线。
  2. 两套分数直接加权是错的。余弦有界 BM25 无界,加权的结果随语料统计漂移。RRF 只用排名,分布免疫。
  3. 内置 hash() 跨进程不稳定。Python 每个进程给 hash 加盐,今天入库的哈希向量明天重启就查不出来。持久化向量必须用 md5 这类稳定哈希。
  4. 换嵌入模型必须重新入库。哈希兜底 512 维和 bge 1024 维的向量空间毫无关系,混库检索全是噪音。任何"换模型"操作都要连带"重建索引"。
  5. BM25 对同义改写零命中。「想休息几天」与语料无一共享词时 BM25 返回空列表——自测第一版直接 [0] 取值当场崩溃。空结果是一等公民,必须显式处理;这个零命中本身反而是最好的教学素材(混合检索存在的理由)。
  6. bge 查询侧指令前缀。检索场景查询要拼指令、文档不要拼,漏了损失几个点召回,且没有任何报错提示。
  7. 元数据存"展示名"还是"可重取地址"。只存文档名,重启后切分块策略重建索引时拿名字找文件必然失败。path 与 source 是两个需求,分开存。
  8. 孤儿标题的判定要放宽。按"标题独占整块"检测永远数出 0 个(标题恰好独占一块的概率太低),改成"标题之后同块正文不足 20 字"才看得见事故——教学演示里看不见的问题等于没发生。

总结

四级检索模式,一张表收束:

模式原理强项弱项
vectorbi-encoder 双塔 + 余弦最近邻语义改写:"想休息几天" → 年假制度精确词:编号可能检索不到
bm25词频饱和 + 长度归一化精确匹配:编号、术语、人名不懂同义:"怎么请假"≠"年假申请"
hybrid两路并行 + RRF 排名融合互补:一路漏的另一路救回粗排天花板:双塔精度上限
hybrid+rerank混合召回 20 → cross-encoder 精排 5最终形态,精度最高每次查询多一跳模型调用

面试题"分块策略有哪几种?Rerank 解决什么问题?放在哪一步?"现在可以这么答:分块是块大小与语义完整性的交易,固定长度简单但句子截断标题分家,语义分块按标题/段落/句子三级边界贪心合并——没有评测数据前不选边,做成开关用数字说话;检索层面向量懂语义不懂精确词、BM25 反之,两路分数不可通约所以用 RRF 只融排名;Rerank 用 cross-encoder 解决双塔"查询和文档从未见过面"的精度天花板,因为贵所以只能放在漏斗最后——粗排管别漏、精排管排对。

下一步:RAG 下半场。检索结果拼进上下文交给模型生成,回答标注 [1][2] 引用来源(本周入库的元数据就是地基),自建 20-30 条评测集,把分块策略对比从定性(0.2974 vs 0.1462)做成定量(准确率 X% → Y%),再记录一个 RAG 答错 case 的定位过程——检索问题还是生成问题。

热门栏目