RAG / 文档构建与检索架构

从文档,
到可检索的知识。

一次查询能找到哪些证据,取决于索引保留了什么。本文沿着文档变更与搜索请求两条链路,展示文本、幻灯片、会议纪要和表格如何进入索引,以及查询如何筛选与融合候选。育苗工作簿示例可以切换切分粒度、窗口尺寸和生成预算。

事件驱动构建按类型解析表格多粒度索引混合召回
01

两条主链路,一份派生索引

上游知识源持有权威正文与权限。构建服务异步把变更转成索引;搜索服务读取索引并返回候选。索引是可以重建的派生数据,不承担文档编辑,也不等同于最终答案生成。

架构分层

独立运行职责 · 共享索引契约

编排集中,策略分开

Pipeline 负责步骤顺序与错误语义;parser 和 chunker 处理文档结构;存储适配层封装读取、清理与写入。新增类型不必复制整条构建链。

每条事件有自己的状态

单文档任务状态持有本次配置快照、解析结果、指纹和文档锁。共享的流水线与依赖不保存单篇文档的中间状态。

运行职责可以拆开

build 消费事件,serve 对外搜索,migrate 管理 schema,DLQ worker 处理延迟重试。Schema 迁移独立于服务启动。

索引层保存 Content、文档类型、权限和上下文元信息;向量索引配置示例为 1024 维、COSINE + HNSW,稀疏向量由 Content 的 BM25 函数生成。构建侧不单独调用一个 BM25 模型。接口调用观察还使用异步 channel / 批量 ClickHouse 写入,按开关启用,属于辅助可观测性链路。
02

一次 build,先判断值不值得做

同一个变更 topic 可以被多种 builder 消费。入口先排除无关类型,元信息只按需查询一次;实际删索引、更新权限和重建,统一受文档锁保护。下面切换事件,观察走过的阶段。

01 / INGEST消息接入

类型早筛
删除 / force / 早判

02 / PLAN拉元信息

确定类型计划
完整过滤

03 / ACQUIRE取文档锁

collection + docID
自动续期

04 / CACHE锁内判定

Hit / MetaOnly
Rebuild / Deferred

05 / CHUNK正文与切分

下载 / 二判 / 限流
解析 / 读旧片段

06 / PERSIST索引持久化

复用 / 计算向量
清旧 / 写新 / 打标

锁外 · 减少无效工作持锁区间 · 保护同文档的变更互斥

正文变化 → 重建索引

元信息生成只读文档处理计划;锁内下载正文并解析、切分。从目标索引读取旧 chunk,复用未变化片段的向量,只计算缺失向量。

1元信息调用
通常 1正文下载
需要文档锁

示意条件:事件类型明确、缓存与依赖可读;正文下载次数描述普通文本 / 网格主路径。PPT 自行获取文件,重试会开启新的处理尝试。

元信息决定执行计划

消息里的类型用于早筛;权威元信息决定 parser、chunker、正文来源、缓存后缀和 chunk 上限。两者不一致时按权威类型过滤并记录差异,避免每个阶段重新解释类型。

跳过、清空、失败,是三种结果

可预期解析失败走 ChunkDrop:保留旧索引、不打 done。合法空文档走 0 chunk 重建:清旧并打 done。依赖错误上抛给消费者重试,耗尽后再进入 DLQ。

类型值 0 是合法类型,不能当成“字段缺失”。缺类型、null 或 -1 走兼容路径并计数:DELETE 仍清理,更新查询权威元信息后分流。已知非本类型的 DELETE 则在缓存、查询和取锁之前结束。
伪代码 · 入口先减少无效工作
on_event(event):
  if event.has_type and event.type != handled_type:
    return SKIP

  if event.is_delete:
    with document_lock(event.document_id):
      clear_available_indexes()
      remove_completion_marker()
    return DONE

  if event.only_content_changed and early_fingerprint_matches(event):
    return SKIP

  metadata = load_metadata_once(event.document_id)
  plan = choose_document_plan(metadata, config_snapshot)
  with document_lock(event.document_id):
    execute_plan(plan, metadata)

仅展示早筛、删除和计划选择的控制关系;正文二判、依赖错误与重试由后续阶段处理。

03

文档切分体系:共性与类型差异

Chunk 太小会丢掉条件、主体和指代,太大又会稀释检索信号、占满上下文。相比表格的“找到整份工作簿”,正文片段更可能直接作为下游回答的证据,因此要同时考虑语义边界、事实覆盖、预算、定位和版本一致性。下面按已核对的实现机制展开,而不是把理想中的 RAG 能力一并算作已具备。

