AI RAG 知识库

1. 系统定位

flowchart LR A["业务知识源<br/>文档 / 表格 / 文件"] --> B["知识库处理<br/>解析 / 切分 / Embedding"] B --> C["检索索引<br/>MySQL + Milvus"] D["用户问题"] --> E["向量召回<br/>过滤 / Rerank"] C --> E E --> F["知识片段"] F --> G["上游问答模型"]

AI Wiki 为业务问答场景提供知识检索能力:

  1. 业务方把飞书文档、表格、PDF、Markdown、Office、HTML、TXT、Excel 或 CSV 等内容放进知识库。
  2. 系统读取源文档,把内容切成适合检索的知识片段。
  3. 每个片段生成 Embedding 向量,写入 Milvus;片段原文和业务元数据写入 MySQL。
  4. 用户提问时,系统把问题转成向量,从指定知识库召回候选片段,再经过状态、标签、Rerank 和相关性阈值处理。
  5. 当前接口主要返回知识片段;上游的模型服务可以继续使用这些片段生成最终回答,但“召回结果”和“最终答案”是两个阶段。

整体处理链路如下:

源文档
  -> 结构化解析
  -> 文本切分
  -> 向量化
  -> MySQL 保存可管理信息 + Milvus 保存可相似度检索信息
  -> 用户问题召回相关片段
  -> 返回片段给上游回答链路

2. 业务背景

flowchart TD A["知识分散<br/>飞书文档 / 表格 / PDF / Office"] --> B["查找成本高"] A --> C["内容结构不一致"] A --> D["更新后容易混用旧内容"] A --> E["来源难以追溯"] B --> F["需要统一的知识索引层"] C --> F D --> F E --> F F --> G["按问题召回相关片段"] G --> H["为模型回答提供可追溯上下文"]

2.1 使用场景与问题

在客服、运营、内部助手等场景里,知识通常分散在不同地方:

  • 飞书文档中有规则和流程;
  • 表格中有商品、渠道、配置或价格信息;
  • PDF、Word、Markdown 中有制度、说明书和历史沉淀;
  • 同一类知识可能由多个团队维护,更新时间和内容结构也不一致。

直接把所有文档拼到 Prompt 中会遇到几个问题:

  • 文档数量大,模型上下文无法容纳全部内容;
  • 一次请求读取全部文档成本高、延迟高;
  • 相似内容多,容易把不相关规则带进回答;
  • 文档更新后,旧内容可能继续被使用;
  • 回答无法稳定追溯到具体文档和片段。

AI Wiki 在业务文档和模型问答之间增加了一层知识索引。文档经过解析、切分和向量化后可以按问题检索,同时保留来源和管理关系。

2.2 业务数据模型

业务数据分为三层:

对象 业务含义 主要职责
wiki_item 一个知识库 组织一组文档,并配置 Embedding、Rerank、阈值和发布状态
wiki_document 知识库中的一篇源文档 代表飞书文档、PDF、CSV、Markdown 等原始知识来源
wiki_document_chunk 源文档切出的一个知识片段 保存可编辑、可展示、可追溯的文本和向量关联信息

这三个对象的层级关系是:

一个知识库 wiki_item
  └── 多篇源文档 wiki_document
        └── 多个知识片段 wiki_document_chunk

这里的“知识库”是业务边界,也是后续向量检索的隔离边界。wiki_item 不只是一个页面名称,它决定了召回范围、模型配置和 Milvus Collection。

3. 数据对象

flowchart TD W["wiki_item<br/>知识库:模型 / 阈值 / 状态"] --> D["wiki_document<br/>源文档:来源 / 类型 / 切片配置"] D --> C["wiki_document_chunk<br/>知识片段:文本 / 顺序 / 向量关联"] D --> T["wiki_document_tag<br/>文档范围过滤"] C --> M["Milvus 向量记录"] C --> X["MySQL 业务记录"]

3.1 wiki_item:知识库根作用域

wiki_item 负责描述一个知识库的整体配置,典型信息包括:

  • 知识库自身的 id;
  • 使用哪一个 Embedding 模型;
  • 使用哪一个 Rerank 模型;
  • 相关性阈值 relevance_threshold;
  • 发布状态;
  • 自动更新策略;
  • 是否允许通过标签限制文档范围。

对检索链路而言,最重要的是 wiki_item.id:

wiki_item.id = 12
    -> Milvus Collection = wiki_doc_12

wiki_item.id 用于确定检索所属的知识库。具体命中的源文档由 doc_id 标识。

3.2 wiki_document:源文档实体

wiki_document 表示知识库里的具体源文档,通常包含:

  • id:文档逻辑 ID;
  • wiki_item_id:归属的知识库;
  • 文档来源 URL 或本地文件信息;
  • 文档类型;
  • 切片类型和切片参数;
  • 处理状态;
  • 更新和发布信息。

文档处理状态定义为:

0 = 待处理
1 = 处理中
2 = 完成
3 = 失败

wiki_document.id 是后续批量删除、重建和回查该文档所有切片的关键条件。

3.3 wiki_document_chunk:可管理的知识片段

