企业知识库 RAG 检索设计,三层召回如何兼顾精度与召回率

企业知识库里的检索问题,通常不是"完全搜不到",而是"搜到的内容不够准"。同一个 Wiki 空间里可能同时存在产品说明、运维手册、故障复盘和历史方案,向量检索能够找到语义相近的片段,却未必能判断用户问的是哪篇文档、哪个章节。遇到错误码、配置项、服务名一类精确词汇时,情况还会反过来。关键词检索明明能直接命中,向量模型却可能把结果排到很后面。

把文档全部切成 chunk 再做一次全库向量搜索,可以快速做出 RAG 原型,但知识库规模扩大后,主题混淆、局部细节漏召回和上下文割裂会一起出现。更稳妥的办法是利用企业文档本身的层级结构,先定位文档,再定位章节,最后检索原文片段。同时保留一条不受上层候选限制的全局 chunk 通道,为摘要没有覆盖的细节兜底。

本文给出一套面向企业 Wiki 和文件知识库的检索链路。它由 Document、Section、Chunk 三层主检索组成,每层同时使用向量检索和 BM25,候选结果通过 Reciprocal Rank Fusion,也就是 RRF 融合。主链路之外再并行执行全局 chunk 检索,合并去重后交给 reranker,最后通过上下文扩展组装给大模型使用的证据。

三层主检索与全局兜底的完整设计

一、单层 chunk 检索为什么容易失效

单层 chunk 检索的优势很直接。数据模型简单,建库成本低,请求链路短,适合验证知识库能否回答问题。但它把所有局部片段都放在同一个竞争空间里,文档级主题、章节位置和片段之间的父子关系很难进入排序过程。

假设知识库里有十几篇与 Kafka 相关的文档,分别讨论消费者架构、Broker 调优、故障恢复和监控告警。用户询问"rebalance 为什么会卡住消费",某些告警文档可能频繁出现 consumer、partition 和 timeout,向量相似度并不低。真正解释 rebalance 过程的段落反而可能因为篇幅短、措辞不同而没有进入 Top K。检索系统看到了局部相似性,却没有先判断问题属于哪类文档,也没有利用"Consumer Group > Rebalance > Performance"这样的标题路径。

纯向量检索还不擅长所有查询,例如 ORA-12514、HTTP 502、KAFKA-10054、user_id、xxx_service 和 LDAP 都带有很强的字面特征。对这类词,BM25 往往比 embedding 更稳定。反过来,用户用自然语言描述现象而文档使用专业术语时,向量检索又比关键词匹配更容易命中。这两种检索信号不是替代关系,而是互补关系。

把检索拆成层级链路能够缩小每一步的候选空间,但严格的逐层硬过滤也有明显风险。文档摘要和章节摘要都是压缩后的信息,不可能保留原文中的每个配置项和边缘能力。如果上层没有召回,正确 chunk 就失去了进入后续阶段的机会。因此,这套设计的核心不是单纯增加层数,而是让分层检索负责提高精度,让全局检索负责守住召回率。

二、整体链路如何工作

完整的在线流程可以表示为下面这张图。

text 复制代码
用户 Query
    │
    ▼
Query Understanding
    ├─ original query
    ├─ rewritten query
    ├─ keywords
    └─ entities
    │
    ├──────────────────────────────────┐
    ▼                                  ▼
Document 检索                       全局 Chunk 检索
Vector + BM25                       Vector + BM25
    │ Top 10                           │ Top 15~20
    ▼                                  │
Section 检索                           │
Vector + BM25                          │
    │ Top 20                           │
    ▼                                  │
Chunk 检索                             │
Vector + BM25                          │
    │ Top 30                           │
    └──────────────┬───────────────────┘
                   ▼
              Merge + Dedup
                   │
                   ▼
                Reranker
                   │ Top 5~10
                   ▼
            Context Expansion
                   │
                   ▼
                  LLM

主链路中的每一层都不是生成答案,而是在回答一个范围逐渐收窄的问题。Document 层判断问题可能属于哪些文档或 Wiki 页面,Section 层定位这些文档中的相关章节,Chunk 层返回能够支持答案的原文证据。全局通道不参与逐层过滤,它直接在整个知识库的 chunk 索引中检索,用来找回上层摘要遗漏的细节。

这套流程比"摘要检索后直接返回整篇文档"多了一层章节定位,也比"全库 chunk Top K"多了主题和结构约束。代价是索引模型更复杂,在线请求次数增加,还需要维护父子关系和评测集。它更适合已经度过原型阶段、知识库规模和答案质量都开始影响业务的系统。

三、Document 层先判断问题属于哪里