3.1

共性难题与实现边界

“已具备”描述实现机制,不代表已经证明所有场景的质量;“未覆盖”保留架构边界,不把待建设能力包装成现有功能。

技术难题已具备的机制仍未覆盖 / 需要验证
语义边界 vs 粒度文本标题与句子边界、小片合并;PPT 按页再细分;纪要按原子单元和局部锚点。没有基于句向量的主题变点切分,也没有按查询动态重切。一个完整句子可能仍依赖前文事实。
脱离原文后的上下文标题路径、页标题、纪要 section / 发言者 / 有效时间写入 Content。没有通用的逐片 LLM 上下文增强、指代消解或摘要层级树。上下文头过长也可能挤掉正文。
Token、字符与存储限制普通文本 rune + UTF-8 byte 保护;PPT 字节驱动细分;纪要出片真实 token / rune / byte 复核。三条路径并非统一 token 预算。PPT 对长度参数的 byte 解释与普通文本不同,末端硬截断不等于无损切分。
原始结构与多模态信息结构节点转码、内嵌表头处理、PPT Vision 转 Markdown;独立表格多粒度。普通图片未获得这条视觉链能力;PPT 没有原图向量或图形坐标索引。长代码 / 内嵌表仍可能碎片化。
高召回与精确证据Dense + BM25、RRF、rerank 和范围 / 权限下推,提高候选的可检索性。没有通用的“检索小片、回取父段”或按相邻片扩展证据链。重排无法恢复构建时已经丢失的条件句。
重复与 Top-K 被挤占可配置 overlap、小片合并、chunk 级内容向量复用,多 query 合并去重。片段 ID 去重不等于语义去重;相邻重复片段仍可能占据前几名。未见通用文档多样性 / MMR 选择机制。
局部更新与边界稳定四类缓存指纹;内容 + pipeline 向量复用;纪要局部 anchor 减少无关重切。文本插入可能移动后续边界,PPT 模型输出可能漂移;索引仍整篇覆盖,局部向量复用并非局部事务更新。
引用可信度与内容完整性表格结构坐标、文本主题路径、纪要组时段,版本化解析与失败分支。普通正文没有统一的字符区间引用协议;PPT 缺页 / 误读和输出截断需单独检查,合法完成不自动意味着完整证据。
Chunking 改善的是“内容如何成为候选证据”。高文档召回不等于事实覆盖完整,更不等于回答忠实:解析丢了单位、切分丢了否定条件,后续换更强 reranker 或扩大模型上下文也不能直接补回来。
3.2

文本、PPT 与会议纪要

按标题与句子组织的文本链路

Markdown → GFM AST → 标题路径 / 文本缓冲 → 句子切分 → 小片合并 → 重叠 → breadcrumb
  • 标题更新层级路径,正文在句子边界优先切分,无可用边界才硬切。
  • 代码和内嵌表在统一链路中转成文本;独立表格才进入网格 / FRTR 链。
  • 结构化切分关闭或失败时,回退到清理后的纯文本固定窗口。
输入示例 → 索引内容
# 育苗手册
## 灌溉
保持土壤湿润,避免积水。

↓
育苗手册 > 灌溉
保持土壤湿润,避免积水。

文本链路直接索引代码与内嵌表的文本,不额外生成 LLM 摘要。小片合并也处理标题路径,减少语境丢失。

切分单位
标题下的文本缓冲区,优先句子边界
预算
正文目标 1024 runes;小片阈值 100;内容上限另计
重叠
普通相邻片段:尾部 10%,最多 200 runes
定位
文档目录与标题路径写入 Content

实际生成顺序

  1. GFM AST 识别标题、段落、列表、引用、代码和内嵌表。遇到标题时先冲出文本缓冲,再更新标题路径。
  2. 段落与列表进入文本缓冲;代码保留正文文本,内嵌表按行转换为带竖线分隔的文本。它们不作为独立的代码 / 表格向量单元。
  3. 超长缓冲区在目标长度内寻找靠后的中英文句末或段落分隔;若边界过近、找不到有效边界,则按 rune 硬切。
  4. 先处理过短片段,再附加最终边界 overlap。小片可并入相邻块,专门的合并阶段处理更深或并列标题路径。
  5. 追加文档目录、标题和小节路径;检查最终内容长度与 UTF-8 字节限制,构造检索片段。

设计理由