一篇文档会被切成多个 chunk。每个 chunk 同时服务于向量检索和业务管理,MySQL 中保存:

  • 切片表行主键 id;
  • doc_id;
  • wiki_item_id;
  • 文档内顺序 chunk_index;
  • 展示或检索使用的 content;
  • 表格等场景下的完整结构 original_content;
  • 向量记录关联键 milvus_id;
  • 有效状态 status。

MySQL 中的 chunk 是业务可管理实体:编辑、查看、审核、溯源都依赖它。Milvus 中的向量记录则更偏向检索索引。

3.4 wiki_document_tag:召回范围约束

标签用于在检索前缩小文档范围。指定知识库后,系统会先得到符合条件的 allowedDocIds,再对 Milvus 返回的候选结果做文档过滤。

这说明标签过滤当前主要由 MySQL 完成,Milvus 负责向量相似度搜索,不负责完整的业务标签权限判断。

4. 文档入库与索引构建

文档处理由后台任务负责。本章按执行顺序说明文档解析、切分、向量化和落库过程。

4.1 建库与召回总架构

flowchart TB subgraph SOURCE["业务知识源"] S1["飞书文档"] S2["PDF / Markdown / Word / HTML / TXT"] S3["飞书表格 / Excel / CSV"] end subgraph BUILD["离线建库:文档处理任务"] B1["读取 wiki_item 配置<br/>model_select / chunk_size / chunk_type"] B2{"解析路线"} B3["提取正文纯文本"] B4["读取表格行与字段配置"] B5{"切片策略"} B6["长度滑窗<br/>换行聚合<br/>一级标题<br/>自定义分隔符"] B7["一行一片<br/>索引字段 -> content<br/>完整字段 -> original_content"] B8["每 10 个 chunk 一个任务<br/>10 线程池并发"] B9["DashScope TextEmbedding<br/>模型来自 model_select<br/>dimension = 1024"] B10["生成 chunkId<br/>docId + '_' + chunkIndex"] end subgraph STORAGE["双存储"] M1[("Milvus<br/>Collection: wiki_doc_{wikiId}<br/>doc_id / chunk_id / vector")] M2[("MySQL<br/>wiki_document_chunk<br/>content / original_content / milvus_id")] end subgraph QUERY["在线召回接口"] Q1["query + wikiIds + wikiTags + size"] Q2["MySQL 计算 allowedDocIds"] Q3["使用同一 model_select<br/>生成 query vector"] Q4["Milvus COSINE Top-K"] Q5["按 chunk_id -> milvus_id<br/>回查有效切片"] Q6["按 allowedDocIds 过滤"] Q7["BGE 或 GTE Rerank"] Q8["relevance_threshold + limit"] Q9["返回 content / docId / chunk表id / score"] end S1 --> B1 S2 --> B1 S3 --> B1 B1 --> B2 B2 -->|"正文类"| B3 B2 -->|"表格类"| B4 B3 --> B5 --> B6 B4 --> B7 B6 --> B8 B7 --> B8 B8 --> B9 --> B10 B10 -->|"先写向量"| M1 B10 -->|"后写业务切片"| M2 Q1 --> Q2 Q2 --> Q3 Q3 --> Q4 M1 --> Q4 Q4 --> Q5 M2 --> Q5 Q5 --> Q6 --> Q7 --> Q8 --> Q9

建库和查询形成两条对应链路:

建库:document content -> chunk -> chunk vector
查询:query text      -> query vector -> 相似 chunk

建库和查询必须使用兼容的 Embedding 模型和相同向量维度。当前实现都读取 wiki_item.model_select,向量维度固定为 1024。

4.2 文档处理流程

该方法以一篇文档为处理单位。它先加载知识库配置并更新文档状态,再根据文档类型选择解析和切片方式。切片完成后准备 Milvus Collection,清理该文档的旧数据,最后执行向量化和双存储写入。

处理过程分为十个阶段:

阶段 处理动作 处理结果
1. 校验上下文 检查 Milvus 组件并查询知识库 得到知识库和模型配置
2. 计算切片大小 文档未配置切片大小时按模型补默认值 v2 为 2048,其他为 8192
3. 更新状态 把文档状态改为处理中 后台任务可以识别正在处理的文档
4. 选择解析器 飞书正文、本地正文、表格走不同分支 正文字符串或表格行
5. 生成切片 正文按配置切分,表格按行生成 切片文本及位置信息
6. 记录总数 更新文档的切片总数 文档记录包含本次切片数量
7. 准备 Collection 检查知识库对应的 Collection Milvus Collection 可以写入
8. 清理旧版本 按文档 ID 删除 Milvus 和 MySQL 旧数据 为本次全量重建腾出位置
9. 向量化与落库 分批生成 Embedding,先 Milvus 后 MySQL 向量记录和业务切片
10. 完成或失败 成功时标记完成,异常时标记失败 文档进入最终处理状态

设计说明与实际执行存在两处差异,以下内容以实际执行为准:

  • 实际 Collection 名称是 wiki_doc_{wikiId},因此是一个知识库一个 Collection,不是一个文档一个 Collection。
  • 实际 DASHSCOPE_MAX_BATCH_SIZE = 10,不是部分旧注释写的 25。

