目录

RAGFlow 详解:架构、数据层级与检索流程

目录

RAGFlow 是一个完整的文档 RAG 平台,覆盖文件上传、解析、分块、索引、混合检索、重排、上下文扩展、LLM 生成和引用回填。它适合处理 PDF、Word、网页、表格和扫描件等企业资料,而不只是提供一个向量检索 SDK。

它解决的核心问题是:如何把复杂文档加工成可检索、可解释、可用于生成答案的证据。

本文依据本地 RAGFlow 提交 554fb1133ac3861732235ad9c377eb5e0a770665 分析,以默认 Python API/Ingestion 和 Elasticsearch 路径为主。

1. 总体架构与底层存储

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
flowchart TB
    U[Web / REST API / SDK] --> API[Quart API Server]
    API --> SQL[(MySQL:控制面与业务元数据)]
    API --> OBJ[(MinIO:原始文件与图片)]
    API --> Q[(Redis Streams:任务队列)]

    Q --> W[Task Executor]
    W --> OBJ
    W --> P[Parser / DeepDoc / OCR]
    P --> C[分块与增强]
    C --> E[Embedding]
    E --> IDX[(Elasticsearch:全文与向量索引)]
    W --> SQL

    API --> R[Dealer Retriever]
    R --> IDX
    R --> RR[融合与 Rerank]
    RR --> L[LLM]
    L --> CI[引用插入与修复]
存储 默认实现 保存内容 角色
关系数据库 MySQL Tenant、用户、KB、Document、Task、Dialog、配置 控制面和层级关系
对象存储 MinIO 原始文件、Chunk 图片、缩略图 原始二进制事实
文档引擎 Elasticsearch Chunk 文本、向量、关键词、位置、图和摘要数据 检索投影
Redis Redis Streams 任务、进度、取消、缓存和分布式状态 异步协调

因此 RAGFlow 并没有把所有内容都放入“向量数据库”:原始文件、业务状态与检索投影分别存储。

2. Tenant、KB、Document 与 Chunk 层级

RAGFlow 原生维护以下层级:

1
2
3
4
5
6
7
flowchart TB
    T[Tenant:租户 / 工作空间] --> K[Knowledgebase:知识库]
    K --> D[Document:来源文档]
    D --> C1[Chunk:检索片段]
    D --> C2[Chunk:检索片段]
    U[User] --> UT[UserTenant:成员与角色]
    UT --> T

2.1 Tenant:数据与模型配置的工作空间

MySQL 的 tenant 表保存租户 ID、名称、默认 LLM、Embedding、Rerank、OCR 和 Parser 等配置。user_tenant 表使用 user_id + tenant_id + role 表示用户加入了哪些 Tenant 以及角色。

Tenant 是最大的隔离边界,但不等于单个用户。一个工作空间可以有多个成员;代码中知识库创建者的用户 ID 也常作为租户所有者 ID 使用。

搜索侧会通过 Tenant 生成索引名:

1
ragflow_<tenant_id>

因此 Tenant 不只是 Chunk 上的普通标签,它还参与选择物理搜索索引。

2.2 Knowledgebase:同一主题的文档集合

MySQL 的 knowledgebase 表通过 tenant_id 归属 Tenant,并保存:

  • 名称、描述、语言和创建者。
  • permission=me|team 的可见范围。
  • 默认 Embedding、Parser、相似度阈值与向量权重。
  • 文档数、Chunk 数和 Token 数。
  • GraphRAG、RAPTOR 等知识库级任务状态。

KB 是逻辑集合,不代表每个知识库单独部署一套 Elasticsearch。Chunk 通常仍在租户索引中,通过 kb_id 区分。

2.3 Document:来源和处理状态

MySQL 的 document 表通过 kb_id 归属某个 Knowledgebase,并保存文件名、类型、来源、对象存储位置、Parser、解析状态、Chunk 数、Token 数等。

Document 是管理和来源单位;Chunk 是它解析后产生的检索单位。普通 Chunk 正文主要保存在 Elasticsearch,而不是作为 MySQL 子表逐条保存。

1
2
3
4
5
Document:MySQL索引.md
  ├─ Chunk 1:B+ 树的节点结构
  ├─ Chunk 2:叶子页与范围查询
  ├─ Chunk 3:二级索引与回表
  └─ Chunk 4:全文索引与倒排表

File 是另一套文件树模型,具有 parent_idtenant_id,负责文件夹与原始文件管理。它不要和 Document→Chunk 的检索层级混为一谈。

2.4 Chunk 如何保存归属

一条简化后的 Elasticsearch Chunk 类似:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "id": "c-btree-2",
  "kb_id": "database-kb",
  "doc_id": "mysql-index-doc",
  "docnm_kwd": "MySQL索引.md",
  "content_with_weight": "叶子节点有序排列并通过链表连接,因此适合范围查询",
  "content_ltks": "叶子 节点 链表 范围 查询",
  "q_1024_vec": [0.018, -0.032, "..."],
  "page_num_int": [3],
  "available_int": 1
}

Chunk 不需要重复保存 tenant_id:调用方先用 Tenant 选择 ragflow_<tenant_id> 索引,再在索引内部按照 kb_iddoc_id 等字段过滤。

2.5 本文贯穿示例:MySQL索引.md

后文不再使用互不相关的零散样例,而是始终追踪同一篇文档在各阶段生成了什么记录。假设向 database-kb 上传 MySQL索引.md,Document ID 为 mysql-index-doc,原文如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# MySQL 索引

索引用额外的存储结构减少数据库需要扫描的数据量。不同索引适合解决不同查询问题,
并不是索引越多越好,因为索引还会占用空间,并增加插入、更新和删除的维护成本。

## 一、B+ 树索引

InnoDB 的普通索引主要使用 B+ 树。B+ 树由根节点、中间节点和叶子节点组成。
根节点和中间节点主要保存键值及下一层页面的指针,真正的索引记录集中在叶子节点。

叶子节点按照索引键有序排列,而且相邻叶子页之间存在链表指针。因此查询 id=100 时,
可以从根节点逐层定位叶子页;查询 id between 100 and 200 时,定位起点后可以沿叶子页顺序扫描。

聚簇索引的叶子节点保存完整行数据,二级索引的叶子节点保存索引列和主键值。
使用二级索引查询其他列时,可能需要根据主键再次访问聚簇索引,这个过程通常称为回表。

## 二、全文索引

全文索引适合对较长文本做关键词搜索。它不会按照整段文本建立 B+ 树查找路径,
而是先对文本分词,再维护“词语到文档或记录”的倒排映射。