Document 层的对象是一篇文件或一个 Wiki 页面,但索引内容不应该是整篇原文。长文档直接生成单个向量会压平内部主题,既浪费 embedding 输入,也无法准确表示一篇文档能回答哪些问题。更实用的做法是为每篇文档生成一份检索画像。这个画像除了描述文档内容,还要承担层级关联、来源追踪和增量更新等职责,因此示例中同时保留了检索字段与治理字段。

json 复制代码
{
  "doc_id": "doc_001",
  "source_type": "wiki",
  "title": "Kafka Consumer Architecture",
  "summary": "介绍 Kafka Consumer、Consumer Group、分区分配、rebalance 和 offset 管理机制。",
  "topics": [
    "Kafka Consumer",
    "Consumer Group",
    "Rebalance",
    "Offset"
  ],
  "keywords": [
    "consumer rebalance",
    "partition assignment",
    "consumer group"
  ],
  "entities": [
    "Kafka",
    "Consumer",
    "Partition"
  ],
  "questions_answered": [
    "Kafka Consumer Group 是什么",
    "什么情况会触发 rebalance",
    "rebalance 为什么会造成消费暂停"
  ],
  "source_url": "https://wiki.example.com/kafka/consumer",
  "updated_at": "2026-09-20T10:30:00Z"
}

这组字段可以分成三类。doc_id 和 source_type 用来确认文档身份,title、summary、topics、keywords、entities 和 questions_answered 用来参与召回,source_url 与 updated_at 则负责来源追踪和索引更新。各字段的具体作用如下。

字段 含义
doc_id 文档的唯一标识,用于关联 Section、Chunk 和原始数据
source_type 文档来源类型,例如 Wiki、PDF、Word 或网页
title 文档标题,是主题判断和结果展示的重要信号
summary 文档内容摘要,用于快速判断整篇文档是否与 Query 相关
topics 文档覆盖的主要主题,通常使用较规范的领域术语
keywords 适合精确匹配的关键词、缩写、配置名或同义表达
entities 文档中涉及的产品、组件、协议等命名实体,可用于过滤或加权
questions_answered 该文档可能回答的问题,用来缩小文档内容与用户问法之间的表达差异
source_url 原文地址,用于答案引用、结果跳转和来源追踪
updated_at 原文最后更新时间,用于增量建库和判断索引是否过期

这些字段并不需要全部写入 embedding,doc_id、source_type、source_url 和 updated_at 更适合作为过滤、展示或治理信息,真正用于向量化的是标题、摘要、主题、关键词和可回答问题。标题与主题提供领域边界,摘要描述主体内容,questions_answered 则把文档能力转换成更接近用户查询的表达。后者适合离线生成,但不能完全替代原始标题和关键词,否则生成模型的措辞偏差会直接进入检索索引。

text 复制代码
Title:
Kafka Consumer Architecture

Summary:
介绍 Kafka Consumer、Consumer Group、分区分配、rebalance 和 offset 管理机制。

Topics:
Kafka Consumer, Consumer Group, Rebalance, Offset

Keywords:
consumer rebalance, partition assignment, consumer group

Questions:
什么情况会触发 rebalance
rebalance 为什么会造成消费暂停

在线请求中,Document 层分别取得向量 Top 20 和 BM25 Top 20,再通过 RRF 融合为 Top 10 文档。这里的 20 和 10 只是第一版基线,知识库中文档越多、主题重叠越严重,候选窗口通常越宽。最终数值应由文档级 Recall@K 和在线延迟共同决定。

四、Section 层利用 Wiki 的天然结构

Section 层是三层方案里最关键的一层。企业 Wiki 和技术文档往往已经用标题表达了作者的内容组织方式。忽略这部分结构,把所有段落当成平级数据,会损失一条质量很高的检索信号。

下面是一篇 Kafka 文档的简化目录。

text 复制代码
Kafka Consumer
├── Consumer Group
├── Partition Assignment
├── Rebalance
│   ├── Trigger
│   ├── Process
│   └── Performance
└── Offset Management

建立 Section 索引时,不仅要保存章节标题和摘要,还要保留它位于哪篇文档、隶属于哪个父章节以及出现在原文什么位置。这样一来,Section 层既能参与检索,也能把命中的章节准确传递给下一层。

json 复制代码
{
  "section_id": "sec_018",
  "doc_id": "doc_001",
  "parent_section_id": "sec_010",
  "title": "Rebalance",
  "heading_path": [
    "Kafka Consumer",
    "Consumer Group",
    "Rebalance"
  ],
  "summary": "说明 consumer group rebalance 的触发条件、分区重新分配过程以及消费暂停行为。",
  "keywords": [
    "rebalance",
    "partition reassignment",
    "consumer pause"
  ],
  "position": 6
}