标题路径让“每天一次”这类短句带上灌溉语境;句子优先避免把条件和结论切成两半;先合并再重叠,减少被 overlap 掩盖的极短片段。保留原文本而不让 LLM 重写,降低构建成本和新增事实风险。

仍需面对的边界

  • 这是规则切分,不使用句向量检测主题突变,也没有根据问题意图动态决定边界。句子完整不等于论证链完整。
  • 代码可能在语句中间被切开,内嵌表可能丢掉空格位置和跨行关系。普通正文的图片不进入这条视觉识别链。
  • 小片合并与 overlap 可以跨章节;代词所指对象、否定条件和标题归属仍可能混淆。路径 / 最终字节兜底截断也可能丢掉末尾内容。
  • 这条路径主要按 rune 和 byte 预算,没有像会议纪要一样逐片复核真实 tokenizer token 数。
伪代码 · 所选类型的切分主线
chunks = []
for section in walk_heading_sections(parse_markdown(input)):
  text = render_nodes_as_text(section.nodes)
  parts = split_near_sentence_end(text, max_runes=1024)
  chunks.extend(with_heading_path(parts, section.path))

chunks = merge_small_chunks(chunks, min_runes=100)
chunks = append_previous_tail(chunks, ratio=0.10, cap_runes=200)
for chunk in chunks:
  chunk.content = add_document_context(chunk)
  enforce_final_rune_and_utf8_byte_limits(chunk)

描述结构化主路径;缓冲区的早期小片合并、标题路径组合和截断规则略去。无法结构化切分时回退到清理后的纯文本固定窗口。

怎么验证:用条件句、跨段指代、短标题下的一句话、长代码、含空单元格的内嵌表做对照;检查事实是否保留、标题是否正确、前后片段重复是否挤占 Top-K。

普通 File 与未支持类型是 pass-through,可进入 0 chunk 清理流程。PPT 开关关闭是 Drop;开关开启但依赖缺失会形成 cleanup-only 计划。会议纪要的快照或 tokenizer 缺失则要求报错,不能伪装成空内容清掉索引。
3.3

表格:结构恢复、多粒度与预算

表格沿三步构建:先从网格或 schema 恢复结构,再为工作簿和各 region 生成检索表示,最后按文档预算控制产出。Overview 属于工作簿;Summary、Column、Window 和 Row 属于 region。两种输入共用这套生成机制。

3.3.1

先恢复结构

构建器接收的是上游转换后的结构化 JSON:电子表格是 sheet → 二维 cell,智能表格是 schema → 记录。不需要在每次 build 中重新还原整份 Excel 文件。前半段恢复网格语义,后半段才生成检索片段。

A / DECODE

读出结构化输入

保留 value、displayValue、format、合并范围与 sheet 属性;智能表格保留列类型和选项。

B / NORMALIZE

还原可见网格

电子表格跳过隐藏 sheet,展开 rowspan / colspan;智能表格把类型化值渲染成文本。

C / UNDERSTAND

识别区域与表头

普通网格推断多个 region 和多层 header;已知 schema 的智能表格直接构造 region。

D / REPRESENT

生成语义代表

按配置选择传统切分或 FRTR 五个 pass,追加上下文和定位元信息,控制长度与数量。

两种输入,共用一套 FRTR 生成器

前置适配不同 · 五阶段生成算法相同

合并单元格展开 → 占用分布 → 区域与表头

展开后的合并区域共享同一 cell 指针,读取时可去掉重复锚点。非空位置先形成行带和列组,再按重合度、空隙、说明文本和标题锚点合并或拆分 region。表头按顶部文本行推断,每列建立 header_paths。

显示值,比裸数值更接近语义

电子表格优先使用上游 displayValue。传统路径缺少显示值时由格式化器按需处理数值和样式,避免构造完整临时工作簿。FRTR 读取 displayValue,缺失则退回原始值字符串;两路并非完全相同。

“已知表头”与“猜测表头”分开

智能表格的表头来自显式 schema,row 打包可去掉表头行;电子表格的启发式表头仍保留为数据行,防止猜错后永久漏掉真实内容。标签优先取 header path 最具体的一层,缺失时用列字母。