例如文章 A 包含 mysql 和 transaction,文章 B 包含 mysql 和 index,倒排表会记录
mysql -> [A, B]、transaction -> [A]、index -> [B]。查询时可以根据关键词快速找到候选记录。

MySQL 可以使用 MATCH(column) AGAINST(keyword) 执行全文查询。全文索引适合文章内容搜索,
但不适合替代主键查询、精确等值查询和范围查询;这些场景通常仍应使用 B+ 树索引。

下面使用的 c-btree-1m-btree 等 ID 都是为了便于阅读而简化的。真实 ID 通常是 UUID 或内容哈希;分词和完整向量也会省略,只保留能说明生成与索引过程的字段。

3. 层级权限和读取过滤

层级关系不仅用于组织页面,也参与授权和检索。

3.1 API 层先判断能否访问 KB

KnowledgebaseService.accessible(kb_id, user_id) 的逻辑是:

  1. KB 必须存在且状态有效。
  2. 如果用户就是 KB 所属 Tenant 的所有者,允许访问。
  3. 否则 KB 必须是 team 权限。
  4. 用户还必须是对应 Tenant 的有效成员。

DocumentService.accessible(doc_id, user_id) 先找到 Document,再把检查委托给它所属的 KB。因此 Document 权限继承自 KB,而不是每个 Document 单独维护一套 ACL。

3.2 先确定检索范围,再计算内容相关性

这里最容易产生的误解是:RAGFlow 会不会先分析用户问题,再依次推断 Tenant、KB 和 Document?

标准对话流程并不是这样。它实际上把检索分成两个问题:

阶段 回答的问题 决定依据
范围选择 允许去哪里搜索? Dialog 配置、用户选择、API 参数和权限
相关性计算 范围内哪些 Chunk 与问题相关? 全文检索、向量检索和 Reranker

也就是说,tenant_idskb_ids 和可选的 doc_ids 通常在调用 Retriever 前就已经确定。用户问题主要用于在这个范围中搜索和排序 Chunk。

tenant_id 选择租户索引是什么意思

Elasticsearch 的 Index 可以理解为一张逻辑表或一个顶层数据集合。RAGFlow 按 Tenant 生成索引名:

1
2
3
tenant_id = company-a
Elasticsearch Index = ragflow_company-a

同一个 Tenant 的多个 KB 通常共享这个索引,再通过 Chunk 上的 kb_id 区分:

1
2
3
4
ragflow_company-a
├── kb_id = database-kb
├── kb_id = redis-kb
└── kb_id = hr-policy

因此“选择租户索引”表示先决定向哪个 Elasticsearch Index 发查询,不是根据问题预测一个 Tenant 标签。如果选择的共享 KB 分别属于多个 Tenant,Retriever 也可以同时查询多个 ragflow_<tenant_id> 索引。

Tenant ID 也不是直接从问题得到的,而是由已选 KB 反查:

1
2
kbs = KnowledgebaseService.get_by_ids(dialog.kb_ids)
tenant_ids = list({kb.tenant_id for kb in kbs})

执行顺序是:

1
2
3
4
5
先取得已选 kb_ids
读取这些 KB 的 tenant_id
生成 ragflow_<tenant_id> 索引名

系统怎么知道问题与哪个 KB 相关

标准 Dialog 路径通常不会在检索前自动判断问题属于哪个 KB。创建 Chat 应用时,管理员已经为它配置了一组 dialog.kb_ids,例如:

1
2
3
4
{
  "dialog_id": "database-assistant",
  "kb_ids": ["database-kb", "redis-kb"]
}

用户提问后,RAGFlow 会同时搜索这两个 KB,统一比较其中 Chunk 的分数:

1
2
3
4
5
6
7
database-kb
├── B+ 树范围查询 Chunk  0.91
└── 全文索引 Chunk       0.48

redis-kb
├── Redis Sorted Set     0.22
└── Redis Stream         0.16

用户提问“为什么 B+ 树适合范围查询”后,经过阈值和排序,最终可能只返回得分为 0.91 的 B+ 树 Chunk。换句话说:KB 决定搜索空间,问题决定这个空间中哪些 Chunk 排名靠前。

KB 范围主要有三种来源:

  1. 创建 Chat/Dialog 时由管理员预先绑定,这是标准对话路径。
  2. 用户在搜索界面选择,或者调用方通过 API 显式传入 kb_ids
  3. 在 Retriever 前增加业务 Router,通过规则、KB 描述向量、LLM 分类或 Agent 工作流动态选择 KB。

第三种属于额外的查询路由能力;底层 Dealer.retrieval() 接收到的已经是确定的 kb_ids,它本身不会再做 KB 语义分类。如果有大量不同主题的 KB,不希望每次全部搜索,就需要在上层增加 Router。

完整过滤和召回顺序

1
2
3
4
5
6
7
8
9
flowchart LR
    U[已认证用户] --> B[Dialog / API 给出 kb_ids]
    B --> A[检查 KB 可访问性]
    A --> T[由 KB 反查 tenant_ids]
    T --> I[选择 ragflow_tenant 索引]
    I --> K[kb_id Filter]
    K --> D[可选 doc_id / Metadata Filter]
    D --> V[available_int=1]
    V --> R[全文 + Dense 召回和排序]

Dealer.retrieval() 最终收到的请求类似:

1
2
3
4
5
6
7
{
  "tenant_ids": ["company-a"],
  "kb_ids": ["database-kb"],
  "doc_ids": ["mysql-index-doc"],
  "available_int": 1,
  "question": "为什么 B+ 树适合范围查询"
}

其中 tenant_ids 用来生成索引名;Dealer.get_filters()kb_ids 映射为索引字段 kb_id,把 doc_ids 映射为 doc_id。过滤条件与全文和 KNN 查询一起交给文档引擎,所以执行语义近似为:

1
2
3
4
5
6
7
8
9
INDEX = ragflow_company-a

WHERE kb_id IN (database-kb)
  AND doc_id IN (mysql-index-doc)
  AND available_int = 1

MATCH 全文 OR Dense KNN
ORDER BY 融合相关性
LIMIT Top K

Metadata 条件会先解析为匹配的 scoped_doc_ids,再作为 doc_id Filter 进入后端搜索。这虽然包含“计算文档范围、执行搜索”两步,但不是取回全局 Top K 后再删除不匹配结果。

检索后还有 _prune_deleted_chunks(),用于清除 MySQL 中 Document 已删除但搜索索引尚有残留的 Chunk。这是最终一致性兜底,不是主要授权机制。

3.3 权限模型的边界

