当 Agent 需要回答企业制度、内部文档或私有资料中的问题时,只依赖模型参数和公网工具显然不够。要让回答建立在可检索、可追溯的材料之上,需要把文档处理成一条稳定的 RAG 链路。接下来将从解析和分块开始,逐步讨论向量化、混合召回与重排,并分析每个环节对最终检索质量的影响。
摘要:承接前三篇的手写 ReAct Agent、Function Calling 重构与多轮历史管理,本文进入 RAG 上半场——文档解析、固定 vs 语义分块(两种共存可切换)、Embedding 选型与 Chroma 入库、向量 + BM25 混合检索(RRF 排名融合)、Rerank 精排。全程不用 RAG 框架,五站链路手写,每站都给实测数据。
上篇结尾留的钩子:记忆管住了,模型还是不知道你公司的知识。多轮 Agent 的知识来源只有两处——模型参数(训练截止日期之后的事一无所知,企业内部文档从未见过)和工具返回(搜索到的是公网内容)。"我们公司年假几天"这种问题,两头都够不着。
RAG(Retrieval-Augmented Generation)的解法一句话:检索在先,生成在后。把文档切块存进向量库,用户提问时先检索出相关块,再把块作为上下文交给模型"照着材料回答"。模型从"记忆者"降级为"阅读理解者"——这是幻觉率下降的根本原因,也是答案可溯源的前提。
本篇是上半场,五站链路:解析 → 分块 → 向量化 → 检索 → 重排,到"检索出相关块"为止。生成与引用溯源是下半场(下一篇)的事。
Markdown 最友好:纯文本加少量标记,策略是"删标记、留内容、保结构"——标题去井号但独立成行(它是后面语义分块的锚点)。用正则按顺序处理:先多行结构(代码块围栏)再行内标记(粗体/链接),顺序反了代码块里的 ** 会被误伤。
PDF 最难搞:格式信息是给人眼看的,不是给机器的。pypdf 逐页提文能拿到"能拿到的部分",但双栏阅读顺序不保证、表格碎成一地、句子中间硬换行;扫描件(图片型)一个字都提不到,需要 OCR。逐页保留页码标记,检索命中时元数据能告诉你"在第几页"。
网页难在中间:正文埋在导航/广告/脚本里,核心动作是"剥离"而不是"提取"。不能用正则删 HTML——属性里可以有任意 > <,嵌套结构正则处理不了(对自由格式用正则,这是手写 ReAct 时代就交过的学费)。BeautifulSoup 建真正的 DOM 树再删节点:script/style/nav/header/footer 整棵子树 decompose(),然后优先取 <main>/<article> 语义标签。
先写对照组——固定长度硬切加重叠:
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 字的段中续行会被误杀成标题。
现实约束:主力对话模型 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
Chroma 是教学/原型期的事实标准:内嵌持久化、零服务部署。但三个坑全靠注释救:
metadata={"hnsw:space": "cosine"},否则相似度排序完全不同——且不报错,静默错;{source}:{strategy}:{index} 规则加 upsert 幂等覆盖,重跑入库不会重复堆叠;source(给人看的文档名)、path(可重取的原始路径/URL)、heading(标题路径)、index(块号)入库时就存好——下半场的 [1][2] 引用标注全部从这里来。元数据要在入库前设计好,检索阶段只是把它带出来。source 和 path 必须分开存,这是踩过才知道的:重启后 /chunk 切策略要重建索引,如果只存了展示名,拿"公司制度手册"去找文件必然 404。
向量检索(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"的错位。
向量检索是双塔结构:查询和文档分别独立编码再算相似度——快,可以离线预计算,但两段文本从未"见过面",精度有天花板。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.69 | 6 倍 | 关键词一击命中,但 2、3 名是讲 BM25 概念的元数据块(噪音) |
| 混合粗排 RRF | ⟨笔记本电脑⟩ 0.0328 | 几乎无差距 | 双塔分不出"答问题的块"和"讲概念的块" |
| Rerank | ⟨笔记本电脑⟩ 0.9173 | 350 倍(0.0026) | cross-encoder 读懂"怎么申请"是操作性问题,置信度断崖领先 |
粗排把目标捞进了 top-20(别漏),精排给出了 350 倍的区分度(排对)——每级的价值用数字说清楚了。
**实验三:同一查询只换分块策略。**查询「年假按照工龄怎么算」,检索模式和 rerank 全不动:
| 策略 | Top-1 | rerank 分数 | 命中块形态 |
|---|---|---|---|
| semantic | ⟨年假⟩ | 0.2974 | 完整自洽的语义单元,一问一答严丝合缝 |
| fixed | ⟨hr-policy⟩ | 0.1462 | 从文档导语开始,"文档说明+假期制度+年假+病假"四个话题混一块;top-3 开头是『等及以上医院出具的证明』——「二级甲」三个字在前一块里 |
固定分块的"向量稀释"直接体现在精排置信度腰斩:块里一半内容与问题无关,信号被摊薄。这就是分块策略对比实验的定性版——量化它(自建评测集跑准确率)是下半场的活。
按痛的程度排序:
hash() 跨进程不稳定。Python 每个进程给 hash 加盐,今天入库的哈希向量明天重启就查不出来。持久化向量必须用 md5 这类稳定哈希。[0] 取值当场崩溃。空结果是一等公民,必须显式处理;这个零命中本身反而是最好的教学素材(混合检索存在的理由)。四级检索模式,一张表收束:
| 模式 | 原理 | 强项 | 弱项 |
|---|---|---|---|
| vector | bi-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 的定位过程——检索问题还是生成问题。
Claude 订阅停止覆盖第三方 Agent 后还能通过 OAuth 使用吗?
Ubuntu系统的笔记本触摸板怎么调节鼠标光标速度?
Claude Code 订阅套餐和 API 按量调用的成本如何管理?
新一代 AI 工具进阶指南:从基本使用到高效工作流
Pi Agent 应该如何通过 Agent SDK 正确连接 Claude 或 Codex?
Pi Agent 能否作为 Claude Code 的 Wrapper 使用?