策略传统路径 · 仍保留FRTR 路径 · 灰度选择
电子表格结构展开合并格,查找 / 合并子表,扫描候选表头行并评分,展平多层列名。根据占用分布识别 region,处理说明文字 / 标题边界,构造 header paths。
表达方式逐行输出“列名: 值”,小表可合并;超长内容用固定窗口二次切分。overview / summary / column / window / row 同时表达不同查询视角。
智能表格空行系统自动生成列不作为“有用户内容”的依据。先排除系统列、公式与 rollup 作为有效行依据;整表只含派生内容时回退到传统的非系统列口径。
存储与回退写主索引。FRTR 意图选新 chunker;repository 可用才写独立表格索引,否则可将新切分结果写回主索引。退出灰度时清理可用的旧表格侧。

智能表格过滤的是“这一行是否值得索引”,并非删除这些列的所有渲染值。多行单元格会被压成单行,避免把一个 cell 的后半段误读成另一条记录。锁定单元格由上游提供占位符,未锁内容仍参与构建。

伪代码 · 已声明的表头不必再猜一次
adapt_table(table):
  if table.has_column_schema:
    grid = [table.column_names] + render_typed_records(table)
    regions = [whole_table_region(grid)]
    header_is_declared = true
  else:
    grid = expand_merged_cells(table.visible_cells)
    regions = detect_regions_from_occupied_cells(grid)
    header_is_declared = false

  headers = build_header_paths(grid, regions)
  return grid, headers, header_is_declared

pack_rows(grid, headers, header_is_declared):
  rows = grid.data_rows if header_is_declared else grid.all_rows
  return pack_until_content_budget(rows, headers)

电子表格的启发式表头保留在数据中,以避免误判漏内容;智能表格的显式表头可以安全摘除。有效行与派生列过滤属于前置适配规则。

3.3.2

多粒度片段与二维滑动窗口

这里的表格切分借鉴了 From Rows to Reasoning(FRTR) 的多粒度思路,但这里的主要目标是工作簿级召回:先找到值得读取的表,再让下游获取完整数据分析。采样后的 chunk 是检索信号,不能当作完整数据集计算精确答案。

一张 sheet,明细与基准两张子表

示例数据

左侧 A1:F8 是育苗明细,右侧 I1:L4 是区域基准,各有表头、字段和数据,G / H 两列为空白分隔。按占用分布识别得到这两个 region,后续粒度均在各自区域内生成。

窄屏上可横向滚动工作簿,查看右侧的区域基准。

育苗观察簿 / 育苗记录与基准物理网格 8 行 × 12 列
ABCDEFGHIJKL
1品种区域灌溉量 ml光照 h成活率 %株高 cm区域基准量 ml目标成活率 %说明
2鸢尾东区12069218东区10095偏湿
3罗勒东区8079616西区8094常规
4薄荷西区10059514南区6092偏干
5鼠尾草西区6089015
6迷迭香南区7099420
7百里香南区5089312
8金盏菊东区9069717
育苗记录与基准G / H:空白分隔列
浅色:当前 region 范围深色:片段 / 样例覆盖

Overview:列出一张 sheet 内的两个子表

“哪份表同时有育苗明细与区域基准?”

工作簿概览遍历所有 sheet 和 region。多 region 的 sheet 列出各子表范围与列名,样例留给 region summary。

Overview 属于工作簿,不绑定单个单元格范围。此格式的“12 rows”是两个 region 高度 8 + 4 的汇总,不是 sheet 的物理行数或数据记录数。

生成的片段文本
Workbook: 育苗观察簿
This workbook contains 1 sheet(s).

Sheet "育苗记录与基准" (12 rows):
  Sub-table 1 at A1:F8 (8 rows):
    Columns: 品种, 区域, 灌溉量 ml, 光照 h, 成活率 %, 株高 cm
  Sub-table 2 at I1:L4 (4 rows):
    Columns: 区域, 基准量 ml, 目标成活率 %, 说明
OVERVIEW / 定位信息
{
  "unit_granularity": "overview",
  "anchor_range": ""
}

范围、空间邻接和序列邻接分别表达什么

anchor_range 是片段在原表中的矩形覆盖范围;summary 的范围是整个 region,文本只带 schema 和样例。Window 的尺寸与行列步长决定窗口起点,空间邻接是起点网格中的八个方向。

相邻窗口可以大量重叠,八方向关系也不同于生成顺序的 prev / next。序列 neighbors 当前记录同一批中的索引,不是空间方向。大窗口若采样,矩形 anchor 仍保持原范围,不承诺范围内全部 cell 都已写入文本。

先确定区域,再生成区域内的表示

Region 是结构恢复得到的矩形候选区域;本例分别对应明细和基准子表。Summary 写入整个 region 的字段与样例,Window 则枚举区域内的局部矩形。区域识别使用布局启发式,仍可能把说明文字、空列或相邻表块判断错,需结合字段和数据检查。