RAGFlow 主要提供 Tenant、KB、Document 粒度的范围控制,并不是通用 Chunk 行级 ACL 引擎。安全性依赖 API 层先完成可访问性检查,并始终把允许的 Tenant/KB/Document 范围传给 Retriever。

4. 写入:文档摄取流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
sequenceDiagram
    participant C as Client
    participant A as API
    participant M as MySQL
    participant O as MinIO
    participant Q as Redis
    participant W as Worker
    participant P as Parser
    participant E as Embedding
    participant S as Search Engine

    C->>A: 上传文件并创建 Document
    A->>O: 保存原始文件
    A->>M: 保存 File、Document、Task
    A->>Q: 发布解析任务
    W->>Q: 消费任务
    W->>O: 读取文件
    W->>P: OCR、版面分析与分块
    P-->>W: Chunk + 页码 + 坐标 + 标题
    W->>W: 关键词、问题、Tag 等增强
    W->>E: 批量生成向量
    W->>S: 写入 Chunk 与向量
    W->>M: 更新进度和计数

解析器会根据文档类型处理 PDF、Word、PPT、Excel、网页和图片。标准 Chunk 还可以携带标题、页码、版面坐标、图片、关键词、候选问题、标签和 Metadata。

以贯穿示例 MySQL索引.md 为例,如果没有开启父子 Chunk,Parser 可能先生成六个普通 Chunk:

1
2
3
4
5
6
c-btree-1   B+ 树的根节点、中间节点和叶子节点
c-btree-2   有序叶子页为什么支持等值与范围查询
c-btree-3   聚簇索引、二级索引与回表
c-fulltext-1  全文索引的分词与倒排映射
c-fulltext-2  mysql -> [A,B] 的倒排表示例
c-fulltext-3  MATCH AGAINST 和适用场景

其中一条写入搜索索引的记录可以简化为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "id": "c-btree-2",
  "kb_id": "database-kb",
  "doc_id": "mysql-index-doc",
  "docnm_kwd": "MySQL索引.md",
  "content_with_weight": "叶子节点按照索引键有序排列,而且相邻叶子页之间存在链表指针……",
  "content_ltks": "叶子 节点 索引键 有序 范围 查询",
  "q_1024_vec": [0.018, -0.032, "..."],
  "available_int": 1
}

这就是后续增强的基础:父子 Chunk 会改变它的切分粒度并增加引用;TOC 会引用这些 Chunk ID;RAPTOR 会读取这些 Chunk 再生成摘要;GraphRAG 会读取这些 Chunk 再抽取实体和关系。

原始文件在 MinIO,Document 状态在 MySQL,Chunk 投影在 Elasticsearch;跨存储依赖任务状态、重试和清理实现最终一致。

5. 读取:混合召回与重排

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
flowchart TD
    Q1["Q1 整理用户问题"] --> Q2["Q2 确定 Tenant / KB / Document 范围"]
    Q2 --> Q3["Q3 生成全文表达式、keywords 和问题向量"]
    Q3 --> Q4["Q4 ES 全文门槛 + KNN 初召回"]
    Q4 --> Q5["Q5 清除失效 Document 的残留 Chunk"]
    Q5 --> Q6["Q6 取得词项、向量和 Rank Feature 分数"]
    Q6 --> Q7["Q7 融合、排序、阈值过滤和分页"]
    Q7 --> Q8["Q8 可选 TOC 导航增强"]
    Q8 --> Q9["Q9 父子 Chunk 上下文替换"]
    Q9 --> Q10["Q10 可选外部搜索结果合并"]
    Q10 --> Q11["Q11 可选 GraphRAG 独立检索并插入图证据"]
    Q11 --> Q12["Q12 组装 Evidence Prompt 并调用 LLM"]
    Q12 --> Q13["Q13 校验或补插 Chunk 引用"]

5.1 一次完整查询的主链

下面以用户在 database-assistant 中提问“为什么 B+ 树适合范围查询”为例。Dialog 已经绑定 database-kbMySQL索引.md 的 Document ID 是 mysql-index-doc

[Q1] 整理本次用于检索的问题

标准路径默认只取最后一条用户问题。根据 Dialog 配置,还可以先执行:

  • refine_multiturn:把多轮对话改写成一个完整问题。
  • cross_languages:把问题扩展成指定语言。
  • keyword:调用 LLM 抽取关键词并追加到问题。

假设这些功能没有开启,本步骤输出仍然是:

1
为什么 B+ 树适合范围查询

[Q2] 确定允许检索的数据范围

这一阶段不计算内容相关性。系统先取得 Dialog 或 API 指定且用户有权访问的 kb_ids,再由 KB 反查 tenant_ids;Metadata 条件会先转换成 scoped_doc_ids

1
2
3
4
tenant_ids = [company-a]           → 选择 ragflow_company-a
kb_ids     = [database-kb]         → 只允许该 KB
doc_ids    = [mysql-index-doc]     → 可选,只允许指定 Document
available_int = 1                  → 只允许普通可检索记录

这些范围随后作为 ES Filter 进入初召回,不是先取得全库结果再做权限删除。

[Q3] 从同一个问题生成三种查询数据

FulltextQueryer.question() 对问题做大小写与字符规范化、分词、停用词处理、词项加权、细粒度切分和同义词扩展,产生两个不同结果;Embedding 模型再产生第三个结果:

结果 用在哪里 含义
matchText.matching_text [Q4] ES 全文条件 带单词、短语、近邻短语和同义词的 Lucene 查询表达式
keywords [Q6] term_similarity Python 后续计算查询词项覆盖率时使用的词项列表
query_vector [Q4] 和 [Q6] Dense KNN 整个问题的 Embedding,例如 q_1024_vec

因此“全文至少匹配 30%”针对的是 matching_text 被 ES 解析后的查询子句,不是简单数原句里有几个中文词,也不等于后续的 keywords 数组。

[Q4] ES 初召回一个候选窗口

ES 同时执行范围限制、全文准入和 Dense KNN:

1
2
3
4
5
可参加 KNN 的记录
= 指定 Tenant 索引
 ∩ kb_id / 可选 doc_id
 ∩ available_int=1
 ∩ 满足全文 minimum_should_match 的记录

当前固定的 FusionExpr("weighted_sum", {"weights":"0.001,1"}) 在 ES 适配器中只使用第二个 1:全文条件仍决定记录能否参加,但全文 Boost 为 0,初召回顺序主要由 KNN _score 决定。

本步骤返回一个约 64 条的候选窗口,包括每条 Hit 的 _id、初始 _score 和所需 _source 字段。RAPTOR 摘要如果已经构建,会在 [Q4] 作为普通可检索记录和原始 Chunk 一起竞争;父记录、TOC 和 Graph 记录通常 available_int=0,不会进入这一步。

