知识库AgentRAG 检索增强RAG 检索增强文档解析、切分与索引文档解析、切分与索引
02 · RAG 检索增强RAG 检索增强
Roadmap 02核心Markdown72 min

文档解析、切分与索引文档解析、切分与索引

从可追溯证据单元出发,系统掌握解析、Chunking、Embedding、混合索引、版本、ACL 与幂等重建。从可追溯证据单元出发,系统掌握解析、Chunking、Embedding、混合索引、版本、ACL 与幂等重建。

#ChunkingChunking#EmbeddingEmbedding#IndexingIndexing#ACLACL更新于 2026-08-16

专题导读

RAG 的索引阶段不是“把 PDF 转成向量”,而是把不断变化、权限各异、格式复杂的业务资料,转换成可检索、可授权、可定位、可重建的证据单元。检索与生成只能消费索引提供的信息:解析时丢失表头,模型就无法恢复表头;chunk 没有来源位置,引用就无法审计;ACL 没有进入索引,查询期就可能泄露数据。

面试中应从 Java 后端系统视角回答:输入是带版本与权限的文档事件,输出是一个不可变、可切换的索引快照;中间经历解析、规范化、切分、嵌入、写倒排/向量索引、校验和发布。重点不只是算法,还包括幂等、重试、版本迁移、删除语义、成本与观测。

知识地图

TEXT
数据源
  └─ 采集:快照、CDC、消息、定时扫描
      └─ 解析:正文 / 标题 / 表格 / 代码 / OCR / 来源坐标
          └─ 规范化:去模板、去重、语言识别、内容哈希
              └─ Chunking:结构边界 / token 预算 / overlap / parent-child
                  ├─ 倒排索引:词项、字段、BM25 所需统计量
                  └─ 向量索引:embedding、维度、距离度量、ANN
                      └─ 发布:indexVersion、校验、原子切流、回滚
                          └─ 治理:tenant、ACL、保留期、删除、审计

一条合格的 chunk 至少能回答六个问题:内容是什么、来自哪个文档和位置、属于哪个版本、谁能看、由哪套解析/切分/embedding 配置生成、如何被更新或删除。

面试题

Q1:RAG 索引管道的正确抽象是什么?为什么不能把向量库当数据库导入工具?

核心回答: 索引管道是一个版本化的数据加工系统:把源文档快照转换为可追溯证据单元,并同时写入适合精确匹配的倒排索引与适合语义匹配的向量索引。它必须支持确定性重放、增量更新、全量重建、权限过滤和删除审计。

源文档和索引记录不是一一对应。一份文档会解析为结构块,再生成多个 chunk;每个 chunk 还要附加 documentIdsourceRevision、位置、ACL、内容哈希和 indexVersion。因此写入成功不能只表示“向量 upsert 成功”,而要表示该文档版本对应的所有目标索引都达到一致的可发布状态。

工程上通常分为:

  1. 采集层保存来源标识和不可变修订号,避免扫描过程中内容变化。
  2. 解析层生成带结构与坐标的中间表示,而不是立刻拼成纯文本。
  3. 切分层由版本化策略产生 chunk,并计算稳定 ID。
  4. 特征层批量生成 embedding,同时准备倒排字段。
  5. 写入与校验层写入构建中的索引版本,核对数量、失败率、抽样内容和权限字段。
  6. 发布层切换读别名或版本路由;旧版本在回滚窗口后回收。

面试追问:索引管道适合用同步 HTTP 串起来吗?

小规模管理操作可以同步触发,但解析、OCR、embedding 和批量写入通常应异步化。队列只能传递任务,源快照、阶段状态与幂等键仍应持久化,否则消息重投会产生重复或半成品。

常见错误回答: “把文档上传到向量库,向量库会自动切分并保证答案正确。”这忽略了解析质量、权限、版本一致性与可重现性,也把供应商默认配置误当成业务语义。

Q2:复杂文档应该如何解析,哪些信息一旦丢失就难以补救?

核心回答: 解析结果应是带类型、层级与来源坐标的结构块,而不只是纯文本。标题层级、段落、列表、表格、代码、页码、字符偏移和 OCR 置信度都可能影响切分、引用和质量诊断。

