Day 15 / 共 20 天 · 阶段4 RAG 知识库

知识库数据流:Dataset / Document / Segment 三张表怎么串起来

Day13 建索引、Day14 做检索,都在操作"文档"和"片段"。可它们在数据库里到底怎么存、怎么关联?今天把 RAG 的数据骨架拼完整。主角是 models/dataset.py 里的三张核心表和 core/rag/models/document.py 里的运行时数据结构。弄清三件事:①知识库、文档、片段这三层是什么关系(一对多的树);②每一层各存了什么关键字段、这些字段怎么支撑前两天的功能;③数据库里的持久化模型(ORM)和内存里流转的 Document 数据结构,为什么是两套、怎么互相转换。

📍 你在 20 天里的位置(阶段4:RAG 知识库 · 收口)
S2 模型运行时 S3 工作流引擎 D13 RAG 与索引 D14 检索 retrieval D15 知识库数据流 S5 工具/Agent S6 收官
💡 先用两个类比兜住今天 类比一:三层结构像图书馆的"馆 → 书 → 页"Dataset 是一个书库(决定用什么检索、什么 embedding 模型);Document 是书库里的一本书(记录来源、页数、处理进度);DocumentSegment 是书里被摘成卡片的一段段(真正拿去检索的最小单位)。一对多层层展开,删一个书库连带删下面所有书和卡片。类比二:ORM 模型 vs 运行时 Document"户口本" vs "工牌"——户口本(数据库表)记录你所有长期信息、要持久保存;工牌(内存里的 Document)是干活时临时带的、只装当前流程需要的字段(内容 + 向量 + 元数据),用完即弃。两者字段不同、用途不同,靠转换函数对接。
L01

痛点:知识库不只是"一堆向量"

🤔 痛点前两天我们说"文档切片、存向量库"。但真实产品远不止如此:用户要能看到每份文档的处理进度(解析中/索引完成/失败)、要能单独启用/禁用某一段、要知道某段被命中过多少次、要支持多租户隔离(A 公司看不到 B 公司的知识库)、要能按知识库统一改检索设置。这些都不是向量库能存的——向量库只管"向量 + 一点元数据"。那些业务状态、层级关系、配置,存在哪、怎么组织?
💡 本质:业务库(关系型)+ 向量库,双写协作Dify 把知识库拆成两套存储:关系型数据库(PostgreSQL)存"业务真相"——知识库配置、文档状态、片段内容与状态、层级关系;向量库存"检索用的向量"。索引时双写(Day13 的 _load_segments 写关系库、_load 写向量库),检索时先查向量库拿到 doc_id、再回关系库取完整片段。今天只聚焦关系库这三张表的骨架——它是整个知识库的"账本"。
L02

三层骨架:一棵一对多的树

类 / 表是什么关系
Dataset / datasets dataset.py:165一个知识库(书库)1 → 多 Document
Document / documents dataset.py:497一份原始文档(一本书)1 → 多 Segment
DocumentSegment / document_segments dataset.py:843一个可检索片段(一张卡片)可再 → 多 ChildChunk
③bChildChunk / child_chunks dataset.py:1057父子模式下的子块属于某 Segment
大白话它就是一棵树:一个知识库下有多份文档,一份文档切成多个片段,父子模式下一个片段还能再分成多个子块。注意别把 Dify 数据库里的 Document(一份完整文档的 ORM)和 core/rag 里那个 Document(内存里流转的一个片段)搞混——同名不同物,L06 专门讲这个坑。三张表都带 tenant_id(租户 id),这是多租户隔离的基石。
L03

Dataset:一个知识库的"档案卡"

Datasetmodels/dataset.py:165)存的是"整个知识库级别"的配置——决定这库里所有文档怎么被索引、怎么被检索:

# models/dataset.py:165
class Dataset(Base):
    __tablename__ = "datasets"
    id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4()))
    tenant_id: Mapped[str] = mapped_column(StringUUID)                 # ★ 属于哪个租户(隔离)
    name: Mapped[str] = mapped_column(String(255))
    permission: Mapped[DatasetPermissionEnum] = mapped_column(...)     # only_me / team...
    indexing_technique = mapped_column(...)                           # ★ high_quality / economy(Day13)
    embedding_model = mapped_column(sa.String(255), nullable=True)     # ★ 用哪个 embedding 模型
    embedding_model_provider = mapped_column(sa.String(255), nullable=True)
    collection_binding_id = mapped_column(StringUUID, nullable=True)   # ★ 绑定到向量库哪个 collection
    retrieval_model = mapped_column(AdjustedJSON, nullable=True)       # ★ 检索设置(Day14:方式/top_k/rerank)
    is_multimodal = mapped_column(sa.Boolean, default=False, ...)