[Q5] 清除搜索索引中的失效记录

初召回返回后,_prune_deleted_chunks() 根据候选的 doc_id 查询 MySQL。如果某个 Chunk 所属 Document 已经删除,但 ES 尚未完成清理,就从候选窗口中移除。这是搜索索引最终一致性的兜底,不是主要权限过滤。

[Q6] 为每个候选取得最终排序所需的信号

默认 Elasticsearch 路径执行:

  1. 对 [Q5] 留下的候选 ID 做第二次 KNN-only 搜索,只取得干净的 ID → KNN _score,不再读取 _source
  2. 使用 [Q3] 的 keywords 与第一次 _source 中的 content_ltkstitle_tksimportant_kwdquestion_tks 计算 term_similarity
  3. 读取可选的 Tag Feature 和 pagerank_fea,计算 rank_feature

term_similarity 不是 ES 全文 _score,也不是索引中预存的字段。代码把查询 keywords 和 Chunk 词项分别转换成单词及相邻二元词特征,然后计算“查询特征被 Chunk 覆盖的加权比例”:

1
2
3
term_similarity
= Chunk 中存在的查询特征权重之和
  ÷ 全部查询特征权重之和

例如 [Q3] 返回的简化 keywords=[B+树, 范围查询, 叶子节点],A 同时包含三项,覆盖率高;B 只有 B+树、叶子节点,覆盖率较低;C 只有无关的“倒排表”,覆盖率接近 0。真实数值还会受到分词权重和相邻二元词影响。

如果配置了外部 Reranker,本步骤改为使用 Reranker 模型给问题与候选正文打分,不执行默认的第二次 KNN-only 分支。

[Q7] 融合分数,得到基础 Top Chunk

没有外部 Reranker 时,默认公式是:

1
2
3
similarity = (1 - vector_similarity_weight) × term_similarity
           + vector_similarity_weight × vector_similarity
           + rank_feature

代码随后稳定降序排列,删除低于 similarity_threshold 的候选,再按 page_size 截取当前页。这时得到的是“基础 Top Chunk”,还不是最终送给 LLM 的证据。

[Q8] 可选的 TOC 导航增强

仅在 toc_enhance=true 时执行。系统把 [Q7] 的 Chunk 按 Document 汇总分数,选出最相关 Document,读取它的 TOC 特殊记录,让 LLM 根据问题选择目录节点对应的 Chunk ID:

  • 已在 [Q7] 结果中的 Chunk:增加 TOC 相关分。
  • 不在 [Q7] 结果中的 Chunk:按 TOC 给出的 ID 补取正文并加入。
  • 最后重新按相似度排序并截取 top_n

所以 TOC 改写的是 [Q7] 的基础结果列表,而不是参与 [Q4] 的普通混合召回。

[Q9] 把命中的子 Chunk 替换为父 Chunk

retrieval_by_children() 检查 [Q8] 输出中的 mom_id。带有同一个 mom_id 的命中子块会被移出列表,系统按该 ID 读取不可直接检索的母记录,再放入一条父 Chunk:

1
2
3
正文       = 父 Chunk 的完整原文
检索词项   = 命中子 Chunk 的 content_ltks 合并
similarity = 这些命中子 Chunk 的 similarity 平均值

因此父子 Chunk 的作用位置是“检索小块已经命中以后扩大上下文”,不会改变 [Q4] 初召回的搜索粒度。

[Q10] 可选地合并外部搜索结果

如果开启 Web Search,系统在父子替换后取得网页 Chunk,并追加到当前 Chunk 列表和 Document 聚合中。这条分支与 RAGFlow 知识库索引无关。

[Q11] 可选地插入 GraphRAG 证据

仅在 use_kg=true 时执行。KGSearch 不复用 [Q4] 的普通 Chunk 候选,而是在相同 Tenant/KB 范围内独立检索 entityrelation 和可选 community_report 记录,结合预计算的 N-hop 与 PageRank,组装出一条临时 Chunk:

1
2
docnm_kwd = Related content in Knowledge Graph
content   = Entities 表 + Relations 表 + 可选 Community Report

只要内容非空,这条图证据就插在当前 Chunk 列表第一个位置。因此 GraphRAG 是一条在 [Q11] 汇入的并行证据分支,不参与 [Q6] 的普通 Chunk 融合公式。

[Q12] 组装证据 Prompt 并调用 LLM

kb_prompt() 根据 Token 预算把当前 Chunk 转成 knowledges,填入系统 Prompt 的 {knowledge}。此时证据可能同时包含:

  • [Q4] 命中的普通 Chunk 或 RAPTOR 摘要。
  • [Q8] 通过 TOC 补取或加权的 Chunk。
  • [Q9] 替换后的父 Chunk。
  • [Q10] 外部网页 Chunk。
  • [Q11] 插在最前面的 GraphRAG 合成 Chunk。

系统还会加入引用格式要求,然后调用回答模型生成答案。

[Q13] 校验或补插引用

如果 LLM 已经输出合法 [ID:n],系统校验编号并修复格式。如果没有引用,系统按需读取当前证据 Chunk 的向量,把答案分句,并根据答案句子与 Chunk 的词项/向量相似度补插 [ID:n],最后只保留实际引用 Document 的资料信息。

检索增强位置总览

能力 构建时生成什么 查询时介入位置 具体改变
Metadata / 权限范围 MySQL 层级和 Document Metadata [Q2]、[Q4] 先缩小允许搜索的索引、KB 和 Document,再召回
全文 + Dense KNN 普通 Chunk 的分词字段和向量 [Q3]、[Q4] 全文设置候选门槛,KNN 形成初始候选顺序
本地融合 / Reranker 不生成新索引记录 [Q6]、[Q7] 重算候选分数、排序、阈值过滤和分页
RAPTOR raptor_kwd 的可检索摘要 Chunk [Q4],随后经过 [Q6]、[Q7] 摘要和原始 Chunk 在同一个候选空间直接竞争
TOC available_int=0、toc_kwd=toc 的目录记录 [Q8] 根据基础结果选 Document,再用目录补取或加权 Chunk
父子 Chunk 子记录的 mom_id 和不可直接召回的母记录 [Q9] 用小块命中,再把命中子块替换成父块完整正文
GraphRAG entity/relation/subgraph/graph/community_report 记录 [Q11] 独立检索图记录,组装一条图证据插到普通证据之前
引用 不生成新的索引结构 [Q12]、[Q13] 要求 LLM 引用证据;缺失时按答案与 Chunk 相似度补插

5.2 混合召回为什么包含两次 ES 查询