4.3 文档解析分支

解析路线由 URL 和 docType 共同决定:

flowchart TD A["WikiDocument"] --> B{"是否飞书文档 URL"} B -->|"是"| C["Lark Docx API<br/>读取 raw content"] B -->|"否"| D{"docType == 1"} D -->|"是:普通文档"| E["下载临时文件"] E --> F{"文件扩展名"} F -->|"pdf"| F1["PDFBox 提取文本"] F -->|"md / markdown / txt"| F2["UTF-8 文本读取"] F -->|"doc"| F3["HWPF WordExtractor"] F -->|"docx"| F4["XWPF WordExtractor"] F -->|"html / htm"| F5["Jsoup 提取 body.text"] D -->|"否:表格"| G{"表格来源"} G -->|"飞书表格"| G1["读取指定 Sheet"] G -->|"CSV"| G2["逐行解析 CSV"] G -->|"Excel"| G3["EEC 读取指定 Sheet"]

正文解析器最终都输出一个 String content。这一步会丢失一部分源文件结构,例如 HTML 标签、PDF 页面布局和 Word 样式。后面的切分器主要面对纯文本,不理解复杂版面。

4.4 chunkSize 配置与长度单位

当文档没有设置 chunkSize,代码根据 Embedding 模型补默认值:

if (chunkSize == 0) {
    if ("text-embedding-v2".equals(modelSelect)) {
        chunkSize = 2048;
    } else {
        chunkSize = 8192;
    }
}

代码中存在两种长度计算方式:

  • 普通正文切分使用 Java String.length() 和 substring(),本质上按字符下标处理;
  • 表格会先用 TokenizerUtils.countTokensWithSpecialTokens() 计算 token 数;
  • 表格超限后仍使用 substring(0, chunkSize) 截断,又回到了字符下标。

chunkSize 在正文和表格处理中分别对应字符长度和 token 限制。中文、英文、特殊字符和不同 tokenizer 下,两种长度并不严格相等。

4.5 正文切分策略

所有正文切分统一经过同一套路由:

系统根据 chunkType 选择切分策略,最后过滤空白切片,并保留每个切片在原字符串中的起止位置。

4.5.1 chunkType = 1:固定长度滑窗

核心计算是:

end = min(start + chunkSize, content.length)
chunk = content[start, end)
nextStart = start + chunkSize - overlapSize

例如:

chunkSize = 1000
overlapSize = 100

chunk 0 = [0, 1000)
chunk 1 = [900, 1900)
chunk 2 = [1800, 2800)

相邻切片共享 100 个字符,可以降低答案刚好跨越边界时的漏召概率。代价是重复文本增加,Embedding 调用量和向量数量也会增加。

当前方法没有看到对 overlapSize >= chunkSize 的显式校验。若配置不合法,start 可能无法正常向后推进。

4.5.2 chunkType = 2:按换行聚合

算法先执行:

String[] lines = content.split("\n");

然后按原顺序追加每一行。当“当前 chunk 长度 + 下一行长度”超过 maxChunkSize 时,先提交当前 chunk,再从下一行开始新 chunk。

示例:

maxChunkSize = 100

第 1 行 40 字符
第 2 行 35 字符  -> 当前约 75
第 3 行 50 字符  -> 加入后会超过 100

chunk 0 = 第 1 行 + 第 2 行
chunk 1 = 第 3 行

这种方式更适合段落式规则文档。当前算法不会继续拆分单个超长行,所以一行本身超过 maxChunkSize 时,仍可能形成超长 chunk。

4.5.3 chunkType = 3:按 Markdown 一级标题

核心正则是:

content.split("(?=^# )", Pattern.MULTILINE)

它只识别行首的一级标题:

# 退换货范围
# 特殊材质说明

## 二级标题 不会独立触发切分。这个策略能保留完整章节语义,但当前实现没有再按 chunkSize 做二次切分,所以一个很长的一级标题章节会整体成为一个 chunk。

4.5.4 chunkType = 4:自定义分隔符

算法先通过:

content.split(Pattern.quote(delimiter))

按业务配置的分隔符拆成多个 part,再按 maxChunkSize 聚合。拼装时会把分隔符加回当前 chunk,尽量保留原文结构。

适用场景包括:

  • 用 --- 分隔的问答条目;
  • 用固定业务标记分隔的规则块;
  • 已由上游预处理好的标准文本。

与换行策略类似,如果单个 part 自身已经超过最大长度,当前实现不会继续拆分它。

4.5.5 默认策略

未知的 chunkType 会回退到:

chunkSize = 1000
overlapSize = 100

这里的默认值直接写在路由方法中,不使用文档原本的 chunkSize 和 overlapSize。

4.6 表格切片规则

飞书表格、Excel 和 CSV 使用相同的业务建模方式:跳过表头,一行生成一个 chunk。

以下表格配置用于说明 field_index 的作用:

字段 值 field_index
品类 鞋类 1
问题类型 开胶 1
处理规则 穿着后需判断人为损伤 1
内部负责人 售后组 A 0

生成的数据是:

content =
  "品类:鞋类,问题类型:开胶,处理规则:穿着后需判断人为损伤"

original_content =
  {
    "品类":"鞋类",
    "问题类型":"开胶",
    "处理规则":"穿着后需判断人为损伤",
    "内部负责人":"售后组 A"
  }

两份内容的用途不同:

  • content 只拼接 field_index = 1 的字段,用于生成 Embedding 和 Rerank;
  • original_content 保存全部已配置字段,用于召回后的完整展示;
  • 表格 chunk 的 start_position 和 end_position 都设置为行序号。

field_index 会直接影响表格召回。未进入 content 的字段即使保存在 original_content,也不会参与当前的向量语义表达。

4.7 Embedding 处理流程

切分完成后进入向量化阶段,执行结构如下:

sequenceDiagram participant P as 文档处理任务 participant B as 向量化批处理 participant E as 10线程执行池 participant D as DashScope TextEmbedding participant V as Milvus participant M as MySQL P->>B: chunks + wikiItem.model_select loop 每 10 个 chunk 一批 B->>E: 提交一批 chunk E->>D: texts[0..9], model, dimension=1024 D-->>E: textIndex -> vector loop 批次内每个 chunk E->>E: chunkId = docId + "_" + globalChunkIndex E->>V: insertVector(doc_id, chunk_id, vector) E->>E: 构建 WikiDocumentChunk end E->>M: 批量 insert chunksToSave end B->>B: 等待所有批次结束 B-->>P: 批处理结束

4.7.1 分批与并发

当前固定参数为:

ExecutorService = Executors.newFixedThreadPool(10)
DASHSCOPE_MAX_BATCH_SIZE = 10

如果文档有 36 个切片,会产生 4 个异步任务:

任务 1:chunk 0  - 9
任务 2:chunk 10 - 19
任务 3:chunk 20 - 29
任务 4:chunk 30 - 35

每个任务调用 getEmbeddingsBatch,请求参数为:

TextEmbeddingParam.builder()
    .model(wikiItem.getModelSelect())
    .texts(batchChunks)
    .parameter("dimension", 1024)
    .build();

如果建库侧模型值为空,getEmbeddingsBatch 会回退到 text-embedding-v4。响应中的 textIndex 用于把每个向量映射回批次中的原始 chunk。

4.7.2 chunk 与双存储的关联

对批次内每一个 chunk:

globalChunkIndex = batchStartIndex + j;
chunkId = doc.getId() + "_" + globalChunkIndex;
vector = vectorResults.get(j);

随后按以下顺序写入:

第一步:Milvus.insertVector(
          collection = wiki_doc_{wikiId},
          doc_id     = docId,
          chunk_id   = chunkId,
          vector     = 1024维 FloatVector
        )

第二步:MySQL.insert(
          doc_id           = docId,
          wiki_item_id     = wikiId,
          chunk_index      = globalChunkIndex,
          content          = chunk,
          original_content = 表格原始 JSON 或 null,
          milvus_id        = chunkId,
          vectorized       = 1,
          status           = 1
        )

当前向量记录按 chunk 逐条写入向量库,业务切片按任务批次批量写入 MySQL。

4.7.3 批次异常处理

批次内部有两层异常处理:

  • MySQL 批量插入失败后只记录日志;
  • 整个批次失败后也只记录日志。

批次异常会被记录并结束当前任务。外层看到任务结束,不等于所有 chunk 都成功写入。随后文档状态仍可能被更新为完成。

生产排查需要同时校验以下三项:

wiki_document.total_chunks
MySQL 实际有效 chunk 数
Milvus 中该 doc_id 的向量数

4.8 文档重建与数据替换

新切片生成完、Collection 准备好之后,系统按文档 ID 清理旧向量和旧切片,再写入本轮数据。当前采用文档级全量替换:

生成新 chunks
  -> 删除旧 Milvus vectors
  -> 删除旧 MySQL chunks
  -> 写入新 vectors 和 chunks

它的优点是逻辑简单,不需要计算新旧 chunk 差异。代价是删除和新写入之间存在空窗期;如果新一轮 Embedding 或写库失败,该文档可能没有完整的可用版本。

5. ID 关系设计

flowchart LR W["wiki_item.id<br/>知识库范围"] --> K["wiki_doc_{wikiId}<br/>Milvus Collection"] W --> D["wiki_document.wiki_item_id"] D --> DOC["wiki_document.id / doc_id<br/>源文档"] DOC --> CH["chunkId = docId + '_' + chunkIndex"] CH --> V["Milvus.chunk_id"] CH --> Q["MySQL.milvus_id"] Q --> R["wiki_document_chunk.id<br/>业务行主键"]

5.1 ID 之间的关系

各层 ID 的关联关系如下:

wiki_item.id
  └── wiki_document.wiki_item_id
        └── wiki_document.id / doc_id
              └── wiki_document_chunk.doc_id
                    └── Milvus.doc_id

wiki_document.id + chunkIndex
  └── chunkId = docId + "_" + chunkIndex
        ├── wiki_document_chunk.milvus_id
        └── Milvus.chunk_id

每个 ID 的职责不同:

ID 所在位置 解决的问题
wiki_item.id / wiki_item_id 知识库、文档、切片关系 这条知识属于哪个知识库
doc_id / wiki_document.id 文档、切片、Milvus 这条知识来自哪篇源文档
wiki_document_chunk.id MySQL 这条切片在业务表中的行主键是什么
chunkIndex / chunk_index 文档内部 这条切片在原文中的顺序是什么
chunkId 入库关联键 如何在 MySQL 和 Milvus 之间定位同一个切片
milvus_id MySQL MySQL 保存的向量记录关联键
Milvus id Milvus 向量库内部的物理主键,当前为 auto id

5.2 wiki_item_id 的职责

doc_id 只能说明“来自哪篇文档”,无法单独说明“属于哪个知识库”。同一个业务系统里,文档可能被重新导入、复制到不同知识库,或者不同知识库存在同名文档。

wiki_item_id 主要承担三类作用:

  1. 业务归属:建立 知识库 -> 文档 -> 切片 的层级关系。
  2. 检索隔离:根据知识库选择对应的 Milvus Collection。
  3. 配置选择:根据知识库读取 Embedding、Rerank 和相关性阈值。

当前 Collection 命名为:

wiki_doc_{wikiId}

例如:

wiki_item.id = 12
Milvus Collection = wiki_doc_12

当前采用“一个知识库一个 Collection,Collection 内通过 doc_id 区分文档”。历史设计说明中曾出现“每个文档独立集合”的表述,实际运行方式以知识库维度为准。

5.3 ID 关联示例

以下数字用于说明 ID 关系,不代表线上真实数据:

知识库:
  wiki_item.id = 12
  业务名称 = 商品售后知识库
  Milvus Collection = wiki_doc_12

源文档:
  wiki_document.id = 345
  wiki_document.wiki_item_id = 12
  文档名称 = 鞋类售后规则 2026-09

文档中的第 7 个切片:
  chunkIndex = 7
  chunkId = "345_7"
  wiki_document_chunk.id = 9812
  wiki_document_chunk.doc_id = 345
  wiki_document_chunk.wiki_item_id = 12
  wiki_document_chunk.milvus_id = "345_7"

Milvus 中对应的记录:
  id = 550001          # Milvus auto id,物理主键
  doc_id = 345
  chunk_id = "345_7"
  vector = [...]

查询返回 chunk_id = "345_7" 后,系统可以在 MySQL 中按 milvus_id = "345_7" 找到 wiki_document_chunk.id = 9812,再拿到完整文本、原始表格内容和文档归属。

Milvus 负责“找得像不像”
MySQL 负责“这条内容是谁、属于哪里、展示什么”

6. MySQL 与 Milvus 存储分工

flowchart LR A["知识片段"] --> B["MySQL<br/>原文 / 状态 / 标签 / 来源"] A --> C["Embedding 向量"] C --> D["Milvus<br/>相似度 Top-K"] D --> E["chunk_id + score"] E --> B B --> F["完整内容与业务信息"]

6.1 两种存储的职责

当前 Milvus Collection 的核心字段是:

id       Int64        auto primary key
doc_id   Int64
chunk_id VarChar
vector   FloatVector

默认检索配置为:

dimension = 1024
indexType = IVF_FLAT
metricType = COSINE
nlist = 1024
nprobe = 10

两种存储的职责如下:

能力 MySQL Milvus
保存完整文本 负责 不作为主存储
保存表格原始 JSON 负责 不负责
保存知识库、文档、状态、标签 负责 不负责
处理标签范围和业务状态 负责 不负责
保存 Embedding 向量 通过关联信息间接管理 负责
按向量相似度 Top-K 搜索 不适合 负责
作为最终展示数据来源 负责 返回关联键和距离

6.2 向量数据与业务数据

向量适合检索,不适合承担完整业务数据职责:

  • 向量本身不能直接展示原文;
  • 向量不能表达文档处理状态和发布状态;
  • 标签、文档名称、来源 URL 等字段需要结构化管理;
  • 业务排查需要知道召回片段属于哪个知识库、哪篇文档、哪一行。

查询结果需要经过向量召回和 MySQL 回查:

问题向量
  -> Milvus 返回 chunk_id / doc_id / score
  -> MySQL 查询有效 chunk
  -> 按 allowedDocIds 过滤
  -> 按 sort_model 调用 Rerank
  -> 返回 content、original_content、docId、chunk 行 ID、score

6.3 写入顺序与一致性

当前一个批次大致采用:

1. 生成向量
2. 插入 Milvus
3. 构建 WikiDocumentChunk
4. 插入 MySQL

MySQL 和 Milvus 是两个独立存储。即使业务代码存在事务边界,常规数据库事务也不能天然覆盖 Milvus,因此“先 Milvus、后 MySQL”可能产生部分成功:

  • Milvus 已经有向量,但 MySQL 没有对应的 chunk;
  • MySQL 写入失败,下一次查询只能拿到无法完整回查的候选;
  • 删除或重建过程中,两个存储短时间内状态不一致。

后续如果需要更强的一致性保障,可以增加幂等写入、补偿任务、状态表、版本号或一致性校验。