从用途上看,section_id、doc_id 和 parent_section_id 负责建立层级关系,title、heading_path、summary 和 keywords 负责表达章节语义,position 则保留章节在原文中的顺序。展开来看,各字段含义如下。

字段 含义
section_id 章节的唯一标识,用于关联所属 Chunk
doc_id 章节所属文档的 ID,也是 Document 层向下过滤的依据
parent_section_id 父章节 ID,用于还原标题树和处理多级章节
title 当前章节标题
heading_path 从文档根节点到当前章节的完整标题路径,用于补充语义上下文
summary 当前章节的内容摘要,用于章节级召回
keywords 章节中的核心术语和精确检索词
position 章节在原文中的顺序,用于排序、定位和上下文扩展

字段之间的配合比单个字段本身更重要。doc_id 让检索范围能够从 Document 层向下收缩,parent_section_id 和 position 用来恢复原文结构,heading_path 则为当前章节补充上级语义。因此,Section embedding 不应只包含章节摘要。完整标题路径能够消除大量歧义。例如"Performance"单独看没有明确语义,放到"Kafka Consumer > Consumer Group > Rebalance > Performance"下面后,检索模型才知道这里讨论的是 rebalance 性能,而不是 Broker、网络或 JVM 性能。

Section 层只在 Document 层候选文档中搜索,也就是使用 doc_id IN candidate_doc_ids 作为过滤条件。一个可行的初始窗口是向量 Top 30、BM25 Top 30,RRF 融合后保留 Top 20。这里应保留每个文档的基本配额,避免某一篇高频文档占满所有候选,让其他相关文档完全失去进入下一层的机会。

五、Chunk 层返回可引用的原文证据

Chunk 层保存真正交给 reranker 和大模型的原文。除了内容本身,它还要能够追溯到所属文档、章节和原始位置。只有保留这些信息,命中的片段才能完成去重、引用和相邻上下文扩展。

json 复制代码
{
  "chunk_id": "chunk_108",
  "doc_id": "doc_001",
  "section_id": "sec_018",
  "heading_path": [
    "Kafka Consumer",
    "Consumer Group",
    "Rebalance"
  ],
  "position": 12,
  "token_count": 624,
  "content": "During a rebalance, partitions are revoked from the current consumers before a new assignment is applied..."
}

与上两层相比,Chunk 的字段更偏向证据本身。content 保存原文,三个 ID 建立片段与文档结构的关联,heading_path 和 position 负责还原语境,token_count 用来控制进入模型的内容规模。各字段的具体含义如下。

字段 含义
chunk_id 原文片段的唯一标识,用于去重、重排和引用
doc_id 片段所属文档的 ID
section_id 片段所属章节的 ID,是第三层局部检索的过滤条件
heading_path 片段所在的完整标题路径,为短文本补充父级语义
position 片段在文档或章节中的顺序,用于读取前后相邻 Chunk
token_count 片段的 token 数,用于控制模型输入和上下文预算
content 未经摘要的原文内容,也是最终回答应引用的主要证据

这些字段会在召回之后继续发挥作用,chunk_id 用来合并主链路与全局通道中的重复结果,doc_id 和 section_id 保证扩展内容不会跨文档或跨章节,position 用来找到前后片段。到了向量化阶段,则要重点处理 content 缺少独立语境的问题。短片段容易缺少主语和上下文,只对 content 生成 embedding 时,"the current consumers"或"this process"一类指代无法独立表达语义。把文档标题和章节路径拼到原文前面,可以让 chunk 向量保留父级上下文。

text 复制代码
Document:
Kafka Consumer Architecture

Section:
Consumer Group > Rebalance

Content:
During a rebalance, partitions are revoked from the current consumers...

第三层只检索候选 Section 下的 chunk。第一版可以取向量 Top 40 和 BM25 Top 40,融合后保留 Top 30。由于这一阶段已经经过两次范围收缩,候选通常比全库检索更聚焦。不过,主链路的高精度来自上层过滤,它的漏召回也同样来自上层过滤,这就是为什么系统还需要一条并行的全局通道。

六、全局 Chunk 通道为什么不能省

假设一份 100 页的认证手册只在某个不起眼的段落中写了一句"LDAP authentication is supported"。文档摘要主要描述账号体系和单点登录,没有提 LDAP。对应章节的摘要也把这句话压缩掉了。用户询问"系统支持 LDAP 吗",严格的三层链路很可能在 Document 层或 Section 层就丢失目标,后续 chunk 检索再准确也无济于事。

全局 chunk 检索绕过 Document 和 Section 候选集,直接在整个知识库执行向量与 BM25 检索。LDAP 这种精确词会被 BM25 直接命中,目标片段因此重新进入候选池。这个通道不是异常情况下才运行的降级逻辑,而是与三层链路并行执行的固定组成部分。