第一次是 [Q4] 的候选召回:Dealer.search() 同时构造全文 Match、Dense KNN 和 weighted_sum Fusion,通过一次 dataStore.search() 交给后端。它不是应用层先查全文、再查向量、最后手工取并集。第二次是 [Q6] 针对候选 ID 的 KNN-only 计分,不会扩大候选集合。

weighted_sum Fusion 是什么

这里有两套容易混在一起的权重:

所在阶段 权重 实际用途
第一次 ES 召回 固定的 "0.001,1" 当前 ES 适配器只读取第二个 1,把全文查询的 Boost 设为 0;第一个 0.001 没有使用
第二步重新打分 KB/Dialog 的 vector_similarity_weight,默认 0.3 在 Python 中真正计算 0.7 × 词项相似度 + 0.3 × KNN 分数

所以 "0.001,1" 不是最终分数公式。它只服务于第一次取得一个较大的候选池;真正决定最终排序的是第二套可配置权重。

第二套 vector_similarity_weight 按设计应位于 [0,1],词项权重由 1 - vector_similarity_weight 得到。当前这段代码没有主动截断越界值,因此调用方仍应保证配置合法。

下面继续使用问题“为什么 B+ 树适合范围查询”。为便于观察,假设租户索引中原本有以下记录。表里的分数是说明流程的示例值,不是对这段文字实际运行模型得到的值。

Chunk 内容摘要 KB / Document available_int 与问题的向量接近程度
A 叶子节点有序,并通过链表连接,适合范围扫描 database-kb / mysql-index-doc 1
B B+ 树的根节点、中间节点和叶子节点 database-kb / mysql-index-doc 1 较高
C 全文索引使用倒排表 database-kb / mysql-index-doc 1 一般
D Redis Sorted Set 的跳表实现 redis-kb / redis-doc 1 很高
E B+ 树范围扫描的旧版本内容 database-kb / mysql-index-doc 0 很高
第一次召回:拿到哪些记录和分数

第一次搜索在 ES 内先应用以下限制:

  • 只搜索调用方给出的 Tenant 索引。
  • kb_id 必须属于本次选择的 KB。
  • 传入 doc_ids 时,doc_id 必须在指定范围内。
  • available_int=1
  • 默认 vector_similarity_weight=0.3 < 0.8,因此全文条件要求至少匹配约 30% 的查询词;这个全文条件也被放进 KNN Filter。

因此,即使 D、E 的向量分数很高,也不会进入候选池:D 属于错误的 KB,E 已经不可用。若某条记录完全不满足全文条件,它同样不会参加这一次 KNN 排名。

在当前 ES 适配器中,"0.001,1" 被处理为:

1
2
3
4
vector_similarity_weight = 1
全文查询 boost = 1 - 1 = 0
KNN boost = 未显式设置
第一个 0.001 = 未使用

全文条件仍然负责筛选“有没有资格参加”,但它的打分贡献被设为 0。因此第一次返回结果可以理解为:

初召回顺序 Chunk 为什么留下 ES 返回的初始 _score(示例)
1 A 范围正确、满足全文条件、向量最接近 0.92
2 B 范围正确、满足全文条件、向量较接近 0.89
3 C 范围正确、勉强满足全文条件、向量一般 0.55

这个 _score 主要是 ES 的 KNN _score,不是 0.001 × 全文分数 + 1 × 向量分数。对于 cosine 向量,ES 返回的是它自己的 KNN 计分结果;RAGFlow 在此处不把它还原成原始向量,也不使用第一个 0.001 重新计算。

第一次返回的每个 Hit 大致包含两部分:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
  "_id": "A",
  "_score": 0.92,
  "_source": {
    "content_with_weight": "叶子节点按照索引键有序排列……",
    "content_ltks": "叶子 节点 索引键 有序 范围 查询",
    "title_tks": "MySQL 索引 B+ 树",
    "important_kwd": ["B+树", "范围查询"],
    "kb_id": "database-kb",
    "doc_id": "mysql-index-doc",
    "pagerank_fea": 0
  }
}

_source 不是计算出来的分数,它只是这条 Chunk 原来存储的正文和元数据。Dealer.search()_source 保存到 sres.field[A],同时把第一次的 _score 也暂存在其中。主检索为了减少传输,不取回 Chunk 向量。

top=1024 表示 ES 的 KNN 最多寻找 1024 个邻居,并不表示最终直接返回 1024 条给用户。RAGFlow 实际只从 ES 取一个约 64 条的重排窗口;例如 page_size=10 时窗口向上取整为 70 条。

第二次搜索:只为候选记录重新取得 KNN 分数

第一次得到 A、B、C 后,_knn_scores() 发起第二次 KNN-only 搜索。它的范围不是整个知识库,而是:

1
2
id IN [A, B, C]
AND kb_id IN [database-kb]

这一步传入的 select_fields=[],所以不读取 _source,只取每条候选的 _id_score

1
2
3
4
5
{
  "A": 0.92,
  "B": 0.89,
  "C": 0.55
}

代码把这个结果称为 knn_scores。它的作用是得到不混入全文打分和 Rank Feature 的 KNN 分数,并按照 ID 映射回第一次取得的 A、B、C;正文等 _source 数据仍然复用第一次查询的结果。

第二次搜索没有再次携带 doc_idavailable_int,但不会因此把 D、E 加回来,因为它只能搜索第一次候选 ID [A,B,C]。第一次结果返回后,代码还会查询 MySQL,删除所属 Document 已不存在的残留 Chunk。

在 Python 中计算最终分数并排序

接下来 rerank_with_knn() 为每个候选计算两个值:

  1. term_similarity:问题关键词在 Chunk 的 content_ltkstitle_tksimportant_kwdquestion_tks 中的覆盖程度。
  2. vector_similarity:第二次 ES 搜索返回的 KNN _score

默认 vector_similarity_weight=0.3,所以 term_similarity_weight=0.7。没有额外 Tag 或 PageRank 加分时:

1
2
final_similarity = 0.7 × term_similarity
                 + 0.3 × vector_similarity

把示例记录代入:

Chunk term_similarity 第二次 KNN vector_similarity 计算 最终 similarity
A 0.90 0.92 0.7×0.90 + 0.3×0.92 0.906
B 0.55 0.89 0.7×0.55 + 0.3×0.89 0.652
C 0.02 0.55 0.7×0.02 + 0.3×0.55 0.179

如果配置了 Tag Feature 或 pagerank_fea,它们还会作为 rank_feature 加到上述结果上:

1
2
3
final_similarity = 词项权重 × term_similarity
                 + 向量权重 × vector_similarity
                 + rank_feature