7. 在线召回流程

在线入口为知识切片召回接口。

7.1 召回主链路

sequenceDiagram participant U as 用户 / 上游问答服务 participant API as 召回服务 participant DB as MySQL participant V as Milvus participant R as Rerank 服务 U->>API: query + wikiIds + 标签条件 API->>DB: 查询知识库配置与 allowedDocIds API->>API: 按知识库选择 Embedding 模型 API->>V: 搜索 wiki_doc_{wikiId} V-->>API: Top-K doc_id + chunk_id + score API->>DB: 按 milvus_id 查询有效 chunk DB-->>API: content + original_content + 文档信息 API->>API: 按 allowedDocIds 再过滤 alt sort_model = ops-bge-reranker-larger API->>R: 对候选片段重排 R-->>API: rerank score else sort_model = gte-rerank-v2 API->>R: 对候选片段重排 R-->>API: relevance score else 模型不支持或服务不可用 API-->>U: 抛出召回异常 end API->>API: relevance_threshold 过滤并取 limit API-->>U: 返回知识片段与来源信息

7.2 请求参数

GetSliceRequest 的核心结构为:

{
  "query": "麂皮鞋穿过一次后开胶,能否按质量问题退货?",
  "size": 10,
  "wikiIds": [12],
  "wikiTags": {
    "in": ["售后规则"],
    "notIn": ["历史版本"]
  }
}

参数含义:

参数 技术作用
query 同时用于生成查询向量和 Rerank
size 每个知识库的 Milvus topK,也是最终结果的 limit
wikiIds 决定读取哪些知识库配置,以及搜索哪些 wiki_doc_{wikiId}
wikiTags.in 查询带有指定标签的文档 ID
wikiTags.notIn 排除带有指定标签的文档 ID

7.3 召回处理步骤

第一步:确定知识范围

  • 校验传入的 wikiIds;
  • 查询对应的知识库;
  • 根据文档完成状态和标签条件得到 allowedDocIds。

没有标签条件时,MySQL 查询条件包括:

wiki_item_id IN (wikiIds)
is_deleted = false
status = 2

此时产物可能是:

allowedDocIds = [345, 346, 351]

第二步:生成查询向量

  • 每个知识库可以有自己的 model_select;
  • 对每个知识库分别调用文本向量化服务;
  • 请求只包含一个 query,维度固定为 1024;
  • 失败时返回空列表,该知识库会被跳过。

建库和查询的对称关系如下:

建库:
  chunk content
    -> wiki_item.model_select
    -> 1024维 chunk vector

查询:
  request.query
    -> 同一知识库的 wiki_item.model_select
    -> 1024维 query vector

如果同一个 Collection 中混入不同模型产生的向量,即使维度相同,向量空间也可能不兼容。因此切换 model_select 后,需要重新向量化该知识库中的历史文档。

第三步:Milvus 初召回

  • 选择 wiki_doc_{wikiId};
  • 使用 COSINE 距离和默认 nprobe = 10 搜索 Top-K;
  • 返回 doc_id、chunk_id 和相似度分数。

Milvus 请求的核心形态是:

collectionName = wiki_doc_12
data           = [queryVector]
outputFields   = [doc_id, chunk_id]
metricType     = COSINE
topK           = request.size
searchParams   = { nprobe: 10 }

Milvus 返回结果会被转成:

milvusIdToScore = {
  "345_7": 0.86,
  "351_2": 0.81,
  "346_5": 0.74
}

当请求包含多个知识库时,每个知识库各执行一次 Top-K,再把所有结果合并到同一个 milvusIdToScore。

第四步:MySQL 回查

  • 通过 milvus_id IN (...) 找到业务切片;
  • 只保留 chunk.status = 1 的有效切片;
  • 拿到原文、原始内容、文档 ID 和 MySQL chunk 行 ID。

对应关系为:

Milvus.chunk_id = "345_7"
        ↓
MySQL.wiki_document_chunk.milvus_id = "345_7"
        ↓
content / original_content / doc_id / chunk表主键

第五步:文档范围过滤

  • 使用前面得到的 allowedDocIds 再过滤;
  • 标签逻辑在这一层生效;
  • 这样可以避免“向量相似,但不属于本次允许范围”的内容进入结果。

当前 allowedDocIds 没有作为 Milvus filter 下推。实际顺序是:

整个知识库 Collection 做 Top-K
  -> MySQL 回查
  -> allowedDocIds 过滤

假设 size = 3,Milvus 前三名全部来自被标签排除的文档,而允许文档的最佳片段排在第 4 名,那么过滤后结果为 0,第 4 名也不会被补召回来。

Milvus Top-3:
  1. doc 900,score 0.93,被排除
  2. doc 901,score 0.91,被排除
  3. doc 902,score 0.89,被排除
  4. doc 345,score 0.87,允许,但未进入 Top-3

过滤结果:空

标签条件较严格时,当前 size 同时影响最终数量和候选召回深度。可选优化包括把 doc_id 过滤下推到 Milvus,或者使用大于最终返回数的候选池。

第六步:Rerank 重排