只生成能完整放入 region 的窗口

窗口数 = max(0, ⌊(行数 − 高度) / 行步长⌋ + 1) × max(0, ⌊(列数 − 宽度) / 列步长⌋ + 1)。行数和列数包含表头;不足窗口尺寸则产出 0 条,不补齐、不生成尾部半窗。本例 4×4、步长 1×1 时,明细 15 个,基准 1 个。

尺寸、步长与采样分别控制什么

尺寸决定一个窗口覆盖多少行列;步长决定相邻窗口的起点距离;单窗口正文采样限制一条文本的长度。大表动态计划可把行高 / 行步长改为 8、16、32、64,列宽维持 4、列步长 1;最终覆盖还受生成配额和正文采样影响。

邻接只连接已经生成的窗口

先生成非空且未超额度的窗口,再按起点加减步长回填八方向 chunk 标识。若某窗口被跳过或预算耗尽,对应邻接就不存在。本例未触发采样和配额截断,展示的是完整枚举结果。

为什么用两列空白分隔:识别规则可能合并仅隔一列空白、且有内容的行重合度 ≥ 0.6 的列组。空白列数量是布局证据之一,不能仅凭看到一条空列就认定出现了新子表。

伪代码 · Region 内的真正二维滑窗
for region in detected_regions:
  last_row = region.end_row - window_height + 1
  last_col = region.end_col - window_width + 1
  windows_by_start = {}
  for row in range(region.start_row, last_row + 1, stride_row):
    for col in range(region.start_col, last_col + 1, stride_col):
      cells = rectangle(row, col, window_height, window_width)
      if quota_exhausted():
        stop_region_generation()
      if cells_are_empty(cells):
        continue
      record = emit_window(cells, original_rectangle_anchor)
      windows_by_start[(row, col)] = record

  for start, record in windows_by_start:
    record.adjacent_windows = existing_neighbors_at(
      start + eight_direction_offsets(stride_row, stride_col)
    )

此处省略单窗口 head + tail 采样及格式化。空间邻接引用生成窗口的标识;序列 prev / next 属于另一条关系。配额耗尽时生成过程会停止,而不是靠末端的 continue 无限遍历。

3.3.3

预算分配、打包与采样

五种粒度提供互补检索信号,也会增加向量数量。预算控制分为生成配额、单片内容长度和最终落地条数:先保住高层语义,再给列、窗口与行组分配额度。

有限预算,先保住高层语义

配置示例 · 350 条 / 文档
350
50 · 紧预算1000 · 扩大总上限
Overview3
Summary18
Column35
Window35
Row 可用余量259

此图演示五类全开、前四 pass 用满逻辑上限时的预算顺序,不预测实际 chunk 数。本演示的 caps 固定为 3 / 18 / 35 / 35;拖动总预算不会同比放大它们。实际按产出扣减,二次切分还会消耗额外的落地条数。

分配给 region,而不只截掉尾部

Column 按列数权重、Window 按估算窗口数分配额度。分配器使用 floor + 最大余数,处理每 region 的最低份额与总 cap;预算少于 region 数时只保留较高权重区域。Summary 超配额时优先较大 region,再恢复输入顺序。

小表先完整打包,大表再均匀抽样

Row pass 先处理 ≤50 行的小 region,按实际打包产出的逻辑记录扣减额度;余量按行数分给大 region。先累积连续行直到内容预算,再在超额时等距 stride 采样,避免小汇总表被大明细表挤掉。

防止“一个逻辑片段切成几十条”

Row / Column 做内容驱动打包;Column / Window 控制正文长度并对长数据做 head + tail 降采样。Window 采样保持整行宽度,避免列错位。最终仍有硬切兜底和 fragmentation 指标,不能把它当成常规压缩手段。

三种长度单位,分别保护不同边界

普通文本 / 网格的正文目标 1024 runes、含 breadcrumb 的内容预算 1280 runes;索引 content 字段上限 4096 bytes。PPT 的局部内容预算按 byte 解释。字符数不能替代 UTF-8 字节数。会议纪要还检查真实 token 数,避免 embedding 截断尾部。

伪代码 · 五个 pass 与实际产出扣预算
output = []
max_chunks = 350