全局通道的窗口不宜无限放大,候选太少无法真正兜底,候选太多又会增加 reranker 成本并引入大量噪声。可以从向量 Top 20、BM25 Top 20、融合后 Top 15 到 20 开始,再观察它为最终 Top K 带来了多少主链路之外的正确结果。

评测时最好单独记录全局通道的增量召回。若它长期没有贡献,需要检查上层画像是否已经足够完整,或者全局检索配置是否与主链路重复。若最终正确结果大量依赖全局通道,则说明 Document 或 Section 摘要可能过度压缩,候选窗口也可能太窄。全局通道既是兜底机制,也是诊断分层索引质量的一面镜子。

七、Query Understanding 不等于随意改写

用户输入通常不是标准检索语句,它可能包含口语、缩写、中英文混写和不完整的上下文。在线检索前可以生成改写查询、关键词和实体,但原始 Query 必须保留。下面的 Query Profile 不是简单保存四份近义文本,而是为向量召回、关键词召回和 metadata 处理分别准备输入。

json 复制代码
{
  "original": "Kafka rebalance 为什么会卡住消费?",
  "rewrite": "Kafka consumer group rebalance causes consumption pause",
  "keywords": [
    "Kafka",
    "rebalance",
    "consumer pause"
  ],
  "entities": [
    "Kafka"
  ]
}

这四个字段会被分配到不同的检索分支。original 保留用户真实表达,rewrite 补充规范术语,keywords 提供精确匹配信号,entities 则负责识别查询涉及的产品或组件。具体作用如下。

字段 含义
original 用户输入的原始问题,保留真实意图,也是查询改写失败时的回退输入
rewrite 规范化或补充术语后的查询,主要用于扩大向量检索的语义召回
keywords 从问题中提取的核心词、错误码、配置名和同义表达,主要供 BM25 使用
entities 查询中识别出的产品、组件或协议等实体,可用于 metadata 过滤或结果加权

有了这层拆分,后续检索就不必强迫所有通道使用同一份文本。向量检索可以组合 original 与 rewrite,借助改写后的标准术语扩大语义召回。BM25 更适合使用原始查询和抽取出的关键词,保留错误码、配置名等精确信号。实体可以用于 metadata filter 或提高特定领域候选的权重,但不宜未经校验就变成严格过滤条件,实体识别一旦出错,硬过滤会把正确结果全部挡在外面。

查询改写还要防止语义漂移。模型可能把"卡住消费"解释成网络阻塞,也可能擅自补充用户没有提到的版本和部署模式。工程上应保存改写前后的内容,为每个检索分支记录实际使用的 Query,并允许在评测中关闭改写做对照。原始 Query 不仅是回退输入,也是判断改写是否真正提升召回的基准。

BM25 依赖词项统计,向量检索依赖 embedding 空间中的距离,两者的原始分数没有天然可比性。直接把 BM25 分数和 cosine similarity 相加,结果会随语料规模、查询长度、向量模型和相似度定义改变,很难稳定调参。

RRF 不比较原始分数,只使用结果在各自列表中的排名。对于文档 (d),两个检索器的融合分数可以写成

$$

\operatorname{RRF}(d)

\frac{1}{k + r_{\text{vector}}(d)}

\frac{1}{k + r_{\text{bm25}}(d)}

$$

其中 (r_{\text{vector}}(d)) 和 (r_{\text{bm25}}(d)) 分别是文档在两个结果列表中的名次。某个检索器没有召回该文档时,对应项记为零。常数 (k) 控制排名下降速度,数值越大,靠后结果之间的分差越平缓。

Elasticsearch 当前的 RRF 文档将 rank_constant 默认值设为 60。这个数可以作为实现起点,但不意味着任何语料都应固定使用 60。另一个容易忽略的参数是候选窗口,只有进入各检索器窗口的结果才有机会参与融合,因此窗口大小往往比最后返回条数更大。窗口扩大可能改善召回,也会增加查询、融合和后续重排成本。

公式落到代码里并不复杂,向量检索和 BM25 各自返回一个已经排好序的列表,融合函数按名次遍历这些列表,把同一结果在不同列表中获得的倒数排名分累加起来,最后再按融合分数排序。下面的实现只保留这一核心过程,阅读时可以重点关注名次从 1 开始、同一列表内去重以及 rank_constant 的边界检查。

python 复制代码
from collections import defaultdict
from dataclasses import dataclass


@dataclass(frozen=True)
class SearchHit:
    item_id: str