表格应保留表头与单元格关系;跨页表格要继承表头,不能只按视觉顺序抽取数字。代码应保留语言、类/方法边界与缩进。扫描件应记录 OCR 引擎版本和置信度,低置信区域进入人工复核或独立失败队列。网页要去掉导航、页脚、Cookie 提示等模板噪声,但去重规则不能误删正文中的重复条款。

来源定位常保存为 pageNumber + boundingBox、DOM 路径或字符区间。引用 URL 只是入口,不足以定位证据。解析器升级也可能改变内容,所以 parserVersion 必须进入索引构建配置。

面试追问:为什么不先把所有格式统一成 Markdown?

Markdown 可作为中间展示格式,但不能表达所有布局与坐标语义。转换时仍需旁路保存表格结构、页码、原始对象 ID 等字段,才能准确引用和重新切分。

常见错误回答: “PDF 按页转文本即可。”页是排版单位,不一定是语义单位;页眉页脚会重复,段落和表格还可能跨页。

Q3:固定长度、递归切分、结构感知切分和语义切分如何选择?

核心回答: 没有全局最优策略。选择标准是“一个 chunk 能否在不过度引入噪声的前提下独立支撑真实问题”,最终由离线检索评测和生成评测决定。

策略 机制 优点 主要边界
固定 token 窗口 每 N 个 token 切分并 overlap 简单、吞吐稳定 容易切断问答、表格和代码;字符数不等于 token 数
递归切分 依次尝试章节、段落、句子等分隔符 通用、实现成本低 只尊重表面分隔符,不保证语义完整
结构感知 按标题、列表、表格、类/方法等节点切分 可解释、引用稳定 依赖高质量 parser;超大节点仍需二次切分
语义切分 根据相邻句向量变化寻找边界 可发现无显式标题的主题变化 需要额外 embedding,阈值敏感,重建成本高
parent-child 小块负责命中,大块负责返回 兼顾召回粒度与上下文完整性 需要两级映射、去重和预算控制

实践中常用“结构优先、token 上限兜底”:先按章节或代码块形成候选节点,过大节点再按段落/句子递归切分。不要用 UTF-8 字节数或任意字符数冒充模型 token 预算;若无法使用目标 tokenizer,应把估算误差纳入安全余量并记录截断。

面试追问:语义切分一定优于固定窗口吗?

不一定。语义边界检测会增加成本和不稳定性;对规则清晰的 API 文档,标题/方法边界可能更可靠。必须在同一金标问题集上比较 Recall@K、重复率、索引体积和端到端成本。

常见错误回答: “统一切成 500 字,重叠 100 字。”参数没有目标 tokenizer、文档类型和评测依据,不能迁移到不同语料。

Q4:Chunk 大小和 overlap 如何影响召回、生成与成本?

核心回答: chunk 太小会导致证据不完整和术语失去限定条件;太大会混入多个主题,降低匹配精度并消耗上下文。overlap 能缓解边界断裂,但会放大索引体积、embedding 调用量、候选重复和生成 token。

设文档 token 数为 T,窗口大小为 C,相邻重叠为 O,且 C > O。对空文档 T=0,chunk 数为 0;对非空短文档 0<T≤C,chunk 数为 1;对 T>C,忽略其他结构边界时大致为:

N ≈ 1 + ceil((T - C) / (C - O))

也可把非空文档写成 N ≈ max(1, 1 + ceil((T - C)/(C - O)))。步长是 C - O。当 O 接近 C 时,数量会快速上升。例如 T=10000C=500:无重叠约 20 块,O=100 约 25 块,O=400 约 96 块。embedding、存储和检索去重成本也近似随 N 增长。

调参时至少观察:证据覆盖、重复候选率、平均返回 token、索引字节数、embedding 次数与 P95 入库时延。对表格、代码和法律条款应优先保持原子结构,而不是为了统一大小强切。

面试追问:为什么 chunk 越大,向量召回未必越好?

embedding 把整段压缩为一个定长向量。混合多个主题后,单个局部事实的信号可能被稀释;查询与长 chunk 的整体语义也未必接近。

