检索质量差的时候,第一反应是"换个更好的 embedding 模型"。但多数情况下,模型没变、查询没变,只是答案被切在了两个块的交界处。切分决定了向量里能装下多少可被检索的语义,这一步做错,后面的重排、改写都只是在给已经丢掉的信息补妆。
1. 三个互相拉扯的目标
| 目标 | 追求它 | 代价 |
|---|---|---|
| 语义完整 | 块内自洽,能独立回答一个问题 | 块变大,向量被多主题稀释 |
| 检索粒度 | 一块只讲一件事,相似度锋利 | 块变小,指代与前提被切走 |
| 上下文预算 | 塞得进 prompt,成本可控 | top-k 变小,召回变脆 |
三者没有同时最优解。工程上要做的是先定预算,再定粒度,最后用父子块把完整性补回来,而不是找一个"最佳 chunk size"。
2. 五种切分方式与适用文档
| 方式 | 边界依据 | 适用文档 | 失效场景 |
|---|---|---|---|
| 固定长度 | 字符/token 数 | 日志、无结构转录稿 | 表格、代码、法条 |
| 段落/标题 | 空行、# 层级 |
Markdown、说明书 | 长段落不分节的老文档 |
| 递归分隔符 | 段落→句→词逐级回退 | 通用默认,混排文档 | 无标点的中文长句 |
| 语义切分 | 相邻句向量相似度断点 | 长论文、访谈 | 成本高、断点不稳定 |
| 按文档结构 | 表格行、代码函数、HTML 节点 | 报表、代码库、网页 | 结构不规范时退化 |
语义切分要用一次 embedding 预算换断点质量,对"一段话讲三件事"的材料才有价值;结构规范的材料用规则切分就够了。
from langchain_text_splitters import RecursiveCharacterTextSplitter, MarkdownHeaderTextSplitter
md = MarkdownHeaderTextSplitter(
headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")],
strip_headers=False) # 标题留在正文里,否则块内丢失主题词
splitter = RecursiveCharacterTextSplitter(
chunk_size=800, chunk_overlap=120,
separators=["\n\n", "\n", "。", ";", ",", ""])
中文必须把"。"和";"放进分隔符列表,否则递归回退会一路退到单字,块边界毫无语义。
3. size 与 overlap:两个旋钮各自管什么
| 现象 | 机制 | 调法 |
|---|---|---|
| 长问题检索不到 | 块太小,只覆盖问题的局部 | 加 size,或上父子块 |
| 召回结果都"沾边但不准" | 块太大,向量被均值化 | 减小 size,下压到单主题 |
| 同一段落反复出现 | overlap 过大,跨块重复 | overlap 降到 size 的 10%–15% |
| 交界处答案永远缺一句 | overlap 不够 | 提升到 15%–20%,或按句对齐 |
| 表格被切成两半、列名丢失 | 固定长度无视结构 | 改按结构切,整表 + 表头一起走 |
size 的判据不在"字符数好看",而在问题类型:事实型查询("X 的参数是多少")要小块;比较型、汇总型查询("这三种方案的区别")要大块或父子块。因此一个库里常需要两种粒度的索引,而不是一个折中的数值。
overlap 的本质是为边界处买保险,保费是存储与召回冗余。它不能修复错误的分隔符顺序——如果先按字符切再想按句切,overlap 只会把破碎重复一遍。
4. 元数据:过滤的抓手与引用的凭据
| 字段 | 用途 | 不带会怎样 |
|---|---|---|
doc_id / chunk_id |
定位、去重、增量更新 | 无法删除旧块,只能重建全库 |
section_path |
引用可读、块内补全语境 | 用户看到"第 3 段",无法验证 |
source_url / page |
溯源 | 答案不可审计 |
updated_at |
时间过滤、新老版本取舍 | "上个月的政策"召回三年前的 |
acl / tenant |
权限前置过滤 | 检索到无权内容,或被过滤后召回崩掉 |
lang / doc_type |
路由到不同索引 | 代码与散文混排,互相污染 |
权限字段必须在检索前参与过滤,而不是拿回结果再剔除——后者会让 top-k 里一半是无效块,召回率被静默吃掉。
5. small-to-big:小块检索,大块喂入
同一份内容建两条路径:细块进向量库负责"被找到",父块(整节或整段)负责"被读懂"。
{ "chunk_id": "doc42#s3#p2", "parent_id": "doc42#s3",
"text": "……这一小节里的第二段……",
"section_path": "第 3 章 > 3.2 限流 > 令牌桶",
"updated_at": "2026-10-02", "acl": ["eng"] }
检索命中 chunk_id 后按 parent_id 回表取原文,喂给模型的是完整小节。要点有三:命中块只用于打分,不直接进 prompt;父块要设长度上限,否则又回到"稀释";chunk_id 必须可推导(文档号 + 结构路径 + 序号),否则增量更新时定位不到旧块。
代价是两套存储与一次回表查询,收益是"精度按小块算、完整性按大块给",这是目前性价比最高的一种折中。
6. 混合检索为什么互补,而不是重复
BM25 打的是词面共现,向量打的是语义邻近。两者失效的方向几乎不重叠:编号、错误码、人名、缩写只有字面能命中;同义改写、跨语言、口语化只有向量能命中。所以融合的价值在于覆盖不同的查询族,不是把两个分数平均一下。
def rrf(rank_lists, k=60, w=None):
w = w or [1.0] * len(rank_lists)
score = {}
for lst, weight in zip(rank_lists, w):
for rank, doc in enumerate(lst): # rank 从 0 开始
score[doc] = score.get(doc, 0) + weight / (k + rank + 1)
return sorted(score, key=score.get, reverse=True)
k 越大,排名差异被压平,长尾块更容易浮上来;k 越小,头部名次主导。融合前要先去重(同一 parent_id 的多个块只保留最高名次),否则同一段内容会占满 top-k,等于自己给自己刷票。
7. 召回评估:口径先定,再谈优化
| 指标 | 口径 | 常见误用 |
|---|---|---|
| Recall@k | 标注相关块中,前 k 条覆盖的比例 | 只报命中"至少一个"的命中率 |
| MRR | 第一个相关块名次的倒数均值 | 多相关块场景下低估 |
| nDCG@k | 带分级相关度的位置折扣 | 相关性只标 0/1,退化成 MRR |
| 答案命中率 | 端到端,看生成对不对 | 与召回混为一谈,归因错位 |
查询集要覆盖查询族:精确词、同义改写、多跳、带约束(时间/权限)。每族至少几十条才能看出差异,样本太少时排序噪声比改进还大。
{"qid":"q017","query":"令牌桶和漏桶的区别","relevant":["doc42#s3#p2","doc42#s3#p5"],"grades":[2,1]}
标注粒度是块级而不是文档级——文档级标注会让"召回对了文档、块选错了"这种真实故障完全不可见。
8. "检索到了但答不对"的三种归因
| 归因 | 判据 | 改法 |
|---|---|---|
| 块里根本没有答案 | 标注里该块不相关,或答案只在别的块 | 补切分/补索引源,不是调 top-k |
| 块被截断,答案缺前提 | 块边界正好落在关键句中间 | 加 overlap、按句对齐、父子块 |
| 排序被噪声挤掉 | 相关块在第 8–15 名,top-5 里没有 | 上重排,或加元数据过滤 |
区分方法很直接:把"标注相关块"强行放进 prompt,看答案对不对。放进去就对了 → 问题在排序;放进去还不对 → 问题在切分或语料缺失。这一步能把"调模型"的冲动挡回去大半。
9. 重排的成本收益与触发条件
| 方案 | 延迟量级 | 收益来源 | 该用的条件 |
|---|---|---|---|
| 只调 top-k | 无 | 多给几条 | 召回率已高,只是排序差 |
| cross-encoder 重排 | 与候选数线性相关 | 查询-文档逐对打分 | 相关块常在第 6–20 名 |
| 加权融合 | 极低 | 两条检索路径互补 | 查询族混合、成本敏感 |
| 重排 + 截断 | 中 | 先粗排 N=50 再精排 top-5 | 上下文预算紧张 |
重排的收益随"候选里真有相关块"而存在:如果 Recall@50 本身就低,重排只能把噪声排得更整齐。先量 Recall@50,再决定要不要付这份延迟。
10. 增量更新与删除
def upsert_chunks(doc_id, chunks):
old = index.query(filter={"doc_id": doc_id}) # 先按 doc 维度取旧块
keep = {c["chunk_id"] for c in chunks}
for c in old:
if c["chunk_id"] not in keep:
index.delete(c["chunk_id"]) # 结构变了,旧块要清
index.upsert(chunks)
三条纪律:chunk_id 由内容结构推导(doc_id + section_path + seq),不要用自增号或随机 uuid,否则删不掉旧块;删除按 doc_id 维度先查后删,不要指望记忆;改了切分参数就必须全量重建——旧块的边界与新规则不一致,混在一起会让相似度分布不可比。
只更新不删除是最常见的隐性 bug:文档改短了,旧的长块还留在库里,检索时它比新块更"完整",于是永远召回过期内容。
11. 排错
| 现象 | 先查 | 再查 |
|---|---|---|
| 新文档检索不到 | 是否已 refresh / 索引是否可见 | 向量维度与索引 mapping 是否一致 |
| 召回全是同一文档 | 是否缺 doc_id 去重 |
overlap 是否过大导致重复块 |
| 改了切分但效果不变 | 索引是否真的重建 | 查询走的是旧别名吗 |
| 表格类问题总答错 | 表格是否被按字符切断 | 表头是否随行一起入库 |
| 删除后仍能召回 | 是否只删了主索引 | 缓存层与副本是否同步 |
12. 边界
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 语料只有几万字 | 全量塞进上下文 | 检索的复杂度不值得 |
| 强结构化数据(订单、指标) | 走 SQL / 精确查询 | 向量检索不适合做精确匹配 |
| 需要逐字引用的合同 | 结构切分 + 原文高亮 | 块内必须能定位到条款号 |
| 超高并发低延迟 | 单路检索 + 缓存 | 多路召回与重排的延迟叠加 |
13. 自测
- 为什么"找一个最佳 chunk size"这个提法本身有问题?
- overlap 能修复什么样的错误,又不能修复什么样的错误?
- 父块回表时,为什么命中块不直接进 prompt?
- RRF 的
k变大,会让排序结果偏向什么? - 判断"检索到了但答不对"属于哪一类归因,最快的实验是什么?
- 改了切分参数却没有重建索引,会观察到什么现象?