def reciprocal_rank_fusion(
    result_lists: list[list[SearchHit]],
    rank_constant: int = 60,
) -> list[tuple[str, float]]:
    if rank_constant < 1:
        raise ValueError("rank_constant must be greater than zero")

    scores: dict[str, float] = defaultdict(float)

    for hits in result_lists:
        seen_in_list: set[str] = set()

        for rank, hit in enumerate(hits, start=1):
            # 同一检索器若意外返回重复项,只计最高名次,避免重复加权。
            if hit.item_id in seen_in_list:
                continue

            seen_in_list.add(hit.item_id)
            scores[hit.item_id] += 1.0 / (rank_constant + rank)

    return sorted(scores.items(), key=lambda item: item[1], reverse=True)

代码中的 result_lists 对应多个检索器的结果集合。在当前场景里,它通常包含向量检索结果和 BM25 结果,也可以继续加入其他召回通道。enumerate(..., start=1) 把列表位置转换成公式里的排名,scores 则负责累加同一个 item_id 在各个列表中的贡献。实现中的 seen_in_list 是一道保护,同一个检索器若意外返回重复 ID,只保留其最高名次,避免重复数据获得额外分数。

这个函数只表达 RRF 的核心行为,不绑定 Elasticsearch、OpenSearch 或某个向量数据库。实际接入时,各检索器必须先按自己的相关性完成排序,再把候选 ID 交给融合函数。融合结果返回后,还需要取业务设定的 Top K,并根据 ID 回查完整的 Document、Section 或 Chunk 数据。这样处理后,RRF 才真正位于"多路召回"和"后续 rerank"之间,而不是一段孤立的排序代码。

RRF 的优势是稳健和容易落地,代价是它丢弃了原始分数间距。排名第一和第二的得分可能非常接近,也可能差距巨大,RRF 会把它们当成相邻名次处理。如果系统拥有可靠的分数校准和足够评测数据,可以尝试归一化加权融合。没有这些前提时,RRF 通常是比直接相加更安全的起点。OpenSearch 的官方混合检索文档也将 RRF 作为基于排名的融合方式,用来规避不同分数尺度的问题。

九、Rerank 负责最后的相关性判断

经过三层链路和全局通道后,系统通常会得到 30 到 50 个候选 chunk。RRF 适合融合不同检索器,却不会细读 Query 与片段之间的语义关系,最终排序应交给专门的 reranker。

Cross-Encoder 会把 Query 和候选文本作为一对输入共同编码,再输出相关性分数。它比独立生成 Query embedding 和 chunk embedding 更能捕捉词语之间的细粒度交互,但也无法像向量检索那样对全库高效扫描。因此,常见设计是先用 BM25 和向量检索缩小候选集,再用 Cross-Encoder 精排。Sentence Transformers 的 retrieve-and-rerank 文档采用的也是这种两阶段思路。

重排输入不要只放 chunk 正文,标题路径能够帮助模型判断片段处于什么语境,文档标题还能区分相同术语在不同系统里的含义。

text 复制代码
Query:
Kafka rebalance 为什么会卡住消费

Candidate:
[Document] Kafka Consumer Architecture
[Section] Consumer Group > Rebalance > Process
[Content] During a rebalance, partitions are revoked...

候选合并后要先按稳定 ID 去重。三层主链路和全局通道可能返回同一个 chunk,重复项既浪费 reranker 推理,也会在最终上下文里制造虚假的证据密度。若知识库中存在内容相同但 ID 不同的副本,还可以增加基于内容哈希的近重复检测,不过要保留来源和版本信息,不能为了去重丢失可追溯性。

最终保留 5 到 10 个 chunk 是常见起点,不是固定答案。事实型短问答可能只需要 3 个高质量片段,跨文档对比则可能需要更多。选择数量时应看答案是否有足够证据,也要看上下文 token、生成延迟和"无关信息干扰回答"的比例。

十、Context Expansion 修复切块边界

reranker 命中的 chunk 可能从句子中间开始,也可能只包含结论,不包含条件和例外。直接把孤立 chunk 塞给大模型,会让答案看似有依据,实际却缺少完整语境。

上下文扩展可以按原始位置取前后相邻 chunk。命中 chunk_108 时,同时加载 chunk_107 和 chunk_109。另一种做法是追加父 Section 的摘要,章节较短时,可以直接返回完整章节,但必须设置 token 上限,防止一个大章节挤掉其他证据。

扩展动作应发生在 rerank 之后。若在候选召回阶段就给每个 chunk 扩展邻居,30 个候选可能迅速膨胀为 90 个片段,重排成本和噪声都会上升。先找出最相关的中心 chunk,再围绕少量结果补上下文,成本更可控。

相邻并不总等于相关,表格、代码块、FAQ 和多级列表可能被解析成多个节点,简单按位置加一减一会把不同结构拼在一起。因此,chunk 数据除了 position,最好还保留内容类型、父 Section 和文档修订版本。扩展时至少校验 doc_id、section_id 和版本一致,避免跨章节或跨版本拼接。