当前配置支持的 Rerank 模型包括:

  • ops-bge-reranker-larger
  • gte-rerank-v2

Rerank 对初召回候选重新计算问题与片段的相关度。向量召回负责候选覆盖,Rerank 负责候选排序和精度。

两种模型的调用路径是:

sort_model 服务 写回的分数
ops-bge-reranker-larger OpenSearchRerankService score.getScore()
gte-rerank-v2 TextRerankService result.getRelevanceScore()

当前召回流程没有“直接使用向量分数并正常返回”的通用分支:

  • 模型是上述两种之一且服务可用,进入 Rerank;
  • Rerank 调用过程中抛异常时,代码记录日志并保留当前候选;
  • 模型不支持或对应服务不可用时,进入 else 并抛出异常。

另外,MySQL 的 IN 查询没有显式 ORDER BY,不能假设回查结果仍保持 Milvus 顺序。Rerank 成功时会按 Rerank 响应顺序重建 finalChunks;Rerank 异常并回退时,filteredChunks 的原始顺序需要运行时验证。

第七步:阈值与数量控制

  • 使用第一个知识库的 relevance_threshold;
  • 代码先对 finalChunks.limit(topK),再逐项判断 score >= relevanceThreshold;
  • 返回片段内容和来源字段。

构建响应时:

original_content 有值
  -> flattenJsonToKeyValue(original_content)

original_content 为空
  -> content

最终 SliceItem 只包含:

{
  "content": "召回后的文本或打平后的表格行",
  "docId": 345,
  "score": 0.91,
  "id": 9812
}

这里的 id 是 MySQL wiki_document_chunk.id,不是 Milvus auto id,也不是 chunkId。

7.4 检索层与回答层

知识切片召回接口负责返回上下文,为上游大模型提供输入。最终答案涉及的 Prompt 组装、引用格式、流式输出和模型生成逻辑属于后续回答链路。

完整问答链路分为两段:

检索层:问题 -> 候选知识片段
生成层:问题 + 知识片段 -> 大模型最终回答

故障排查时可以分别确认:

  • “没有召回正确知识”;
  • “召回正确,但模型没有正确使用知识”。

8. 商品售后知识库示例

flowchart LR A["售后规则文档<br/>麂皮鞋 / 开胶 / 退货条件"] --> B["切片"] B --> C["Embedding"] C --> D["Milvus 索引"] B --> E["MySQL 原文与来源"] F["用户问题<br/>穿过一次后开胶能否退货"] --> G["查询向量"] G --> D D --> H["候选片段"] E --> H H --> I["Rerank / 阈值"] I --> J["上游问答"]

8.1 业务数据

示例知识库名称为“商品售后知识库”,包含:

  1. 《鞋类售后规则 2026-09》:Markdown 文档;
  2. 《特殊材质处理表》:Excel;
  3. 《品牌退换货补充规则》:飞书文档。

用户提问:

一双麂皮鞋穿过一次后发现开胶,还能按质量问题退货吗?

8.2 入库过程

以《鞋类售后规则 2026-09》为例:

wiki_item.id = 12
wiki_document.id = 345

文档配置为按一级标题切分,其中一个片段如下:

chunkIndex = 7
content = "麂皮鞋出现开胶时,需要结合穿着次数、开胶位置和是否存在人为损伤判断..."
chunkId = "345_7"

入库时:

MySQL:
  wiki_document_chunk.id = 9812
  wiki_document_chunk.wiki_item_id = 12
  wiki_document_chunk.doc_id = 345
  wiki_document_chunk.chunk_index = 7
  wiki_document_chunk.milvus_id = "345_7"
  wiki_document_chunk.status = 1

Milvus:
  Collection = wiki_doc_12
  doc_id = 345
  chunk_id = "345_7"
  vector = Embedding(content)

8.3 查询过程

用户问题进入知识切片召回流程后:

  1. 根据 wikiIds = [12] 确定只搜索商品售后知识库;
  2. 把问题生成查询向量;
  3. 在 wiki_doc_12 中初召回多个候选;
  4. 通过 chunk_id 回查 MySQL;
  5. 丢弃已失效切片或不在标签范围内的文档;
  6. 使用 Rerank 判断“麂皮鞋、穿过一次、开胶、质量问题”这组语义的相关度;
  7. 过滤低于阈值的候选,返回最相关的规则片段。

接口返回面向业务使用的文档、切片和相关性信息:

知识库:12
源文档:345
业务切片:9812
关联键:345_7
片段内容:麂皮鞋出现开胶时,需要结合穿着次数...
相关性分数:...

上游回答服务再基于这些片段组织最终答复,并可以把来源文档展示给用户。

9. 实现边界与风险

flowchart TD A["文档重建"] --> A1["先删旧数据,再写新数据"] A1 --> A2["失败时可能出现数据空窗"] B["双存储写入"] --> B1["Milvus 与 MySQL 独立"] B1 --> B2["可能出现孤儿向量或缺失切片"] C["异步批次"] --> C1["异常被记录后结束任务"] C1 --> C2["完成状态不代表全部写入成功"] D["在线召回"] --> D1["标签过滤在 Top-K 之后"] D1 --> D2["允许文档可能拿不到候选名额"] E["多知识库"] --> E1["Rerank 与阈值取第一项配置"]