常见错误回答: “上下文窗口很大,所以 chunk 也应该尽量大。”模型可接收不代表检索能精确命中,也不代表长上下文中证据一定被利用。

Q5:什么是 parent-child 或 small-to-big 索引?

核心回答: 用较小 child chunk 建索引以提高召回精度,命中后再返回其 parent 段落、章节或相邻窗口,为生成提供完整语境。索引粒度和返回粒度因此解耦。

每个 child 要保存 parentId、位置区间和稳定顺序。查询期先对 child 排名,再按 parent 聚合、去重和扩展;扩展必须在 ACL 过滤后进行,并重新计算 token 预算。若一个 parent 的十个 child 同时命中,不能机械地重复放入整章。

parent 不是越大越好。常见层级是“句群 → 段落/小节”,而不是“句子 → 整份 200 页手册”。对于 FAQ,问题和答案应作为一个语义单元;对于 Java 代码,方法签名、实现和紧邻说明通常要保持关联。

面试追问:只存 parent 向量可以吗?

可以作为基线,但会失去小粒度命中优势。也可以同时存 parent 和 child,查询期做多粒度召回;代价是索引、融合和版本管理更复杂。

常见错误回答: “命中一句后直接把整篇文档放进 prompt。”这会突破预算、引入无关或越权段落,并放大提示注入风险。

Q6:Embedding 模型与向量索引有哪些必须一致的契约?

核心回答: 至少包括模型标识与修订、输入前缀/模板、向量维度、归一化方式、距离度量、最大输入 token、截断策略和语言适用范围。这些共同定义一个向量空间。

余弦相似度为 cos(a,b) = (a·b) / (||a|| ||b||);若向量已做 L2 归一化,点积与余弦排序等价。若索引按点积构建但写入端有时归一化、有时不归一化,分数语义会漂移。不同模型、不同维度或不同输入模板生成的向量不能混在同一空间直接比较。

ANN(近似最近邻)索引用精度换延迟和内存。构建参数与查询参数要通过标注集调节,并用精确检索小样本或已知邻居评估 ANN recall;“向量相似”也不等于事实相关,查询期仍需混合召回和重排。

面试追问:模型升级能否只给新文档用新 embedding?

通常不能直接混用。应建立新 indexVersion 全量或有计划地重嵌入,再切换读流量;迁移期可以双写、影子查询,但要隔离评分空间。

常见错误回答: “向量维度相同就可以混用。”维度相同不表示坐标语义相同。

Q7:为什么生产 RAG 往往同时建设倒排索引与向量索引?

核心回答: 两者解决不同匹配问题。倒排索引/BM25 对错误码、类名、配置项、订单号和罕见实体等精确词项更稳;稠密向量能覆盖同义改写和自然语言语义。索引阶段应为两条路径保留一致的 chunk、版本和 ACL 元数据,查询期再融合。

倒排字段要区分:全文字段可分词;documentId、错误码、Java 全限定类名、版本号等应提供 keyword/精确字段,避免分析器把标识符拆坏。向量字段要固定维度与距离。两套索引的 chunkId 必须可关联,否则查询期无法去重与生成可信引用。

双索引不是要求跨两个独立系统做分布式事务。更稳妥的做法是把目标版本标记为 BUILDING,幂等写入各索引,完成后做数量与抽样校验,只有一致快照才能变为 READY 并接收读流量。

面试追问:为什么不直接把 BM25 分数和余弦分数相加?

两者量纲、分布和查询间可比性都不同。直接相加需要校准;常见做法是在查询期使用 RRF 等秩融合,具体见检索专题。

常见错误回答: “向量检索是 BM25 的升级版。”向量并不会天然保留精确字符串匹配能力,两者是互补关系。

Q8:索引记录应保存哪些元数据?哪些字段属于安全边界?