十一、索引模型与离线建库

离线阶段决定了在线检索的上限。最少需要 Document、Section 和 Chunk 三类逻辑对象。它们可以落在三张表、三个搜索索引,也可以由同一搜索引擎中的不同文档类型承载,关键是父子关系、过滤字段和版本信息必须完整。

对象 核心字段 主要用途
Document doc_id、title、summary、topics、keywords、entities、questions_answered、source_url、updated_at、embedding 文档级主题判断与来源追踪
Section section_id、doc_id、parent_section_id、title、heading_path、summary、keywords、position、embedding 章节定位与层级过滤
Chunk chunk_id、doc_id、section_id、content、position、token_count、content_type、revision、embedding 原文召回、重排与上下文扩展

Wiki 场景还应保存 page_id、namespace 和 revision。权限信息也不能只留在生成阶段处理。若用户无权访问某篇文档,所有检索层都应在召回时应用访问控制过滤,不能先取回敏感内容再期待大模型不输出。

离线流水线可以按下面的顺序执行。

text 复制代码
原始文件或 Wiki 页面
        │
        ▼
解析与清洗
        ├─ 标题层级
        ├─ 表格和代码块
        ├─ metadata
        └─ ACL 与 revision
        │
        ▼
结构感知分块
        │
        ├─ Document 检索画像
        ├─ Section 摘要与关键词
        └─ Chunk 原文与父级路径
        │
        ▼
Embedding 生成
        │
        ├─ Document embedding
        ├─ Section embedding
        └─ Chunk embedding
        │
        ▼
Vector Index + BM25 Index

Document 摘要、Section 摘要、关键词、实体和可回答问题可以由大模型离线生成,但生成结果需要版本化。源文档更新后,受影响的 Section 和 Chunk 必须重建,文档画像也要同步刷新。只更新原文索引、不更新摘要索引,会让上层候选长期指向过期主题。

结构感知分块优先按照 Heading、段落、表格和代码块边界切分,不要机械地每隔固定 token 截断。Microsoft Azure Architecture Center 的 RAG chunking 指南同样强调按文档结构与语义边界组织 chunk。对于技术文档,可以先从目标 400 到 800 tokens、最大 1000 到 1200 tokens、重叠 50 到 100 tokens 开始,但应尽量避免跨 Heading。若一个结构完整的章节只有 900 tokens,让它保持为单个 chunk 往往比强行切成两段更合理。

这些数值受 embedding 模型输入长度、reranker 上限、文档语言和内容类型影响。代码、表格和 API 字段说明的信息密度与自然语言不同,统一尺寸通常不是最佳选择。更重要的判断标准是一个 chunk 能否独立表达可检索的事实,同时又不会混入多个无关主题。

十二、在线检索如何实现

下面的 Python 代码是实现中立的架构伪代码。它省略了具体搜索引擎 SDK 和模型客户端,但保留了在线链路中不能省略的候选约束、权限过滤、去重和上下文扩展步骤。

python 复制代码
from dataclasses import dataclass
from typing import Protocol, Sequence


@dataclass(frozen=True)
class QueryProfile:
    original: str
    rewrite: str
    keywords: tuple[str, ...]
    entities: tuple[str, ...]


@dataclass(frozen=True)
class Candidate:
    item_id: str
    doc_id: str
    section_id: str | None
    content: str
    score: float


class Retriever(Protocol):
    def search_documents(
        self,
        query: QueryProfile,
        acl_filter: set[str],
        vector_top_k: int,
        bm25_top_k: int,
        final_top_k: int,
    ) -> Sequence[Candidate]: ...

    def search_sections(
        self,
        query: QueryProfile,
        doc_ids: set[str],
        acl_filter: set[str],
        vector_top_k: int,
        bm25_top_k: int,
        final_top_k: int,
    ) -> Sequence[Candidate]: ...

    def search_chunks(
        self,
        query: QueryProfile,
        acl_filter: set[str],
        vector_top_k: int,
        bm25_top_k: int,
        final_top_k: int,
        section_ids: set[str] | None = None,
    ) -> Sequence[Candidate]: ...


class Reranker(Protocol):
    def rank(
        self,
        query: str,
        candidates: Sequence[Candidate],
    ) -> Sequence[Candidate]: ...


def deduplicate(candidates: Sequence[Candidate]) -> list[Candidate]:
    best_by_id: dict[str, Candidate] = {}

    for candidate in candidates:
        current = best_by_id.get(candidate.item_id)
        if current is None or candidate.score > current.score:
            best_by_id[candidate.item_id] = candidate

    return list(best_by_id.values())