for kind, quota in [
    (OVERVIEW, 3), (SUMMARY, 18),
    (COLUMN, 35), (WINDOW, 35), (ROW, REMAINING)]:
  remaining = max_chunks - len(output)
  if remaining == 0:
    break

  limit = remaining if quota == REMAINING else min(quota, remaining)
  records = generate(kind, contexts, limit)
  for record in records:
    fragments = encode_for_storage(record)
    output.extend(fragments[:max_chunks - len(output)])

row_pass(regions, budget):
  small_regions = [r for r in regions if r.row_count <= 50]
  large_regions = [r for r in regions if r.row_count > 50]
  small = take_within_budget(pack_all_rows(small_regions), budget)
  budget -= count(small)  # 扣打包记录数,不扣原始行数
  if budget == 0:
    return small
  quotas = allocate_by_weight(large_regions.row_counts, budget)
  large = pack_then_stride_sample(large_regions, quotas)
  return small + large

REMAINING 表示剩余额度。此处强调逻辑记录与落地分片分别计数;真正的生成器还需处理 region 权重、空内容、单片长度与负值“不限”等条件。

3.4

用问题和证据验证切分质量

01 / 转换完整性

先看信息有没有丢

原始节点 / 页图 → 转换文本 → 片段逐层比对。观察事实、单位、标题、页序、引用主体、空页和硬截断,区分 parser / Vision 损失与 splitter 损失。

02 / 证据覆盖

再看问题能否被回答

用条件句、跨段指代、长代码、跨页图表和跨发言决策构造匿名问题。检查 Top-K 是否同时包含必要事实与限定条件,而不只是命中正确文档。

03 / 重复与稳定

最后看成本和漂移

同条件对照粒度 / overlap,统计去重后证据量、候选文档多样性、上下文占用;局部编辑前后观察边界漂移、向量复用和重建耗时。

这些是建议的验收维度,不是新增实验结果。正文、PPT、纪要尚无可在本文展示的同条件量化对照;表格的历史工作簿召回实验也不能代替这些验证。

04

少做重复工作,也要防止“错误地完成”

文档缓存用来判断是否需要重建,chunk 指纹用来减少 embedding 成本,文档锁用来约束并发修改。三者解决不同问题;缓存命中不是索引事务的替代品。

四类文档指纹

done 值记录处理过的版本
Meta权限、类型、归档、目录归属及相关元信息
Content标题 + 上游正文 MD5;缺失时下载后计算
Context实际渲染进 chunk 的路径或纪要上下文
Pipeline基础版本 + 类型配置 / chunker 版本后缀
Hit

四类一致 → 结束。入口早判仅适用于字段充分且全为 CONTENT 的事件;含 BASE / AUTH、force 或缓存不可读时仍向下处理。

MetaOnly

正文、上下文、pipeline 一致 → 读取目标索引的旧 chunk,只套新元信息再整篇写回。无需正文下载与重新 embedding。

Rebuild / Deferred

内容、上下文或切分版本变化 → 重建。正文 MD5 未给且尚未下载 → Deferred,下载后执行第二次完整判定。

切分与上限检查→复用 / 补齐向量→写前预检→清旧索引→200 条 / 批写新→done 标记

向量复用键是 MD5(Content + PipelineHash),不包含权限 MetaHash;空旧向量不复用。force=rebuild 绕过文档缓存但可复用向量;force=reembed 额外跳过旧 chunk 读取。

文档锁保护什么?

锁键为 collection + docID;示例锁配置的 TTL 为 120 秒,自动续期,获取重试间隔 2 秒、最大等待 10 秒。下载正文、切分、删除、写入和 done 操作在锁内,避免同篇重建与删除互相覆盖。失锁 context 会取消后续操作,清理另外检查锁所有权;已发出的 Milvus 请求只能尽力取消,目标端没有 fencing token 保证。

为什么先预检,再清旧?

空 Content、非法 UTF-8、空向量和不合规条数可以提前发现。先清再发现不可写,会用一次失败换掉整篇旧索引。写前预检把这些错误挡在删除之前。MetaOnly 的存量 Content 不套用新的严格内容规则,避免旧内容不合规让一次权限收紧永久无法写入。

整篇覆盖并不等于事务

这里采用 delete-by-doc + 分批 insert,不是原子替换。某批失败可能留下“旧的已删,新的只写一部分”。调用方在写入失败时删除旧 done 标记并返回错误,尝试通过重试收敛;删除 done 自身失败也只记日志,旧标记可能继续遮蔽修复。done 的 Redis 写失败只记录日志,不重试已完成的索引写入。缺标会增加未来重建成本,错误打标却可能永久跳过修复。