核心回答: 元数据应服务于追溯、过滤、版本、生命周期和质量诊断。最小集合通常包括:

  • 标识:documentIdchunkIdparentIdsourceRevision
  • 来源:sourceUri、标题路径、页码/偏移、内容哈希;
  • 权限:tenantId、资源域、允许的主体/角色或策略引用、aclVersion
  • 生命周期:状态、业务生效时间、过期时间、删除标记;
  • 构建版本:parserVersionchunkerVersionembeddingVersionindexVersion
  • 诊断:语言、token 估算、OCR 置信度、写入时间。

tenantId 与 ACL 字段是安全边界的一部分,必须由可信服务端写入,不能接受文档正文或模型自行声明。索引后端必须支持相应过滤,并在把候选交给 reranker 或 LLM 之前执行。安全要求还包括日志、trace、缓存和死信中不泄露无权正文。

面试追问:把完整 ACL 列表复制到每个 chunk 是否合理?

小型、低变更 ACL 可以反规范化以提高查询速度;大型或高频变更 ACL 会导致写放大,可保存策略/资源域引用并由检索网关求交。选择取决于后端过滤能力、一致性要求和变更频率。

常见错误回答: “先全局召回 top 100,再在应用层过滤就安全。”无权内容可能已进入检索响应、日志或重排模型,而且过滤后候选不足会造成质量偏差。

Q9:文档版本、索引版本与 ACL 版本有什么区别?

核心回答: 三者变化原因和发布节奏不同:

  • sourceRevision 表示业务文档某次不可变修订;
  • indexVersion 表示一套解析、切分、embedding、schema 和索引参数构建出的快照;
  • aclVersion 表示访问策略修订,用于缓存失效、审计与并发更新判定。

同一文档修订可被多个索引版本处理,用于 A/B 或模型升级;同一内容也可能只变 ACL 而无需重算 embedding。查询 trace 应记录读到的 indexVersion 与 ACL 决策版本,缓存键至少包含租户、权限上下文摘要和索引版本。

发布新索引时可采用蓝绿流程:构建 vNext、离线评估与数据校验、影子读、原子切换别名、保留 vPrev 回滚窗口。不要在原索引上原地批量重写后声称可回滚。

面试追问:配置版本只存一个字符串够吗?

版本字符串用于路由,但还要保存不可变 manifest:解析器、切分参数、embedding 模型、索引 schema、代码提交与数据快照时间。否则无法复现。

常见错误回答: “文档更新时间就是索引版本。”更新时间既不能表示处理配置,也不能保证全局快照一致。

Q10:如何实现增量更新、幂等重试与失败恢复?

核心回答: 以不可变源修订和构建版本组成幂等键,例如 (tenantId, documentId, sourceRevision, indexVersion);chunk ID 由该上下文、结构路径和内容哈希确定性生成。阶段状态要持久化,重试只重做失败阶段。

内容哈希可跳过未变化正文的解析或 embedding,但要注意:parser/chunker/embedding 版本变化时,即使内容不变也必须重建;ACL 变化则可只更新权限字段。批处理要记录单文档或单 chunk 失败,不能因为 1 条失败就盲目重跑整个批次。

写入通常使用 upsert,发布前校验“期望 chunk 集合”与“实际 chunk 集合”。对源文档重新切分后消失的旧 chunk,要通过本次 manifest 差集删除,而不是只写新 chunk 留下幽灵证据。

面试追问:消息队列至少一次投递会导致重复吗?

会重投,但确定性 ID、条件状态迁移和幂等 upsert 能把重复处理转成同一结果。幂等不等于全链路 exactly-once;外部 embedding 调用仍可能重复计费,应结合请求去重能力、阶段检查点和成本监控。

常见错误回答: “消费成功后提交 offset,所以不会重复。”进程可能在外部写入成功后、提交 offset 前崩溃。

Q11:删除、保留期和“被遗忘权”如何形成闭环?

核心回答: 删除是一条可审计工作流,不是一条向量删除语句。要识别所有派生物:倒排记录、向量、parent/child 映射、查询缓存、embedding 缓存、离线评测快照、消息和备份保留策略。

在线系统可先写 tombstone 或把文档状态改为不可检索,立即阻断查询;随后异步物理删除各派生存储。删除任务保存 deletionId、范围、发起者、策略依据、各存储完成状态和验证结果。备份通常不能任意原地删单条,需要通过加密密钥销毁、到期淘汰和恢复后重放删除日志来满足既定政策,具体要求由组织的合规与数据治理规则决定。