def retrieve(
    query: str,
    allowed_acl_ids: set[str],
    retriever: Retriever,
    reranker: Reranker,
) -> Sequence[Candidate]:
    profile = understand_query(query)

    documents = retriever.search_documents(
        query=profile,
        acl_filter=allowed_acl_ids,
        vector_top_k=20,
        bm25_top_k=20,
        final_top_k=10,
    )

    sections = retriever.search_sections(
        query=profile,
        doc_ids={item.doc_id for item in documents},
        acl_filter=allowed_acl_ids,
        vector_top_k=30,
        bm25_top_k=30,
        final_top_k=20,
    )

    local_chunks = retriever.search_chunks(
        query=profile,
        section_ids={
            item.section_id
            for item in sections
            if item.section_id is not None
        },
        acl_filter=allowed_acl_ids,
        vector_top_k=40,
        bm25_top_k=40,
        final_top_k=30,
    )

    global_chunks = retriever.search_chunks(
        query=profile,
        section_ids=None,
        acl_filter=allowed_acl_ids,
        vector_top_k=20,
        bm25_top_k=20,
        final_top_k=20,
    )

    candidates = deduplicate([*local_chunks, *global_chunks])
    ranked = reranker.rank(profile.original, candidates)

    # 扩展只围绕少量高相关结果执行,避免候选集提前膨胀。
    return expand_context(
        ranked[:8],
        neighbor_chunks=1,
        max_context_tokens=8_000,
    )

代码中的 Retriever 把底层实现抽象掉了,但每个方法内部仍需要分别执行向量检索和 BM25,再用 RRF 得到 final_top_k。Document 和 Section 候选使用集合传入下一级,能够明确表达过滤边界。全局 chunk 请求把 section_ids 设为 None,因此不会被主链路候选限制。

ACL 过滤出现在每一个检索方法中,而不是最后合并后才执行。这既能防止无权限数据进入中间结果,也避免文档级候选被无权访问的高相关内容占满。真实系统还需要把租户、语言、数据源状态和 revision 加入过滤条件。

deduplicate 选择同一 ID 中得分更高的候选,只是一个基础实现。不同分支的分数若不可比,去重时应优先保留完整的来源标记,再让 reranker 重新评分,而不是依赖这里的 score。另外,understand_query 和 expand_context 需要有超时、错误处理和可观测性。生产代码不应在查询改写失败时伪造成功结果,可以明确回退到仅使用原始 Query,并记录回退原因。

十三、第一版参数如何设置

下面这组数值适合作为中等规模企业知识库的实验起点。它们来自候选集逐层收缩的思路,不是跨数据集通用的最优参数。

阶段 初始参数
Document Vector Top 20
Document BM25 Top 20
Document 融合后 Top 10
Section Vector Top 30
Section BM25 Top 30
Section 融合后 Top 20
Chunk Vector Top 40
Chunk BM25 Top 40
Chunk 融合后 Top 30
Global Chunk Top 15 到 20
Reranker 输入 30 到 50
最终中心 Chunk Top 5 到 10

调参时不要一次修改所有窗口,先确认正确文档能否进入 Document Top K,再检查正确 Section,最后看目标 chunk 是否进入 reranker。若 Document Recall 已经很高而最终答案仍然错误,继续扩大 Document Top K 通常没有意义,应检查章节摘要、chunk 边界、reranker 或生成提示词。

延迟预算也应按阶段拆开,Query Understanding、四路检索、reranker 和上下文读取分别记录 P50、P95 和错误率。主链路与全局 chunk 检索可以并行,其中全局通道不依赖 Document 和 Section 结果。Document、Section、Chunk 三层内部则存在数据依赖,无法简单并行。若延迟压力较大,可以缓存文档画像查询、使用更轻量的改写模型,或根据查询类型跳过不必要的步骤,但不能用静默超时换取表面上的成功响应。

十四、评测要定位到每一层

只看最终回答"感觉不错"无法调优多阶段检索。评测集至少要包含 Query、相关文档、相关 Section、证据 chunk 和参考答案。错误码、缩写、口语问法、跨文档对比、否定问题和摘要未覆盖的长尾细节都应单独采样。

检索层可以关注以下指标。

指标 用途
Document Recall@K 正确文档是否进入第一层候选
Section Recall@K 正确章节是否通过上层约束进入候选
Chunk Recall@K 证据片段是否进入 reranker
MRR 首个相关结果是否足够靠前
nDCG@K 多个不同相关度结果的整体排序质量
Global Incremental Recall 全局通道额外找回了多少正确 chunk
Reranker Lift 重排前后 Top K 相关性提升

生成层还应评测答案正确率、证据一致性、引用准确性和拒答质量。回答措辞流畅,不代表它受检索证据支持。对于知识库中不存在的信息,系统能否明确说无法确认,与正确回答已有知识同样重要。