主索引和表格索引,清理要求是否一样?

有 chunk 的重建要求目标侧清理成功;非目标侧清理失败只告警后继续,因此仍可能残留。0 chunk 清理和显式删除则要求所有可用、被选中清理侧成功。删除成功后清掉 done 而不重新打标,文档恢复时才有机会重新构建。

消息重试链有哪些可靠性边界?

消费者关闭自动 mark,由滑动窗口跟踪已完成消息并推进提交,窗口满时形成背压。处理尝试失败后先按配置立即重试,再投递 DLQ;DLQ worker 用 Redis ZSET + HASH 延迟调度,并把事件投回原 topic。延迟记录按 docID 保存,同篇后续消息可覆盖前一条;超过最大次数只告警后结束。原消费者回调结束会 处理完成标记,DLQ 发送失败只记录日志,所以这套机制不能宣称端到端零丢失或 exactly-once。

为什么会议纪要超限要报错,表格可以截断?

表格 chunk 是语义代表,尾部受预算压缩仍可提供召回信号。纪要超限属于异常保护,静默截断逐字稿会丢掉后段且调用方难以察觉,因此使用 超限拒绝策略 并保留旧索引。合法空纪要清旧;异常空 payload 报错保留旧索引,二者不能合并。

伪代码 · 缓存、向量复用与写前预检
with document_lock(document_id):
  decision = compare(meta_hash, content_hash, context_hash, pipeline_hash)
  if decision == HIT:
    return SKIP

  if decision == META_ONLY:
    chunks = replace_metadata(read_existing_chunks(), new_metadata)
  else:
    chunks = parse_and_chunk(load_body(), plan)
    for chunk in chunks:
      key = hash(chunk.content + pipeline_version)
      chunk.vector = reuse_nonempty_vector(key) or embed(chunk.content)

  validate_chunks_before_deleting(chunks)
  verify_lock_ownership()
  clear_indexes_according_to_policy()
  try:
    insert_in_batches(chunks, batch_size=200)
  except WriteError:
    best_effort_remove_completion_marker()
    raise
  best_effort_mark_complete()

没有把权限元信息加入向量复用键。清理与分批插入不是原子事务,失锁取消和 best-effort 标记也不提供零丢失保证;具体失败边界见本节说明。

05

构建的结构,在召回时继续有用

Content 支持语义与关键词两路检索,顶层字段支持来源、权限与粒度筛选,extra_meta 保留回到原表的位置。构建和召回的配置需要对齐:有新索引但未开启相应召回,效果不会自动出现。

范围与身份

请求来源 ∩ 服务允许类型;目录、归档与权限组合成过滤表达式,在 Top-K 前下推。

→

Dense ∥ BM25

同一个查询绑定文本与向量。各路并发检索,再用倒数排名融合(RRF)合并排名。

→

后处理与返回

按接口组装 rerank / finalize / score_filter / Top-K。需要原文时走回查或由下游取完整数据。

表格索引有两种接入方式

按接口与配置选择

先跨库合并,再统一后处理

主库与表格库的 dense 候选共用模型和 COSINE,按有效相关分排序,保留真实 cosine。Sparse 跨库及 dense ↔ sparse 使用排名融合,再进入同一后处理链。

Dense(main, table) → score pooling Sparse(main, table) → RRF Dense ↔ Sparse → RRF → rerank

分数的含义必须跟着结果走

Cosine、BM25、归一化 RRF 与 rerank 不是同一个标尺。Dense 跨库权重用于排序,不改写真实 cosine 阈值。Reranker fallback 时跳过不再可比的阈值;部分检索接口用 -1 哨兵或 ScoreType 表达降级。

权限收紧,不能只靠旧向量

MetaOnly 把权限变化写回 chunk;检索先下推索引权限。执行知识源批量权威鉴权的路径,在调用失败时返回空结果、未明确授权的文档不保留。请求范围只能收窄服务配置,未知来源枚举返回参数错误。

降级要区分原因

主路 BM25“能力不存在”且 dense 可用时可降到 dense;普通查询错误或超时继续上抛。独立 FRTR 侧路是增量能力,其错误可降为空侧路。不能把所有异常都归结成“返回部分结果”。

纪要和目录不是同一归属关系

目录范围约束普通文档,显式混合来源时表达为“纪要 OR(普通文档 AND 目录)”,再与权限等约束相交。minutes-only 不查询表格侧路。多 query 每个拥有独立 RunState,最后轮询合并与去重。