删除与重建并发时,新版本构建器必须读取删除水位或 tombstone,避免已删除文档在新索引“复活”。

面试追问:为什么只从主库删除不够?

RAG 索引是派生数据,异步任务、缓存和旧索引版本仍可能持有副本;必须有资产清单和最终验证。

常见错误回答: “向量库 delete 返回 200 就完成合规删除。”这没有覆盖其他副本,也没有证明候选不再可见。

Q12:如何验证一个新索引版本可以发布?

核心回答: 同时做数据完整性、检索质量、安全与运行成本校验,不能只看写入成功率。

数据校验包括文档数/chunk 数、孤儿 child、重复 ID、空文本、异常超长、向量维度、非有限数值、来源定位可回放和双索引集合一致性。质量校验使用固定金标集比较 Recall@K 等指标,并按文档类型、语言、时间和租户切片。安全校验必须用正负权限样本证明无权 chunk 不进入候选、缓存、trace 与引用。运行校验包括构建吞吐、失败率、embedding 调用量、索引体积和查询 P95。

发布应有明确门禁和回滚条件。若只提升平均 Recall 却使某个高风险租户发生 ACL 回归,不能上线。

面试追问:索引质量下降首先看哪里?

先对比 manifest 与数据分布,再定位解析、切分、embedding、ANN 或过滤中的变化。不要直接靠调大 top-K 掩盖上游缺失。

常见错误回答: “随机抽十条看起来没问题即可发布。”抽样可发现格式问题,但不能代替回归指标和权限负测试。

Java 21 完整示例:结构感知切分与幂等索引任务

下面示例只用 Java 21 标准库。它不实现具体向量库或搜索引擎,而用接口抽象外部系统;重点展示结构块、有限 overlap、确定性 chunk ID、版本化元数据和幂等 upsert。真实系统的 token 数应由目标模型 tokenizer 计算,此处使用 Unicode code point 作为演示预算单位,不能把它当成真实 token 数。

JAVA
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Objects;

public final class RagIndexingExample {

    public record SourceBlock(
            String documentId,
            String sourceRevision,
            String sectionPath,
            int ordinal,
            String text) {
        public SourceBlock {
            Objects.requireNonNull(documentId);
            Objects.requireNonNull(sourceRevision);
            Objects.requireNonNull(sectionPath);
            Objects.requireNonNull(text);
        }
    }

    public record Chunk(
            String chunkId,
            String documentId,
            String sourceRevision,
            String sectionPath,
            int blockOrdinal,
            int partOrdinal,
            String text,
            String tenantId,
            String aclVersion,
            String indexVersion,
            String contentHash) {
    }

    public interface EmbeddingClient {
        /** 返回值顺序必须与输入顺序一致;维度由具体模型契约固定。 */
        List<float[]> embed(List<String> texts);
    }

    public interface SearchIndex {
        /** chunkId 是幂等主键;实现方必须校验 indexVersion 与向量维度。 */
        void upsert(List<Chunk> chunks, List<float[]> vectors);
    }

    public static List<Chunk> chunkBlocks(
            List<SourceBlock> blocks,
            String tenantId,
            String aclVersion,
            String indexVersion,
            int maxCodePoints,
            int overlapCodePoints) {

        if (maxCodePoints <= 0 || overlapCodePoints < 0
                || overlapCodePoints >= maxCodePoints) {
            throw new IllegalArgumentException("Require 0 <= overlap < max");
        }

        List<Chunk> result = new ArrayList<>();
        int step = maxCodePoints - overlapCodePoints;

        for (SourceBlock block : blocks) {
            int[] codePoints = block.text().codePoints().toArray();
            if (codePoints.length == 0) {
                continue;
            }

            int part = 0;
            for (int start = 0; start < codePoints.length; start += step) {
                int end = Math.min(start + maxCodePoints, codePoints.length);
                String text = new String(codePoints, start, end - start).strip();
                if (!text.isEmpty()) {
                    String contentHash = sha256(text);
                    String stableKey = String.join("\u001f",
                            tenantId,
                            block.documentId(),
                            block.sourceRevision(),
                            indexVersion,
                            block.sectionPath(),
                            Integer.toString(block.ordinal()),
                            Integer.toString(part),
                            contentHash);
                    result.add(new Chunk(
                            sha256(stableKey),
                            block.documentId(),
                            block.sourceRevision(),
                            block.sectionPath(),
                            block.ordinal(),
                            part,
                            text,
                            tenantId,
                            aclVersion,
                            indexVersion,
                            contentHash));
                }
                part++;
                if (end == codePoints.length) {
                    break;
                }
            }
        }
        return List.copyOf(result);
    }