离线指标之外还要记录在线成本。候选窗口扩大后,Recall@K 可能上升,但 reranker 推理次数、搜索延迟和上下文 token 也会增加。最终目标不是单独最大化召回,而是在准确率、延迟和成本约束下找到可接受的工作点。

最有效的调优方式是按错误发生的位置分类。目标 Document 没进入候选时,检查查询改写、文档画像和第一层窗口。Document 正确但 Section 错误时,检查标题路径、章节摘要和每文档配额。目标 chunk 已进入候选但被 reranker 排低时,检查重排模型、输入格式和负样本。证据正确而答案错误时,再处理提示词、上下文顺序和生成模型。这样才能避免用扩大 Top K 掩盖所有问题。

十五、工程落地中的边界与误区

三层检索不是文档层级越多越好。短 FAQ、工单摘要和字段字典可能没有稳定的 Section 结构,强行构造三层索引只会增加维护成本。可以按数据源选择策略,长篇 Wiki 和手册使用三层链路,短文档直接进入 Document 与 Chunk 两层,极短条目则作为单个可检索单元。

摘要不能成为事实来源,Document 和 Section 摘要的作用是导航,最终回答应尽量引用原文 chunk。生成摘要时出现的遗漏可以由全局通道补救,出现的幻觉则可能把检索引向错误文档,因此摘要生成需要抽样检查、版本记录和可重建能力。

层级过滤不应过早绑定不可靠信号。实体、语言识别和 query intent 都可能出错。除 ACL、租户和明确数据范围外,其他条件优先作为加权或软过滤信号。用户明确指定文档、产品或版本时,才适合收紧范围。

上下文扩展也不是越多越安全。相邻 chunk 可能带入过期配置、另一版本说明或与答案无关的例子。扩展逻辑需要检查父节点和 revision,并在 token 预算内按相关性组装。对同一 Section 的多个命中,应先合并连续区间,避免重复加载邻居。

最后,索引更新必须保持三层一致。文档删除时要同步移除 Section、Chunk、向量和关键词索引。文档修改时要能判断哪些节点受影响,并使旧 revision 尽快退出检索。若采用异步流水线,需要暴露索引状态,避免用户看到原文已经更新而 RAG 仍在回答旧内容。

十六、总结

这套方案把企业知识库检索拆成了两个互补部分。Document、Section、Chunk 三层主链路利用文档结构逐步缩小范围,解决全库 chunk 检索容易混淆主题的问题。全局 chunk 通道绕过摘要和上层候选,为错误码、配置项、边缘能力和长尾事实保留直接命中的机会。两条链路都使用 BM25 与向量检索,通过 RRF 融合不同排名,再由 Cross-Encoder 精排,最后围绕少量中心 chunk 扩展上下文。

落地时可以先完成最小闭环。建立三类索引,保留父子关系和标题路径,给每层接入 BM25、向量检索和 RRF,再并行加入全局 chunk 通道。随后用分层评测集检查正确结果究竟在哪一步丢失,而不是盲目扩大所有 Top K。等检索链路稳定后,再优化摘要生成、查询改写、每文档配额、reranker 和上下文预算。

真正决定系统质量的不是"三层"这个形式,而是每一层是否有明确职责,硬过滤是否有召回兜底,参数是否经过可复现的评测。做到这三点后,企业 Wiki 中的主题定位、章节导航和原文证据才能形成一条可解释、可调优、也能长期维护的检索链路。

相关推荐
算家云1 小时前
doubao-seed-2-1 系列模型上架算桥 API | 多模态理解能力继续加强!
人工智能·api·token·算力租赁·算力平台
程序员清风1 小时前
企业级 AI 中台架构:模型、知识库、Agent 与业务系统
人工智能·架构
甲维斯1 小时前
“凤雏”MiniMax3.1和 “太空兔”一锅炖了吧!!
人工智能
ADAI_Bowen1 小时前
建筑可视化的 AI 可控生成路径:渲境 AI 技术原理与工程应用要点
人工智能
天空'之城1 小时前
多智能体通信灾难:消息泛滥如何拖垮整个 Agent 系统
大数据·人工智能
赤龙ERP1 小时前
AI+ERP:Agent 进企业,卡在数据和权限这两道坎
人工智能·erp·企业数字化
智圣新创011 小时前
地方应用型高校智慧校园效能跃升 智圣新创数据治理全链路落地参考框架
人工智能
老饼讲解-BP神经网络1 小时前
一篇详解-BP神经网络matlab实现例子与方法(newff)
人工智能·神经网络·matlab
没落之王1 小时前
AI在文言文——论文脉复苏对AI的意义
大数据·人工智能·算法