最后代码按 final_similarity 稳定降序排列,再应用 similarity_threshold。默认阈值假设为 0.2,那么 C 的 0.179 会被删除,剩下:

1
2
A  0.906
B  0.652

最后才按照分页取 page_size 条。例如候选窗口有 70 条、page_size=10,流程是:先对 70 条重算分数并排序,删除低于阈值的记录,再返回当前页前 10 条。这 10 条才是调用方实际得到的最终 Top 结果。

返回给上层的 Chunk 会明确区分三种分数:

1
2
3
4
5
6
7
{
  "chunk_id": "A",
  "content_with_weight": "叶子节点按照索引键有序排列……",
  "similarity": 0.906,
  "term_similarity": 0.90,
  "vector_similarity": 0.92
}

因此,不要使用第一次 ES 返回的 _score=0.92 解释最终排名。最终采用的是 Python 重新计算的 similarity=0.906。如果启用了外部 Reranker,第二次 KNN-only 分支会被 Reranker 模型分数替代,但之后仍然执行降序、阈值过滤和分页。

5.3 父子 Chunk 和 TOC

父子 Chunk:从章节大块切出检索小块

父子关系在写入阶段生成,在查询阶段只影响 [Q9]。子 Chunk 正常参加 [Q4]~[Q7],母记录不参加普通召回。

父子 Chunk 使用两次确定性的切分,不需要 LLM 判断关系。以 MySQL索引.md 的 B+ 树章节为例:

  1. 主分块先按 delimiterchunk_token_num 把整个“B+ 树索引”章节形成一个较大的块 M1。它包含树结构、范围查询、聚簇索引和回表。
  2. 开启 parent_child.use_parent_child 后,执行层根据 children_delimiterM1 按段落继续切成 c-btree-1c-btree-2c-btree-3
  3. 每个子对象临时保存同一份完整母文本 mom=M1.text
  4. 写索引时,RAGFlow 对母文本做 xxhash64,得到简化表示 m-btree。子记录增加 mom_id=m-btree,同时额外写入一条 id=m-btree 的母记录。
1
2
3
4
M1:B+ 树章节完整原文
 ├─ c-btree-1:根节点、中间节点和叶子节点
 ├─ c-btree-2:有序叶子页与范围查询
 └─ c-btree-3:聚簇索引、二级索引与回表

ragflow_<tenant_id> 搜索索引中,核心记录可以简化为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{ "id": "c-btree-1", "doc_id": "mysql-index-doc", "kb_id": "database-kb",
  "content_with_weight": "B+ 树由根节点、中间节点和叶子节点组成……",
  "mom_id": "m-btree", "available_int": 1,
  "q_1024_vec": [0.018, -0.027, "..."] }

{ "id": "c-btree-2", "doc_id": "mysql-index-doc", "kb_id": "database-kb",
  "content_with_weight": "叶子节点有序排列并通过链表连接,因此适合范围查询。",
  "mom_id": "m-btree", "available_int": 1,
  "q_1024_vec": [0.011, -0.032, "..."] }

{ "id": "m-btree", "doc_id": "mysql-index-doc", "kb_id": "database-kb",
  "content_with_weight": "B+ 树章节的完整原文:结构、范围查询、聚簇索引和回表……",
  "available_int": 0 }

全文索引章节会以相同方式生成 c-fulltext-1..3m-fulltext。父子关系只有一跳:

1
2
3
c-btree-1.mom_id ─┐
c-btree-2.mom_id ─┼──> m-btree.id
c-btree-3.mom_id ─┘

母记录没有 children 数组,也没有单独的 mother_kwd。它的身份来自“自己的 id 被子块的 mom_id 引用”,所以这不是可递归遍历的任意层级树。

查询“为什么 B+ 树适合范围查询”时:

  1. [Q4]~[Q7]:普通检索只搜索 available_int=1 的小块,c-btree-2 容易精确命中并完成基础排序。
  2. [Q9]retrieval_by_children() 发现它带有 mom_id=m-btree
  3. [Q9]:系统按 ID 读取 available_int=0m-btree
  4. [Q12]:最终把 B+ 树章节的大块原文交给 LLM,实现“用小块搜索、用大块回答”。

TOC:从已有 Chunk 生成章节导航

TOC 在写入阶段生成目录记录,在查询阶段只影响 [Q8]。它读取 [Q7] 已经排好的基础结果,但不参加 [Q4] 初召回。

TOC 不会改变上述 Chunk,也不使用 mom_id。普通 Chunk 生成后,LLM 阅读 MySQL索引.md 的标题和内容,输出目录层级及每个章节对应的 Chunk ID。系统把整个目录作为一条特殊记录写入同一搜索索引:

1
2
3
4
5
6
7
8
{
  "id": "toc-mysql-index",
  "doc_id": "mysql-index-doc",
  "kb_id": "database-kb",
  "toc_kwd": "toc",
  "available_int": 0,
  "content_with_weight": "[{'level':1,'title':'MySQL 索引'}, {'level':2,'title':'B+ 树索引','chunk_ids':['c-btree-1','c-btree-2','c-btree-3']}, {'level':2,'title':'全文索引','chunk_ids':['c-fulltext-1','c-fulltext-2','c-fulltext-3']}]"
}

这条记录表达的是“目录标题 → 已有 Chunk ID”,而不是父 Chunk:

1
2
B+ 树索引 ──> c-btree-1、c-btree-2、c-btree-3
全文索引   ──> c-fulltext-1、c-fulltext-2、c-fulltext-3

查询“这篇文章介绍了哪两种索引”时,系统先在 [Q4]~[Q7] 通过普通召回确定 MySQL索引.md 最相关;[Q8] 再按 doc_id + toc_kwd="toc" 读取目录,让 LLM 选择“B+ 树索引”和“全文索引”两个章节,最后按 chunk_ids 加权或补取正文。TOC 自身 available_int=0,不会作为普通正文命中。

5.4 RAPTOR 与 GraphRAG

RAPTOR:从六个基础 Chunk 生成多层摘要

RAPTOR 在写入阶段增加摘要记录,在查询阶段影响 [Q4]~[Q7]。它没有单独的查询后处理步骤:可检索摘要直接与普通 Chunk 一起召回、重新打分和排序。

RAPTOR 不按标题建立父子关系,而是读取前面已经写入的普通 Chunk 文本和向量,然后循环执行“相邻语义聚类 → LLM 摘要 → 摘要 Embedding → 再聚类”。在本例中可以形成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
第 0 层(已有普通 Chunk)
  c-btree-1、c-btree-2、c-btree-3
  c-fulltext-1、c-fulltext-2、c-fulltext-3

