数据截至 (上游 commit 482e72519a4e)
知识库(RAG):从文档摄入到混合检索
30 秒导读: RAG(Retrieval-Augmented Generation,检索增强生成)就是「让模型答 题前先去翻资料」。本章讲 FastGPT 的这套「资料系统」怎么运转——左半边把文档拆成小块、排队、向量化后存进库;右半边在用户提问时把问题扩写、同时跑向量搜索和关键词搜索、用 RRF 算法把两路结果融成一张榜、必要时再精排,最后把命中的片段回填给工作流。第 04 章讲模型拿到这些片段后怎么生成答案,本章只管把最相关的片段找出来。
本章接在 工作流引擎 和 AI 节点 之后:知识库检索本身也是工作流里的一个节点(dataset search node),它产出的「引用(quote)」会作为上下文喂给下游的 LLM 节点。
1. 这是什么(零基础也能懂)
-
一句话定义: 知识库子系统 = 一个「文档进、相关片段出」的检索引擎,让 AI 回答能引用你自己的资料而不是瞎编。
-
解决什么问题: 大模型不知道你公司的内部文档、产品手册、历史工单。RAG 的做法是:预先把这些资料切碎存好;提问时先检索出最相关的几段,连同问题一起塞给模型。模型于是「带着资料答题」。
-
两个阶段,缺一不可:
阶段 白话 什么时候发生 摄入(ingest) 把文档拆块、算向量、存进库 你上传/同步文档时,后台异 步慢慢跑 检索(retrieval) 拿问题去库里捞最相关的块 用户每次提问时,实时同步跑 -
一句话直觉: 摄入像「给一屋子书做卡片索引」(慢、离线、一次性);检索像「拿着问题冲进图书馆,同时查卡片目录和全文,再把最像的几本抽出来」(快、在线、每次都做)。
-
FastGPT 的关键取舍: 它不押注单一检索方式,而是默认双路召回 + RRF 融合——向量搜索懂语义、全文搜索懂关键词,两者各有盲区,融合起来才稳。这是本章的重点。
2. 顶层全景(它大概怎么转)
先给一张两半边的总图。怎么读: 上半是离线摄入(左→右),下半是在线检索(左→右);两半在中间的 dataset_datas 库(存好的块 + 向量)会合——摄入往里写,检索从里读。
━━━━━━━━━━━━━━ 摄入侧(异步 / 离线)━━━━━━━━━━━━━━
上传文档
│
▼
① 拆块 rawText → chunks[](可含 QA 拆分/图片块)
│ createCollectionAndInsertData
▼
② 入训练队列 pushDataListToTrainingQueue → MongoDatasetTraining
│ (mode: chunk / qa / auto / image / parse)
▼
③ 后台 worker 拆QA / 调 VLM / 算 embedding 向量
│
▼
┌─────────────────────────────────────────┐
│ dataset_datas(块的 q/a/图 + 索引) │
│ + 向量库(vector)+ dataset_data_texts │──── 全文索引
└─────────────────────────────────────── ──┘
▲ │
│ 写 │ 读
━━━━━━━━━━━━━━ 检索侧(同步 / 在线)━━━━━━━━━━━━━━
用户提问
│
▼
④ 查询扩展 datasetSearchQueryExtension(LLM 改写出多条检索词)
│
▼
⑤ 多路召回 multiQueryRecall ──并发──┬─ embeddingRecall(向量/语义)
│ └─ fullTextRecall(关键词/jieba)
▼
⑥ RRF 融合 datasetSearchResultConcat(按名次加权合并成一张榜)
│
▼
⑦ 重排+过滤 reRankSearchResults(只对文本)→ 去重 → 相似度阈值 → token 上限
│
▼
⑧ 回填工作流 dispatchDatasetSearch → quoteQA → 下游 LLM 节点
各部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
createCollectionAndInsertData | 摄入总入口:拆块 + 建 collection + 入队 | packages/service/core/dataset/collection/controller.ts:41 |
pushDataListToTrainingQueue | 把 chunk 批量写进训练队列 | packages/service/core/dataset/training/controller.ts:73 |
MongoDatasetTraining | 训练队列表(worker 按 mode 排优先级拉取) | packages/service/core/dataset/training/schema.ts:17 |
MongoDatasetData | 存好的块:q/a、图片、索引 | packages/service/core/dataset/data/schema.ts:15 |
defaultSearchDatasetData | 检索总入口:编排扩写 + 分发召回 | packages/service/core/dataset/search/index.ts:18 |
multiQueryRecall | 并发调度向量 + 全文两路召回 | packages/service/core/dataset/search/defaultRecall/multiQueryRecall.ts:10 |
datasetSearchResultConcat | RRF 融合算法 | packages/global/core/dataset/search/utils.ts:5 |
reRankSearchResults | 只对文本召回做精排 | packages/service/core/dataset/search/defaultRecall/rerank.ts:55 |
dispatchDatasetSearch | 检索节点:把结果变成工作流引用 | packages/service/core/workflow/dispatch/dataset/search.ts:56 |
3. 摄入侧:文档怎么变成「可检索的块」
这一半是异步的,用户上传后不阻塞——真正的重活(拆 QA、调 VLM、算向量)交给后台 worker 慢慢跑。设计核心是一张训练队列表。
3.1 拆块:一份文档 → chunks[]
摄入总入口是 createCollectionAndInsertData(collection/controller.ts:41)。它做的第一步是把原始文本切成小块(chunk),块大小、重叠率等由 collection 的配置决定:
// 示意,非源码:拆块的核心思路
const chunks = await rawText2Chunks({
rawText, // 整份文档的纯文本
chunkSize, // 每块目标大小
overlapRatio: 0.2, // chunk 模式下块间重叠 20%,避免切断语义
});
// → [{ q: "第一段…" }, { q: "第二段…" }, …]
真实实现见 collection/controller.ts:117-169:文本走 rawText2Chunks,图片则每张图直接当作一个块({ imageId, indexes: [] })。重点看一处取舍:只有普通 chunk 模式才加 overlapRatio: 0.2(collection/controller.ts:142)——块之间留 20% 重叠,防止一句话正好被切在两块边界而两块都答不全。
3.2 训练模式:同一张队列,五种加工方式
拆完块要「加工」——但加工方式不止一种。FastGPT 用一个枚举 TrainingModeEnum 统一表达(packages/global/core/dataset/constants.ts:239):
| mode | 干什么 | 谁来算 |
|---|---|---|
chunk | 直接对块算 embedding 向量 | 向量模型 |
qa | 先让 LLM 把块拆成一问一答,再向量化 | LLM(agent 模型) |
auto | chunk + 自动补索引 | 向量 + LLM |
image | 对图片算图片向量 | 图片 embedding 模型 |
imageParse / parse | 先调 VLM 把图/文件解析成文本,再回队列 | VLM |
具体给哪个 mode,由 getTrainingModeByCollection(collection/utils.ts)根据 collection 的训练类型、是否开图片索引、是否 Plus 版决定。注意 image/auto/imageParse 都要求 global.feConfigs?.isPlus——这些是商业版能力,社区版会回落到 chunk。
3.3 入队:pushDataListToTrainingQueue
拆好的块通过 pushDataListToTrainingQueue(training/controller.ts:73)批量写进 MongoDatasetTraining 表。这个函数的两个看点:
看点一:按 mode 决定「一块最多多大、权重多少」(training/controller.ts:113-148):
// 示意,非源码:不同 mode 的入队参数
if (mode === 'chunk') maxToken = Infinity, weight = 向量模型权重;
if (mode === 'qa' || 'auto') maxToken = getLLMMaxChunkSize(llm), weight = 0;
if (mode === 'image'/'imageParse') maxToken = VLM 上下文, weight = 0;
// 超过 maxToken 的块直接丢弃(training/controller.ts:163)
weight 只有向量化的 chunk 模式非零——它后面会决定 worker 拉取优先级。
看点二:大数据量分段事务(training/controller.ts:171-248)。每批 500 条,每个事务最多 20 批(= 1 万条);超过 1 万就切成多个事务,每段用 retryFn 包起来重试。这是为了避免「一次性插 10 万条」把 MongoDB 事务撑爆。
对于还没解析成文本的文件(比如刚上传的 PDF),走的是另一条更轻的入口 pushDatasetToParseQueue(training/controller.ts:264)——它只创建一条 mode: parse 的记录,等 worker 先把文件解析成文本,再回头走上面的拆块入队流程。
3.4 队列表的排队模型
worker 怎么知道先干哪条?靠 MongoDatasetTraining 上的一个复合索引(training/schema.ts:130):
TrainingDataSchema.index({ mode: 1, retryCount: 1, lockTime: 1, weight: -1 })
读法:按 mode 分组 → retryCount 小的(快用完重试次数的)优先 → lockTime 早的(没被锁的)优先 → weight 大的优先。配合 lockTime 字段(schema.ts:52)实现「谁抢到锁谁执行」,retryCount 默认 5 次(schema.ts:56),expireAt 挂了 7 天 TTL(schema.ts:131)兜底清理。团队级的锁由 lockTrainingDataByTeamId(training/controller.ts:19)处理——比如团队 AI 点数用尽时,用一个 30 分钟的定时锁把该团队所有任务冻结,避免继续扣费。
3.5 collection 与 data:两级组织
摄入的产物落在两张表,是一个「文件 → 块」的父子关系:
| 表 | 粒度 | 存什么 | 文件 |
|---|---|---|---|
dataset_collections | 一份文档/一个来源 | 来源、训练配置、原文 hash | collection/schema.ts |
dataset_datas | 一个块 | q/a/imageId + indexes[](每条索引带自己的 dataId) | data/schema.ts:15 |
dataset_datas 里最关键的是 indexes 数组(data/schema.ts:51):一个块可以有多条索引向量(比如 QA 模式下问题和答案各一条)。每条索引在向量库里是独立的一个点,各带一个 dataId。检索时向量库返回的是 dataId,需要再回查 dataset_datas 才能拿到完整的 q/a——这个「向量库出 id、Mongo 出内容」的两步走,是理解检索侧回查逻辑的前提。
此外还有一张 dataset_data_texts(data/dataTextSchema.ts)专门存全文索引 token,供 Mongo $text 全文搜索用。