    public static void indexDocument(
            List<SourceBlock> parsedBlocks,
            String tenantId,
            String aclVersion,
            String indexVersion,
            EmbeddingClient embeddingClient,
            SearchIndex searchIndex) {

        List<Chunk> chunks = chunkBlocks(
                parsedBlocks, tenantId, aclVersion, indexVersion, 800, 80);
        List<String> texts = chunks.stream().map(Chunk::text).toList();
        List<float[]> vectors = embeddingClient.embed(texts);

        if (vectors.size() != chunks.size()) {
            throw new IllegalStateException("Embedding result count mismatch");
        }
        int dimension = vectors.isEmpty() ? 0 : vectors.getFirst().length;
        boolean invalid = vectors.stream().anyMatch(vector ->
                vector.length != dimension || !allFinite(vector));
        if (invalid) {
            throw new IllegalStateException("Invalid embedding batch");
        }

        searchIndex.upsert(chunks, vectors);
    }

    private static boolean allFinite(float[] vector) {
        for (float value : vector) {
            if (!Float.isFinite(value)) {
                return false;
            }
        }
        return true;
    }

    private static String sha256(String value) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            return HexFormat.of().formatHex(
                    digest.digest(value.getBytes(StandardCharsets.UTF_8)));
        } catch (NoSuchAlgorithmException impossible) {
            throw new AssertionError("SHA-256 is required by the JDK", impossible);
        }
    }

    public static void main(String[] args) {
        List<SourceBlock> blocks = List.of(
                new SourceBlock("doc-42", "rev-7", "JVM/GC/G1", 0,
                        "G1 将堆划分为多个 Region,并按收益预测选择回收集合。"),
                new SourceBlock("doc-42", "rev-7", "JVM/GC/ZGC", 1,
                        "ZGC 以低停顿为目标,具体能力和限制应以所用 JDK 文档为准。"));

        EmbeddingClient demoEmbedding = texts -> texts.stream()
                .map(text -> new float[] {text.length(), text.hashCode() % 997})
                .toList();
        SearchIndex demoIndex = (chunks, vectors) ->
                System.out.printf("upsert chunks=%d, dimension=%d%n",
                        chunks.size(), vectors.isEmpty() ? 0 : vectors.getFirst().length);

        indexDocument(blocks, "tenant-a", "acl-3", "index-2026-08",
                demoEmbedding, demoIndex);
    }
}

设总输入 code point 数为 T,输出 chunk 数为 N,向量维度为 D

  • 设物化后的全部 chunk 文本长度为 L,则 L ≤ T + (N - 1) × O;切分与构造字符串的时间复杂度为 O(L),保存 chunk 文本的空间复杂度也是 O(L)。只有在窗口和 overlap 参数固定时,才可把它简化为相对 T 的线性复杂度;
  • SHA-256 总成本同样为 O(L)
  • 向量校验为 O(ND),向量在内存中的空间为 O(ND)
  • 外部 embedding 与索引写入成本取决于实现,生产中应按批大小、并发、限流与重试预算控制;
  • 示例的稳定 ID 包含 indexVersion,因此新索引版本与旧版本天然隔离。若希望跨版本复用 embedding,应单独设计以 contentHash + embeddingVersion + inputTemplateVersion 为键的缓存,不能误复用不同向量空间。

场景设计题

场景:为多租户 Java 技术文档平台设计十亿级 chunk 的索引系统