第 1 层(两个主题摘要)
  r-btree:B+ 树通过有序叶子页支持等值和范围查询,二级索引可能回表……
  r-fulltext:全文索引通过分词和倒排表支持长文本关键词搜索……

第 2 层(整篇文档摘要)
  r-all:MySQL 会根据查询类型选择 B+ 树索引或全文索引……

算法运行时使用 parent_child_map 暂时记录 r-all → [r-btree,r-fulltext],并向上合并叶子来源。但普通可检索模式不会像父子 Chunk 那样持久化 mom_id,而是把每个摘要独立写成可检索记录:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{ "id": "r-btree", "doc_id": "mysql-index-doc", "kb_id": "database-kb",
  "raptor_kwd": "raptor", "raptor_layer_int": 1,
  "content_with_weight": "B+ 树通过有序叶子页支持等值和范围查询,二级索引可能回表……",
  "content_ltks": "B+树 叶子页 等值 范围查询 二级索引 回表",
  "q_1024_vec": [0.021, -0.018, "..."] }

{ "id": "r-fulltext", "doc_id": "mysql-index-doc", "kb_id": "database-kb",
  "raptor_kwd": "raptor", "raptor_layer_int": 1,
  "content_with_weight": "全文索引通过分词和倒排表支持长文本关键词搜索……",
  "q_1024_vec": [0.017, -0.012, "..."] }

{ "id": "r-all", "doc_id": "mysql-index-doc", "kb_id": "database-kb",
  "raptor_kwd": "raptor", "raptor_layer_int": 2,
  "content_with_weight": "MySQL 会根据查询类型选择 B+ 树索引或全文索引……",
  "q_1024_vec": [0.008, -0.006, "..."] }

raptor_kwd="raptor" 说明它是摘要 Chunk,raptor_layer_int 说明摘要层级。普通 Chunk 与这些摘要都有分词和向量,所以一起参加全文 + Dense KNN:

  • 问“叶子节点为什么适合范围扫描”,更可能命中原始 c-btree-2
  • 问“B+ 树索引有哪些特点”,更可能命中 r-btree
  • 问“B+ 树和全文索引应该如何选择”,更可能命中总摘要 r-all

查询不会先命中叶子再沿 parent_id 上行。RAPTOR 的主要作用是把不同抽象层级的摘要直接加入候选空间。部分路径会保存 source_chunk_ids;结构展示模式也可以额外保存一条 raptor_kwd="raptor_tree"、available_int=0 的嵌套树 JSON,但它不参加普通召回。

构建配置位于 parser_config.raptor,包括 use_raptorscope=file|dataset、摘要 promptmax_tokenmax_clusterclustering_thresholdclustering_ratiorandom_seed。它作为后台任务执行;重新构建时先写入新摘要,再清理旧摘要。

GraphRAG:如何启用

GraphRAG 在写入阶段生成图记录,在查询阶段通过独立的 [Q11] 分支检索;它不参加普通 Chunk 的 [Q4]~[Q7] 排名。

GraphRAG 有两个独立开关,缺一不可:

  1. 构建开关:在 KB 的 parser_config.graphrag 中设置 use_graphrag=true,并执行 GraphRAG/Graph 索引任务,生成图数据。
  2. 查询开关:对话的 prompt_config.use_kg=true,或者检索 API 请求传 use_kg=true,查询阶段才会额外调用 KGSearch

示意配置如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "graphrag": {
    "use_graphrag": true,
    "method": "light",
    "entity_types": ["organization", "person", "geo", "event", "category"],
    "resolution": false,
    "community": false,
    "batch_chunk_token_size": 4096,
    "retry_attempts": 2
  }
}
  • method=light:LightRAG 风格的 LLM 抽取,当前默认。
  • method=general:较完整的 LLM 图抽取路径。
  • method=ner:基于 NER 的抽取路径,实体/关系抽取本身不依赖 LLM。
  • resolution=true:做实体消歧与合并,例如把“OpenAI”和“Open AI”合并。
  • community=true:做社区划分并让 LLM 生成社区报告,适合全局性问题,但构建更慢、成本更高。

本地代码还提供构建、合并、实体消歧和社区阶段的超时与重试配置。需要特别注意当前 checkout 的接口演进:兼容层注释把旧 /run_graphrag 指向 /index?type=graph,但统一索引服务又把 type=graph 映射为新的 structure_graph 任务;旧的 task_type="graphrag" 处理器仍然存在。也就是说,这个提交中的“Graph/Structure Graph”和旧 GraphRAG 构建入口正在合并演进,不能只凭 URL 名称判断执行了哪条代码路径。部署时应以实际版本产生的任务类型和索引记录为准。

GraphRAG 使用什么数据库

RAGFlow 没有为 GraphRAG 单独配置 Neo4j、NebulaGraph 之类的图数据库。图构建过程在内存中使用 NetworkX,持久化仍通过统一 docStoreConn 写入当前文档引擎;默认 Elasticsearch 部署中,它们仍位于 ragflow_<tenant_id> 索引,并用 kb_id 隔离 KB。

MySQL 保存 KB 配置和任务状态,Redis 用于任务、锁、缓存与阶段标记;它们都不是 GraphRAG 的图查询数据库。若部署切换其他文档引擎,GraphRAG 也是走对应的文档引擎适配器,而不是另配一套图数据库连接。

GraphRAG 从本例的六个基础 Chunk 中抽取出的内容可能是:

1
2
3
4
5
6
实体:B+ 树索引、叶子节点、范围查询、二级索引、回表、全文索引、倒排表

关系:
  B+ 树索引 ──支持──> 范围查询
  二级索引  ──可能触发──> 回表
  全文索引  ──使用──> 倒排表

RAGFlow 没有把这些数据写入 Neo4j,而是把每个实体和关系转换为带向量的搜索索引记录:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
  "id": "e-btree",
  "knowledge_graph_kwd": "entity",
  "entity_kwd": "B+ 树索引",
  "entity_type_kwd": "category",
  "content_with_weight": "{\"description\":\"适合等值和范围查询的有序树索引结构\"}",
  "source_id": ["mysql-index-doc"],
  "rank_flt": 0.031,
  "n_hop_with_weight": "[...]",
  "kb_id": "database-kb",
  "available_int": 0,
  "q_1024_vec": [0.013, -0.019, "..."]
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
  "id": "rel-btree-range",
  "knowledge_graph_kwd": "relation",
  "from_entity_kwd": "B+ 树索引",
  "to_entity_kwd": "范围查询",
  "content_with_weight": "{\"description\":\"B+ 树的叶子页有序并通过链表连接,因此支持范围查询\",\"weight\":3}",
  "source_id": ["mysql-index-doc"],
  "weight_int": 3,
  "kb_id": "database-kb",
  "available_int": 0,
  "q_1024_vec": [0.009, -0.014, "..."]
}