以下内容来自当前实现梳理,生产环境仍需结合运行数据、数据库和 Milvus 实例验证。

9.1 双存储可能出现部分成功

Milvus 和 MySQL 不属于同一个常规事务边界。当前写入顺序为 Milvus 在前、MySQL 在后,失败时可能出现孤儿向量或缺少向量关联的 MySQL 记录。

文档处理流程内部捕获了最外层异常,并在失败时更新文档状态。事务边界和实际提交结果需要结合运行环境验证。无论 MySQL 事务是否生效,都无法自动回滚已经写入 Milvus 的向量。

排查建议:

按 wikiId / docId 对比:
  Milvus.doc_id + chunk_id
  MySQL.wiki_document_chunk.doc_id + milvus_id

9.2 重建期间可能暂时没有可用数据

如果重建流程先删除旧向量和旧切片,再写入新数据,且新数据处理失败,旧版本可能已经不存在。对于线上知识库,重建最好具备版本切换或失败回滚能力。

9.3 异步任务的成功状态需要运行时确认

异步批次会捕获并记录异常,MySQL 批量插入也有独立的异常处理。任务结束不代表所有切片都已经完整写入两个存储。不能只根据最终完成状态就下结论。

9.4 chunkId 当前没有版本号

当前 chunkId 由:

docId + "_" + chunkIndex

构成。这足以支持当前的 MySQL-Milvus 回查,但在并发重建、历史版本保留、灰度切换和回滚场景中,可能需要增加文档版本或索引版本。

9.5 多知识库召回的配置选择需要确认

当前召回逻辑涉及多个知识库时,Rerank 模型和阈值使用 validWikiItems.get(0) 的配置。需要确认产品设计是否要求“多个知识库共用第一项配置”,还是每个知识库应该分别使用自己的配置。

9.6 标签过滤发生在 Top-K 之后

allowedDocIds 没有下推到 Milvus。被排除文档仍会占用 Top-K 名额,严格标签条件下可能出现允许文档有相关片段,但返回结果不足甚至为空。

9.7 切片长度单位和边界校验不统一

  • 正文使用字符长度,表格先计算 token 再按字符截断;
  • overlapSize >= chunkSize 没有显式防护;
  • 换行、一级标题、自定义分隔符策略都可能产生超过 chunkSize 的单个 chunk。

这些边界会影响 Embedding 请求是否超限,以及不同语言文档的切片粒度是否一致。

9.8 Rerank 回退路径不完整

召回流程只接受两个明确的 sort_model。模型不支持或服务不可用时会抛出异常;调用过程中异常虽然会保留候选,但 MySQL 回查结果没有显式排序,不能确认仍按 Milvus 分数顺序返回。

9.9 DDL 与数据库索引

本文未覆盖完整建表 DDL,因此以下信息不能仅凭业务对象说明确认:

  • 是否存在物理外键;
  • milvus_id 是否有唯一索引;
  • 字段真实长度;
  • 线上字段类型和字符集;
  • 删除、更新时是否有数据库级联约束。

这些内容应通过实际数据库 schema 或发布配置补充验证。

10. 配套架构图

flowchart LR A["系统架构图"] --> A1["看组件与数据流"] B["检索架构图"] --> B1["看召回、过滤与重排"] C["数据库关系图"] --> C1["看知识库、文档、切片关系"] D["ID 关系图"] --> D1["看 MySQL 与 Milvus 如何回查"]

配套图示可以结合本文一起讲解:

系统架构图

检索架构图

数据库关系图

ID 关系图

分享时可重点结合最后两张图:

  • 数据库关系图说明业务对象如何关联;
  • ID 关系图说明 MySQL 和 Milvus 如何通过 chunkId 互相找到同一个知识片段。

11. 总结

flowchart TD A["文档"] --> B["解析与切分"] B --> C["Embedding"] C --> D["Milvus 召回"] D --> E["MySQL 回查"] E --> F["过滤 / Rerank / 阈值"] F --> G["知识片段"] G --> H["模型回答"]
  1. wiki_item 是知识库根作用域,wiki_item_id 决定业务归属、检索隔离和模型配置。
  2. wiki_document 表示源文档,wiki_document_chunk 表示源文档拆出来的可管理知识片段。
  3. doc_id 解决“来自哪篇文档”,chunk_id 解决“文档中的哪一个片段”。
  4. MySQL 保存业务事实和展示内容,Milvus 保存向量检索所需的数据。
  5. Milvus 返回候选关联键,MySQL 回查完整内容和业务元数据。
  6. 标签和文档状态属于业务过滤,应在召回链路中明确区分向量搜索与业务过滤。
  7. 召回结果是上游生成答案的输入,不能直接等同于最终答案。
  8. 生产验证应覆盖双存储一致性、重建失败处理、异步异常传播、切片长度单位、过滤召回深度和多知识库配置策略。

posted @ 2026-09-10 18:09  alin_qu  阅读(38)  评论(0)    收藏  举报