tenant_id★多租户的命根子。所有查询都带 WHERE tenant_id=?,保证 A 公司永远查不到 B 公司的数据。这是 SaaS 产品的安全底线。
indexing_technique★呼应 Day13:高质量(向量)还是经济(关键词)。它定在知识库级别——同一个库里所有文档统一用一种,因为检索时要用同一套索引。
embedding_model(_provider)★这个库用哪个 embedding 模型。Day13 _loadget_model_instance(TEXT_EMBEDDING, ...) 读的就是这两个字段。一旦建库定了就不能随便改——换模型意味着所有向量维度/语义都变了,得整库重建。
retrieval_model (JSON)★Day14 的检索设置(检索方式、top_k、score_threshold、rerank 模式与权重)就存在这个 JSON 字段里。检索时读它决定怎么召回怎么重排。
collection_binding_id把这个知识库和向量库里的某个"集合(collection)"绑定——检索时才知道去向量库的哪张表捞。
L04

Document:一份原始文档的"病历"

Documentmodels/dataset.py:497)记录一份上传文档的来源和"处理进度",字段清一色带时间戳——这就是 Day13 那条状态机的存储:

# models/dataset.py:497
class Document(Base):
    __tablename__ = "documents"
    dataset_id = mapped_column(StringUUID, nullable=False)             # ★ 属于哪个知识库
    position: Mapped[int]                                              # 在库里的序号
    data_source_type: Mapped[str] = mapped_column(...)                # upload_file / notion / web...
    dataset_process_rule_id = mapped_column(StringUUID, nullable=True) # ★ 用哪套切分规则(Day13)
    batch: Mapped[str]                                                 # 同一批上传的文档
    name: Mapped[str]
    # —— 处理进度:一个工位一个时间戳 ——
    processing_started_at = ...
    parsing_completed_at = ...       # 抽取完成
    cleaning_completed_at = ...      # 清洗完成
    splitting_completed_at = ...     # 切分完成
    tokens = ...; indexing_latency = ...; completed_at = ...          # 索引完成
    indexing_status = mapped_column(..., server_default="'waiting'")  # ★ 状态机当前状态
    enabled: Mapped[bool] = ...      # 启用/禁用(禁用则不参与检索)
    doc_form: Mapped[IndexStructureType] = ...                        # ★ 普通/父子/QA(Day13 选处理器靠它)
    error = mapped_column(LongText, nullable=True)                    # 失败原因
一串 *_completed_at 时间戳★对应 Day13 的五个工位:解析/清洗/切分/索引各记一个完成时间。界面上的进度条、"耗时 12 秒"这类信息就是从它们算出来的。哪个时间戳是空的,就知道卡在哪一站。
indexing_status★状态机的当前值:waiting→parsing→…→completed(或 error)。Day13 的 _load_segments_update_document_index_status 改的就是它。
doc_form★决定这份文档用哪种处理工艺——Day13 run()IndexProcessorFactory(index_type)index_type 就是它。三选一:普通段落 / 父子分层 / QA。
enabled / error禁用的文档不参与检索(但数据还在);error 存失败详情,就是 Day13 _handle_indexing_error 写进来的红字。
L05

DocumentSegment:真正被检索的最小单位

DocumentSegmentmodels/dataset.py:843)是 Day13 切出来、Day14 检索命中的那一"片",落在关系库里的样子:

# models/dataset.py:843
class DocumentSegment(TypeBase):
    __tablename__ = "document_segments"
    tenant_id = ...; dataset_id = ...; document_id = ...    # ★ 三级归属:租户/库/文档
    position: Mapped[int]                                   # 在文档内的第几段(排序、上下文拼接靠它)
    content: Mapped[str] = mapped_column(LongText, nullable=False)   # ★ 片段正文
    word_count: Mapped[int]; tokens: Mapped[int]
    index_node_id: Mapped[str | None] = ...                # ★ = Day13 打的 doc_id,向量库里的主键
    index_node_hash: Mapped[str | None] = ...              # ★ = Day13 打的 doc_hash,内容指纹
    enabled: Mapped[bool] = ...                            # 单段启用/禁用
    answer: Mapped[str | None] = ...                       # QA 模式:这段是"答案",content 是"问题"
    keywords: Mapped[Any] = mapped_column(sa.JSON, ...)    # 经济模式的关键词
    status: Mapped[SegmentStatus] = ...                    # waiting/indexing/completed
    hit_count: Mapped[int] = mapped_column(..., default=0)  # ★ 被检索命中的次数
index_node_id★这一个字段是关系库和向量库的"接头暗号":它就是 Day13 transform 里生成的 doc_id,也是向量库里那条向量的主键。Day14 检索时先从向量库拿到一堆 index_node_id,再回这张表 WHERE index_node_id IN (...) 取出完整 content
index_node_hash★= doc_hash。内容没变则 hash 不变,增量更新时用它判断"这段要不要重新向量化"。
content vs answer★普通模式只用 content;QA 模式下 content 存问题、answer 存答案,检索匹配问题、返回答案——这解释了 Day13 为什么有个 qa_index_processor
hit_count★每被检索命中一次 +1。产品里"高频命中片段"统计、"这段没人用"的清理,靠它。数据驱动的知识库运营。
position + previous/next_segment该类还有 previous_segment/next_segment 属性(dataset.py:899 起),按 position 找相邻段——用于"命中一段时把上下相邻段也带上",补足上下文。
L06

两个 Document 的坑 + 父子分层

最容易绊倒新人的:Dify 里有两个 Document。上面 L04 是数据库 ORM。而 Day13/14 一路在传的,是 core/rag/models/document.py 里那个内存运行时Documentcore/rag/models/document.py:35):

# core/rag/models/document.py:35
class Document(BaseModel):
    """Class for storing a piece of text and associated metadata."""
    page_content: str                                    # ★ 一个片段的正文
    vector: list[float] | None = None                    # ★ 它的向量(索引时算出)
    metadata: dict[str, Any] = Field(default_factory=dict)   # ★ doc_id/doc_hash 等就塞这里
    provider: str | None = "dify"
    children: list[ChildDocument] | None = None          # ★ 父子模式:子块挂在这

# core/rag/models/document.py:10
class ChildDocument(BaseModel):
    page_content: str
    vector: list[float] | None = None
    metadata: dict[str, Any] = Field(default_factory=dict)
ORM Document vs rag Document同名不同物!ORM 的 Documentmodels/dataset.py:497)= 一份完整文档、存数据库、字段是状态/时间戳。rag 的 Documentcore/rag/models/document.py:35)= 一个片段、在内存流转、字段是 page_content+vector+metadata。Day13 切分产出的、Day14 检索返回的,全是后者。看代码时先看 import 的是哪个包,别认错。
metadata 装 doc_id/doc_hash★Day13 transformdocument_node.metadata["doc_id"]=... 塞的就是这个 dict。存进 DocumentSegment 时,metadata["doc_id"]index_node_idmetadata["doc_hash"]index_node_hash。这就是两套模型的"翻译对照"。
children / ChildDocument★父子分层检索:父块大(给模型足够上下文),子块小(检索更精准)。检索时匹配小的子块,返回时给大的父块。rag Document.children 挂子块,落库对应 L02 的 ChildChunk 表(dataset.py:1057)。
💡 设计取舍:为什么不用一个 Document 走天下?数据库 ORM 模型和内存数据结构刻意分开:ORM 绑定表结构、带一堆持久化/审计字段、依赖数据库会话;而 RAG 流水线里只想要"内容 + 向量 + 元数据"这么个轻量对象,还要能被向量库/切分器/rerank 各种组件传来传去、序列化。硬用 ORM 对象贯穿整条流水线,会把"数据库耦合"泄漏到每个算法组件里。分成两套 + 转换函数,代价是要写映射、要小心别认错名字;收益是领域逻辑(RAG 算法)和持久化(数据库)彻底解耦。这是 DDD 里"领域模型 vs 持久化模型"的经典分层。
L07

数据流全景 + 阶段收官