06

设计取舍,落在质量、成本与可靠性上

架构的价值来自约束下的选择。下表把设计选择、原因和代价放在一起;实现层的收益与实验指标分开描述,避免用一个数字解释全部设计。

选择原因效果与边界
01

显式六阶段 Pipeline
单次解析文档计划

锁边界、缓存分支与多类型路由交织,隐式处理链难以确认执行顺序。

步骤可以单独观测和测试;类型只解释一次。新类型仍需定义空内容、失败和清理语义。

02

类型先筛,再考虑删除
按需读取权威元信息

共享 topic 下无关 builder 会放大查询、锁竞争和重复删除;显式删除不需要再问正文是否存在。

明确的非本类型事件可做到零查询、零缓存、零锁。依赖类型不可漂移等生产方契约;缺类型保留兼容成本。

03

高层信号先生成
五粒度保留互补

只有逐行文本难以覆盖主题、列分布和局部矩阵查询;大明细表容易淹没小汇总表。

概览与摘要不被 row 尾部预算挤掉;小 region 优先。Summary 全局 cap 仍可能排除部分小区域。

04

内容驱动打包 + 确定性采样

固定“几行一片”无法适配宽表 / 窄表;超长逻辑片段会触发二次切分,消耗额外预算。

减少碎片、提高单片信号密度,可复现。采样不保证保留任意关键行,精确统计必须读取原表。

05

独立表格索引
切分意图与存储可用性分离

新策略需要粒度、region 与 sheet 字段,也需要灰度验证和退回旧路径。

独立配置与召回模式支持演进。切分 / 写入 / 清理 / 召回要一起考虑,非目标清理失败仍可能残留。

06

文档四指纹
chunk 向量复用不含权限

权限变化不改变文本向量;路径、标题和切分配置变化却会影响真实 Content。

元信息更新无需重算 embedding;新 chunk 可复用旧向量。指纹覆盖不完整或版本未更新时,需要显式重建存量。

07

清理前预检
失败不留虚假 done

删除之后才发现空向量 / 超限会损失旧索引;失败时保留旧 done 又可能永远命中错误缓存。

提前拒绝可预知写入错误,并让失败重试。Milvus 分批插入没有原子替换,仍可能出现半写状态;重试链也存在有限次数与投递失败边界。

08

Schema 迁移独立运行

启动时 DDL 把结构变更混进滚动部署,放大运行期不确定性。

先 dry-run,再 apply,再启动构建与搜索。迁移流程添加缺失字段 / 索引,遗留字段告警,不自动删除。

历史对照实验的工作簿召回与片段数

原稿记录 · 原始报告待补

Hybrid · workbook recall@5

+26 pp
基线
0.67
FRTR
0.93
00.51.0

索引片段总数

−64.9%
基线
29,751
FRTR
10,451
015,00030,000

数据来自原稿所载的实验记录,原始报告待补。实验条件:一组历史切分对照实验包含 30 份评测工作簿、100 个 query、候选 top-k=50,同一 bge-m3 embedding;对比切分方案。wb_recall@5 衡量“前 5 个工作簿候选包含目标”的比例,不是答案准确率、单元格定位准确率或生产 SLA。

检索模式wb_recall@5:基线 → FRTRwb_recall@50:基线 → FRTR工作簿 MRR:基线 → FRTR
Hybrid0.67 → 0.930.89 → 1.000.60 → 0.82
Dense0.56 → 0.930.59 → 0.980.54 → 0.91
BM250.38 → 0.710.81 → 0.87文档未给出
实验方案 / 对照条件

总 cap 1000,前置配额 3 / 50 / 100 / 100

历史报告记录了 column / window 超长碎片、小 region 被挤压等修复和实验结果。不能把粒度首命中占比解释为严格消融因果,也不能用“减少 chunks”直接推出端到端成本或延迟下降同样比例。

紧预算方案 / 参数示例

总 cap 350,前置配额 3 / 18 / 35 / 35

该方案将总 cap 收紧至 350,并配合内容驱动 row 打包、小 region 按实际记录数扣减、两种表格输入共用五 pass。历史 0.93 不能直接归属于这套参数,仍需要同条件实验验证。

进一步的质量收益需要独立验证:采用相同数据与查询对照配置,同时看工作簿召回、各粒度产出、二次碎片率、embedding 复用率、构建耗时与删除失败。跨表 join、跨文档歧义和精确计算,仍需要下游检索 / 重排 / 分析能力。