知识库数据流:Dataset / Document / Segment 三张表怎么串起来
Day13 建索引、Day14 做检索,都在操作"文档"和"片段"。可它们在数据库里到底怎么存、怎么关联?今天把 RAG 的数据骨架拼完整。主角是 models/dataset.py 里的三张核心表和 core/rag/models/document.py 里的运行时数据结构。弄清三件事:①知识库、文档、片段这三层是什么关系(一对多的树);②每一层各存了什么关键字段、这些字段怎么支撑前两天的功能;③数据库里的持久化模型(ORM)和内存里流转的 Document 数据结构,为什么是两套、怎么互相转换。
Dataset 是一个书库(决定用什么检索、什么 embedding 模型);Document 是书库里的一本书(记录来源、页数、处理进度);DocumentSegment 是书里被摘成卡片的一段段(真正拿去检索的最小单位)。一对多层层展开,删一个书库连带删下面所有书和卡片。类比二:ORM 模型 vs 运行时 Document 像"户口本" vs "工牌"——户口本(数据库表)记录你所有长期信息、要持久保存;工牌(内存里的 Document)是干活时临时带的、只装当前流程需要的字段(内容 + 向量 + 元数据),用完即弃。两者字段不同、用途不同,靠转换函数对接。痛点:知识库不只是"一堆向量"
_load_segments 写关系库、_load 写向量库),检索时先查向量库拿到 doc_id、再回关系库取完整片段。今天只聚焦关系库这三张表的骨架——它是整个知识库的"账本"。三层骨架:一棵一对多的树
| 层 | 类 / 表 | 是什么 | 关系 |
|---|---|---|---|
| ① | Dataset / datasets dataset.py:165 | 一个知识库(书库) | 1 → 多 Document |
| ② | Document / documents dataset.py:497 | 一份原始文档(一本书) | 1 → 多 Segment |
| ③ | DocumentSegment / document_segments dataset.py:843 | 一个可检索片段(一张卡片) | 可再 → 多 ChildChunk |
| ③b | ChildChunk / child_chunks dataset.py:1057 | 父子模式下的子块 | 属于某 Segment |
Document(一份完整文档的 ORM)和 core/rag 里那个 Document(内存里流转的一个片段)搞混——同名不同物,L06 专门讲这个坑。三张表都带 tenant_id(租户 id),这是多租户隔离的基石。Dataset:一个知识库的"档案卡"
Dataset(models/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 _load 里 get_model_instance(TEXT_EMBEDDING, ...) 读的就是这两个字段。一旦建库定了就不能随便改——换模型意味着所有向量维度/语义都变了,得整库重建。retrieval_model (JSON)★Day14 的检索设置(检索方式、top_k、score_threshold、rerank 模式与权重)就存在这个 JSON 字段里。检索时读它决定怎么召回怎么重排。collection_binding_id把这个知识库和向量库里的某个"集合(collection)"绑定——检索时才知道去向量库的哪张表捞。Document:一份原始文档的"病历"
Document(models/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 写进来的红字。DocumentSegment:真正被检索的最小单位
DocumentSegment(models/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 找相邻段——用于"命中一段时把上下相邻段也带上",补足上下文。两个 Document 的坑 + 父子分层
最容易绊倒新人的:Dify 里有两个 Document。上面 L04 是数据库 ORM。而 Day13/14 一路在传的,是 core/rag/models/document.py 里那个内存运行时的 Document(core/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 的 Document(models/dataset.py:497)= 一份完整文档、存数据库、字段是状态/时间戳。rag 的 Document(core/rag/models/document.py:35)= 一个片段、在内存流转、字段是 page_content+vector+metadata。Day13 切分产出的、Day14 检索返回的,全是后者。看代码时先看 import 的是哪个包,别认错。metadata 装 doc_id/doc_hash★Day13 transform 里 document_node.metadata["doc_id"]=... 塞的就是这个 dict。存进 DocumentSegment 时,metadata["doc_id"] → index_node_id,metadata["doc_hash"] → index_node_hash。这就是两套模型的"翻译对照"。children / ChildDocument★父子分层检索:父块大(给模型足够上下文),子块小(检索更精准)。检索时匹配小的子块,返回时给大的父块。rag Document.children 挂子块,落库对应 L02 的 ChildChunk 表(dataset.py:1057)。数据流全景 + 阶段收官
把 Day13-15 拼成一张图。持久化的接头点在 Day13 的 _load_segments(core/indexing_runner.py:816)——它通过 DatasetDocumentStore.add_documents 把内存里的 rag Document 列表落成一行行 DocumentSegment:
index_node_id 是两库接头暗号。索引双写,检索先向量库后关系库。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_segments 表 WHERE 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