把 Day13-15 拼成一张图。持久化的接头点在 Day13 的 _load_segmentscore/indexing_runner.py:816)——它通过 DatasetDocumentStore.add_documents 把内存里的 rag Document 列表落成一行行 DocumentSegment

知识库数据流全景(Day13-15 合璧) Dataset 配置/模型/检索设置 Document 来源/状态机 DocumentSegment content + index_node_id 关系库(业务真相/账本) 带 tenant_id 隔离 Day13 索引:切片→rag Document rag.Document{page_content, vector, metadata:{doc_id, doc_hash}} _load_segments 落库 向量库(vector) 存 vector + index_node_id Day14 检索:向量库出 index_node_id → 回关系库按 index_node_id 取 content
图注:关系库存"账本"(三层树 + 状态),向量库存"向量";index_node_id 是两库接头暗号。索引双写,检索先向量库后关系库。
📝 真实值:从建库到命中,数据在两库里的落点 运营在 A 租户建"制度库"(Dataset:tenant_id=A、indexing_technique=high_quality、embedding_model=text-embedding-3-small、retrieval_model={hybrid, top_k=4})→ 上传《员工手册.pdf》(Document:dataset_id=制度库、doc_form=paragraph、indexing_status 从 waiting 一路走到 completed)→ 切成 120 段,每段一行 DocumentSegment(content=正文、index_node_id=某 uuid、status=completed、hit_count=0),同时 120 条向量写进向量库(每条带同一个 index_node_id)→ 用户问"年假几天"→ Day14 向量库返回命中段的 index_node_id 列表 → 回 document_segmentsWHERE index_node_id IN(...) 取出 content,命中的段 hit_count += 1 → 拼进 Prompt 给模型。三张表 + 一个向量库 + 一个 index_node_id,撑起了整个知识库。

👶 小白:为什么片段正文既存关系库 content、又要在向量库存一份?不冗余吗?

👨‍🏫 老师:是有冗余,但这是刻意的分工。向量库擅长"按向量找最近邻",但不适合当"正文的权威存储"(它主打相似度检索,字段能力弱、也不好做复杂的业务查询/事务)。关系库擅长"按条件精确查、管状态、保证一致性"。所以:向量库只存向量 + 少量元数据(含 index_node_id),负责"快速找到是哪几段";关系库存完整正文和所有业务状态,负责"给出这几段的权威内容"。检索时两者接力——用一点存储冗余,换来"各用所长"。真要省,向量库那份也可以只存 id 不存正文,但那样检索预览就得多查一次库。冗余换性能与职责清晰,是分布式数据设计的常态

🧠 今天你应该能回答

  • 知识库三层骨架是什么?(Dataset → Document → DocumentSegment,一对多的树)
  • 知识库配置(模型/检索设置)存在哪?(Dataset:embedding_model、retrieval_model JSON)
  • 文档处理进度怎么存的?(Document 的一串 *_completed_at + indexing_status
  • 关系库和向量库靠什么对上?(index_node_id = Day13 的 doc_id)
  • 两个 Document 分别是什么?(ORM 的完整文档 vs rag 的内存片段,同名不同物)
  • 为什么领域模型和持久化模型要分开?(解耦 RAG 算法和数据库依赖)

✋ 10 分钟动手

cd /Users/bitmart/work/codes/github/AI_WORK/dify/api

# 1. 三层表骨架
grep -n "class Dataset\|class Document\|class DocumentSegment\|class ChildChunk" models/dataset.py
sed -n '165,213p' models/dataset.py     # Dataset
sed -n '497,566p' models/dataset.py     # Document(含状态机时间戳)
sed -n '843,890p' models/dataset.py     # DocumentSegment(index_node_id/hit_count)

# 2. 内存里的 rag Document(别认错)
sed -n '10,49p' core/rag/models/document.py

# 3. 落库接头点
sed -n '816,847p' core/indexing_runner.py   # _load_segments
阶段4 收官 · 明日预告 Day 16:RAG 三天(索引 → 检索 → 数据流)到此完整。接下来进入 阶段5:工具与 Agent——大模型怎么"调用外部工具"、Agent 怎么自己规划多步动作。我们会从工具的定义与调用协议讲起,看 Dify 怎么把"函数"暴露给模型、又怎么接住模型的调用请求。
← Day 14 检索 retrieval Day 16 · 工具与 Agent 起步 →