约束: 数据来自对象存储和内部 Wiki;文档每天增量更新;租户 ACL 高频变化;支持精确错误码与自然语言检索;索引升级不能停服;删除请求要求可审计。

一个合格方案应包含:

  1. 数据模型:源文档不可变修订、结构化解析结果、chunk manifest、构建 manifest、ACL 策略引用与删除任务。
  2. 任务编排:采集事件进入队列;解析、切分、embedding、双索引写入分别检查点;确定性 ID 与幂等 upsert;死信可按文档重放。
  3. 存储布局:原文与解析产物放对象存储;状态和 manifest 放关系库;倒排/向量后端按租户规模、生命周期或时间分片,但不能让分片破坏权限隔离。
  4. 版本发布BUILDING → VALIDATING → READY → ACTIVE → RETIRED;新旧双读做影子评估,通过后切别名,旧版延迟回收。
  5. ACL:内容变化与权限变化拆分;查询前由可信身份产生过滤条件;ACL 版本参与缓存;负样本证明无权内容在所有中间层不可见。
  6. 容量估算:若 N=10^9、向量维度 D=768、每维 float32,仅原始向量约为 N × D × 4 ≈ 3.07 TB,还未包含 ANN 结构、倒排、元数据、副本和文件系统开销。必须用后端实测膨胀系数做规划,而不是只算原始向量。
  7. 删除闭环:先 tombstone 阻断读,再清理活动/旧索引、缓存和派生数据;重建器消费删除水位;最终写审计结果。

面试官继续追问时,应主动讨论热点租户、embedding 服务限流、重建资源与在线查询隔离、失败补偿、索引一致性校验和成本上限。

排障路径

遇到“新文档搜不到”时,按数据链路从上游到下游排查,不要先调相似度阈值:

  1. 确认版本与权限:请求读的是哪个 indexVersion?用户的租户和 ACL 是否允许?文档是否已生效或被 tombstone?
  2. 确认采集:源修订事件是否到达,幂等键是否错误地判为已处理,队列是否积压或进入死信?
  3. 确认解析:正文、标题、表格是否真实存在于解析产物;是否被模板去重误删;OCR 置信度是否异常?
  4. 确认切分:manifest 中是否有目标证据;是否被截断、切断或生成重复/空 chunk;parent-child 关系是否完整?
  5. 确认 embedding:模型、模板、维度、归一化和距离是否与索引契约一致;是否发生静默截断、NaN 或批次错位?
  6. 确认写入:倒排与向量索引是否都有该 chunkId;目标版本是否仍处于 BUILDING;写入失败是否被错误标记成功?
  7. 确认发布:读别名是否切到新版本;缓存键是否遗漏索引版本;回滚是否留下路由不一致?
  8. 用已知查询对照:精确词在 BM25 路径是否可找到,向量精确/近似检索差异多大。若精确可见而 ANN 不可见,再调 ANN 查询参数。

建议为每个文档保留可查询的处理时间线:sourceRevision → parseArtifact → chunkManifest → embeddingBatch → indexAck → publishVersion。日志只记失败字符串不够,必须能关联阶段与版本。

速记总结

  • 索引的产物不是“向量”,而是带来源、权限、版本和生命周期的证据单元。
  • Chunking 先尊重结构,再用 token 上限兜底;不存在脱离语料与评测集的万能长度。
  • overlap 缓解边界丢失,但线性放大 embedding、存储、重复候选和上下文成本。
  • parent-child 用小块召回、大块返回,必须做 ACL、去重与 token 预算。
  • embedding 模型、输入模板、维度、归一化、距离和截断策略共同定义向量空间。
  • BM25 与向量互补;两套索引以稳定 chunkId、版本和 ACL 对齐。
  • sourceRevisionindexVersionaclVersion 解决不同问题,不能混为一个更新时间。
  • 至少一次消息配合确定性 ID、阶段检查点和幂等 upsert;不要宣称跨系统 exactly-once。
  • 删除要覆盖活动索引、旧版本、缓存、任务和备份策略,并防止重建复活。
  • 发布门禁同时检查数据、质量、安全、延迟与成本。

参考资料