完成实体和关系写入后,还会保存图的整体结构:

  • knowledge_graph_kwd="subgraph"MySQL索引.md 的 NetworkX node-link JSON,source_id=["mysql-index-doc"],既是文档级图,也是构建断点。
  • knowledge_graph_kwd="graph":把 database-kb 中所有 Document 子图合并后的 KB 图快照,source_id 包含所有来源 Document ID。
  • knowledge_graph_kwd="community_report":可选的社区摘要与证据。

这些记录的 available_int=0 表示不进入普通 Chunk 召回;它们由专门的 KGSearch 查询。

GraphRAG 如何构建、更新和检索

构建流程是:

1
2
3
4
5
6
7
8
9
Document 普通 Chunk
  → 按 token 预算分批
  → light/general/ner 抽取实体与关系
  → 保存每个 Document 的 subgraph
  → 合并成 KB graph
  → 计算 PageRank 与 N-hop 邻居
  → 可选实体消歧
  → 可选社区划分与社区报告
  → 写入 entity、relation、graph、subgraph、community_report 记录

更新不是查询时临时重新抽取。已有 subgraph 会被当作检查点并跳过 LLM 抽取,因此文档内容变化后,最稳妥的更新方式是使旧文档子图失效,或者重置 Graph 索引后重新运行构建。全量 wipe 会删除 graph/subgraph/entity/relation/community_report 并清除阶段标记,再重新排队构建。删除 Document 时,代码会从图记录的 source_id 中移除该 Document,并把总图标记为 removed_kwd="Y",后续可根据剩余子图重建。

图更新时先准备新的图快照、子图和实体/关系向量,准备完成后再删除或替换旧的图记录;社区报告也采用先写新集合、再清理过期 ID 的方式,降低构建中断造成空图的风险。

检索流程全部发生在 [Q11]

  1. 例如问题“哪些索引支持范围查询”,LLM 先抽取问题实体“范围查询”和可能的答案类型。
  2. 在指定 tenant index + kb_ids 范围内,对 knowledge_graph_kwd="entity" 做 Dense 检索,命中“范围查询”和“B+ 树索引”,并按实体类型、PageRank 和相似度补强。
  3. knowledge_graph_kwd="relation" 做 Dense 检索,命中 B+ 树索引 → 支持 → 范围查询,再结合 N-hop 路径和关系权重排序。
  4. 若启用社区报告,按相关实体读取高权重 community_report
  5. 把实体表、关系表和社区报告拼成一个临时结果:docnm_kwd="Related content in Knowledge Graph"
  6. 将该合成 Chunk 插入普通检索证据的前面,再一起交给回答模型。

因此 GraphRAG 不是让 Elasticsearch 执行图遍历,也不是把 Cypher 发给 Neo4j;它是“向量检索图记录 + RAGFlow Python 代码读取预计算的 N-hop/PageRank + 组装图证据”。

简单说:父子 Chunk 持久化一跳外键;RAPTOR 算法生成摘要树、检索时主要使用扁平摘要节点;GraphRAG 把实体关系保存为专用文档记录,再由 KG Retriever 额外组织图证据。

5.5 引用如何生成

引用发生在 [Q12]~[Q13],不会反过来改变 [Q4]~[Q11] 已经选出的证据。

系统先在 Prompt 中把知识片段编号,要求 LLM 输出 [ID:n]。生成后会校验引用格式和范围。

如果模型没有给出引用,decorate_answer() 会把答案拆成句子,对答案片段和候选 Chunk 计算词项与向量相似度,再给最相关的句子插入 [ID:n],最多关联四个 Chunk。

这种后插引用是相关性匹配,不是事实蕴含证明。高风险场景仍应增加 Claim Verification 或“证据不足则拒答”。

6. 主要问题与解决方案

问题 RAGFlow 的方案
PDF、表格和扫描件难解析 DeepDoc、OCR、版面识别和专用 Parser
固定分块破坏语义 结构分块、标题路径、父子 Chunk、TOC
纯向量漏掉精确词项 全文与 Dense KNN 混合召回
初召回顺序不准 词项/向量融合和可选 Reranker
概括性问题难命中 RAPTOR 摘要节点
跨 Chunk 关系难表达 GraphRAG 图证据
回答难以验证 Chunk 引用和引用资料返回
多租户数据混淆 Tenant 索引、KB/Document Filter 和访问检查
大文件处理耗时 Redis 任务、Worker、进度、取消和重试

7. 事实源与检索投影的边界

RAGFlow 保存原始文件,并把 Chunk 与向量作为解析投影,这是合理方向。但它还不是完整的动态文档版本系统:

  • Chunk PATCH 会直接更新检索投影,不会回写原始文件。
  • 重新解析可能覆盖人工修改的 Chunk。
  • 没有统一的 document_version_id 和 ready-version 原子读指针。
  • 跨 MinIO、MySQL、Redis、Elasticsearch 的写入不是单一事务。
  • File/Artifact Commit 尚未形成“提交原文版本—构建完整索引—原子发布—一键回退”的闭环。

所以它适合以上传、同步和重新解析为主的企业知识库。如果需要多人频繁在线编辑同一文档,应在上层增加版本化事实源和发布协议。

8. 本地代码依据

  • api/db/db_models.py:Tenant、UserTenant、Knowledgebase、Document、File 等模型。
  • api/db/services/knowledgebase_service.py:KB 可访问性检查。
  • api/db/services/document_service.py:Document 权限、状态和任务。
  • rag/svr/task_executor.pyrag/svr/task_executor_refactor/:摄取、解析、增强、RAPTOR、Embedding 和索引。
  • rag/nlp/search.pyrag/utils/es_conn.py:租户索引、过滤、混合召回、ES 查询翻译、重排和结构扩展。
  • rag/flow/chunker/title_chunker/hierarchy_chunker.pytoken_chunker.py:层级和父子 Chunk。
  • rag/advanced_rag/knowlege_compile/raptor.py:RAPTOR。
  • rag/graphrag/general/index.pyrag/graphrag/utils.pyrag/graphrag/search.py:GraphRAG 构建、持久化与查询。
  • api/apps/services/dataset_api_service.py:Graph、RAPTOR 等索引任务入口、重置与清理。
  • api/db/services/dialog_service.pyrag/prompts/citation_prompt.md:生成与引用。

下一篇:Mem0 详解。对比与选型见:RAG 框架对比与选型