跳到主要内容

摄取管线与树状节点模型:文件如何变成可检索的 artifact

30 秒导读: 这一章讲 PrivateGPT 的写入侧——你上传的一份 PDF / Word / Markdown, 经过「解析 → 转成一棵节点树 → 拍平 → 嵌入 → 落库」这条流水线,最终变成向量库里一堆 可被检索的片段。理解写入侧,是理解下一章检索的前提: 检索能「取回一个片段还能顺藤摸到它的上下文」,靠的正是这里种下的树结构

本章只讲入库;一次查询怎么把片段取回来、怎么沿树展开上下文,是 02 章的事。想先看全局请回 总览


1. 这是什么(零基础也能懂)

一句话定义: 摄取(ingestion)就是把「一份人类可读的文件」翻译成「机器可检索的向量 + 带结构的节点」,存进数据库。

解决什么问题: RAG(检索增强生成,先从你的资料里搜到相关段落、再喂给大模型作答)要能工作, 前提是资料已经被切成合适大小的片段、算好向量、存好。这一步没做好,后面检索再聪明也没用。

两个贯穿全章的核心概念——先记住这两个词,后面到处用:

概念白话在代码里
artifact一份被摄取的文档(一个 PDF、一个 Word)一个字符串 id,如 myfile
collection一组 artifact 的容器 / 命名空间(一个知识库、一个租户)一个字符串 id

一句话关系:一个 collection 里装很多 artifact;一个 artifact 就是一份文件摄取后的全部产物。

用起来什么样: 对外就是一个「上传文件」的 API。内部把它拆成两步——先给这份 artifact 建一个空索引,再往里灌数据:

# 示意,非源码:同步摄取一份文件的骨架
service.initialize_artifact_indices(collection="kb1", artifact="report") # ① 建空索引
docs = service.populate_vector_index( # ② 灌数据
collection="kb1", artifact="report", file_data=Path("/tmp/report.pdf"),
)

真实的同步入口就是这两步的组合,见 private_gpt/server/ingest/ingest_router.py:677ingest_data_sync

一句话直觉: 把一份文档想成一本书。摄取不是把书撕成一堆散页塞进抽屉,而是先建目录树 (书 → 章 → 节 → 段 → 句),再把每一片小内容连同「它在目录里的位置」一起存起来。检索时你不但能 翻到那一句,还能顺着目录知道它属于哪一节。这棵目录树,就是本章的主角。

本节不出现底层代码。记住:artifact / collection 两个容器,加一句「先建目录树再存」的直觉。


2. 顶层全景(它大概怎么转)

先看整条流水线。从左到右是数据流,一份文件依次穿过这几个部件,最后落进向量库:

上传文件


┌──────────────────────┐
│ IngestService │ 对外门面:bulk_ingest / populate_vector_index
│ (server/ingest) │ 按 (collection, artifact) 组织
└──────────┬───────────┘
│ 委托给

┌──────────────────────┐
│ VectorArtifactIndex │ 一个 artifact 的索引句柄
│ (artifact_index) │ initialize() 建空索引 → populate() 灌数据
└──────────┬───────────┘
│ populate() 内部三步

┌───────────────┬────────────────────┬─────────────────┐
│ ① 解析+校验 │ ② 解析成节点 │ ③ 嵌入 + 落库 │
│ ParseComponent│ IngestComponent │ IngestComponent │
│ load_and_ │ parse_file_into_ │ load_index │
│ validate_file │ nodes │ → ExtendIndex │
└───────┬───────┴─────────┬──────────┴────────┬────────┘
│ │ │
▼ ▼ ▼
文件是什么、 Reader + 一串 EmbeddingComponent 算向量
能不能读 Transform 把它 VectorStoreComponent 写 Qdrant
变成【节点树】 NodeStoreComponent 存节点/索引元数据

怎么读这张图: 上半是「谁调谁」的委托链,下半是 populate() 内部真正干活的三步。 本章后面就按 ①②③ 逐步拆。

部件一句话职责:

部件干什么在哪个文件(真实符号)
IngestService对外门面,按 artifact 循环摄取server/ingest/ingest_service.py:31 IngestService
VectorArtifactIndex单个 artifact 的索引:建空 / 灌数据artifact_index/vector_artifact_index.py:32 VectorArtifactIndex
ParseComponent校验文件、调 Reader 读成节点components/ingest/parse_component.py:48 ParseComponent
IngestComponent去重、跑 Transform 管线、嵌入落库components/ingest/ingest_component.py:46 IngestComponent
TreeNode 及子类核心数据结构:带父子/前后关系的节点树components/readers/nodes/tree_node.py:49 TreeNode
EmbeddingComponent给节点内容算向量components/embedding/embedding_component.py:15 EmbeddingComponent
VectorStoreComponent向量库门面(默认 Qdrant)components/vector_store/vector_store_component.py:28 VectorStoreComponent

主线走一遍(高层): bulk_ingest 拿到 (路径, artifact, 元数据) 列表,逐个 initialize_artifact_indices + populate_vector_index (server/ingest/ingest_service.py:151 bulk_ingest)。populate_vector_index 里先确认索引已初始化,再把活儿转给 VectorArtifactIndex.populate (server/ingest/ingest_service.py:65)。populate 就是上图下半的 ①②③ (artifact_index/vector_artifact_index.py:115 populate)。

目标:看懂「大盘」。下面逐个机制深入。


3. 核心原理

3.1 门面与「先建空索引、再灌数据」

它要解决的小问题: 上传一份文件,系统要往哪存?怎么保证「这份 artifact 的索引」存在?

思路: PrivateGPT 把「一个 artifact 的向量索引」抽象成 VectorArtifactIndex。它有两个阶段: initialize() 先在存储里创建一个空索引并盖上状态戳;populate() 再往这个已存在的索引里灌节点。 分两步是为了让状态机清晰——初始化过、灌过,各是一种明确状态。

索引状态只有三种(artifact_index/base_artifact_index.py:39 ArtifactIndexStatus):

状态含义
NOT_INITIALIZED还没建索引
INITIALIZED建了空索引,尚无数据
POPULATED已灌入节点

index_id 命名规则(记住这个): 每个 artifact 的索引 id 就是 <artifact>-vector,由一个静态方法拼出来:

# 真实源码:artifact_index/vector_artifact_index.py:72-74
@staticmethod
def index_id_from_artifact(artifact: str) -> str:
return artifact + "-vector"

它还有个反向解析 artifact_from_index_id (artifact_index/vector_artifact_index.py:76-80):凡是不以 -vector 结尾的 index_id 一律返回 None。这条命名约定是「artifact ↔ 索引」互查的锚点。

真实实现: initialize() 先试着加载已存在的索引,ValueError 说明没有,才用 VectorStoreIndex.from_documents(documents=[], ...) 建一个空的、盖上 ArtifactIndexStatus.INITIALIZEDpersist 到磁盘 (artifact_index/vector_artifact_index.py:82-112 initialize)。

populate() 则先 load_index_from_storage 把这个已存在的索引连同嵌入模型一起加载回来 (artifact_index/vector_artifact_index.py:129-138),再跑 ①②③:

# 真实源码(节选):artifact_index/vector_artifact_index.py:143-169
file_info, _, warnings = self.parse_component.load_and_validate_file(...) # ①
nodes = self.ingest_component.parse_file_into_nodes(...) # ②
self.ingest_component.load_index(..., index=index, nodes=nodes, ...) # ③

关键细节: populate 最后只回 nodes[:1] 的一个 IngestedDoc (vector_artifact_index.py:173)——对外只报「这份文件的代表节点」,而不是几百个碎片。 后面 3.3 会讲这个代表节点为什么是根节点


3.2 解析与校验:文件到底是什么、能不能读(第 ① 步)

它要解决的小问题: 用户传来的可能是加密 PDF、空文件、超大文件、不支持的格式。灌数据前得先 看清楚、拦下来

思路: 先算出文件的「身份信息」(名字、大小、扩展名、哈希),再跑校验规则;有错就抛 InvalidFileError 直接中止,有警告就记下来继续。

真实实现: 入口是 load_and_validate_file,返回三元组 (file_info, errors, warnings):

# 真实源码(节选):components/ingest/parse_component.py:79-83
file_info = self._get_file_info(file_data, file_metadata, progress)
errors, warnings = self._validate_file(file_info, progress)
return file_info, errors, warnings

_validate_file 里一旦有 errorsraise InvalidFileError (parse_component.py:180-183),摄取到此为止。

从文件到节点由 Reader 负责: 真正「读内容」的是 file_to_nodes (parse_component.py:85)——它按扩展名挑一个 Reader,lazy_load_data 异步读出节点。这里有一层 容错值得记:PDF 抽取失败时会回退到 vision(视觉大模型)读图,vision 也没有可用文本才最终判失败 (parse_component.py:104-160except 分支 + _extract_with_vision_fallback)。

这一步的产物是什么? 关键点:Reader 出来的不是一段纯文本,而是已经成树的节点。 以文本类文件为例,TextReader.lazy_load_data 读出 Document 后,会立刻跑一整串 transform 把它变成树(components/readers/text/text_reader.py:152_tranformations)。 下面 3.4 专门拆这条 transform 管线。


3.3 核心数据结构:为什么是「树」而不是扁平 chunk

这是全章最该记住的一节。 别的 RAG 系统常把文档切成一串互不相干的扁平 chunk;PrivateGPT 把它组织成一棵树。为什么?

它要解决的小问题: 一个被检索命中的句子,单独看往往缺上下文——它属于哪一节?前一句是什么? 下一句呢?扁平 chunk 丢掉了这些坐标;树把它们结构性地保留下来。

树长什么样(以一份 Markdown 为例):

DocumentRootNode 整份文档的根(root_id 指向它)
├── SectionNode "# 引言" 一个标题划出的小节
│ ├── TextNode 段落
│ │ ├── ChunkNode 句/块 ← 叶子,真正被嵌入检索的最小单位
│ │ └── ChunkNode 句/块
│ └── TableNode / ImageNode
└── SectionNode "# 方法"
└── ...

每个节点身上都带着「坐标」,这就是树的价值所在 (components/readers/nodes/tree_node.py:52-68):

字段含义
parent / children父节点、子节点列表
parent_id父节点 id
root_id整棵树的根 id——所有节点都能一步指回自己属于哪份文档
depth / height在树中的深度 / 到叶子的高度
idx / abs_idx在兄弟中的序号 / 在整棵树(DFS 序)中的绝对序号

root_id 是关键锚点。 当子节点被挂到父节点下时,root_id 自动向下传递:

# 真实源码(节选):components/readers/nodes/tree_node.py:121-123
child.parent = self
child.parent_id = self.id_
child.root_id = self.root_id or self.id_

于是任意一个碎片都知道自己的根。前面 3.1 说 populate 只回「代表节点」—— get_ingested_files 里正是用 sample_node.root_id 反查出根节点当作这份 artifact 的门面 (server/ingest/ingest_service.py:191-199)。

沿树导航的两个工具方法:

  • find_self_or_child_by_id:从当前节点递归往下找某个 id 的节点 (tree_node.py:437 find_self_or_child_by_id)——02 章的树扩展靠它把命中片段还原回上下文。
  • flatten:DFS 把整棵树拍成一个扁平序列(tree_node.py:219 flatten)——下面落库前要用。

内容有多种「视角」: 同一个节点,喂给嵌入模型、喂给 LLM、展示给用户,想要的文本不一样。 TreeMetadataMode 就是这组视角开关(tree_node.py:25 TreeMetadataMode),取值 ALL / EMBED / LLM / NONE / RAG / USERget_content(mode) 按视角决定「带不带元数据、带不带子节点内容」 (tree_node.py:391 get_content)。

兄弟节点类型概览(都继承自 TreeNode,各自实现 get_content_internal,见 components/readers/nodes/__init__.py):

节点类型代表什么特别之处
DocumentRootNode整份文档的根自身无内容,token_count 是所有子节点之和(document_node.py:13-15)
SectionNode标题划出的小节没有带内容的子节点就被剪掉(section_node.py:23-46 prune)
TextNode一般文本块树节点的基类,承载 text 字段(text_node.py:13)
ChunkNode切好的句/块叶子,检索的最小单位,继承 TextNode(chunk_node.py)
TableNode / TableRowNode表格 / 表格行内部用 pandas DataFrame(table_node.py)
ImageNode图片暂不参与嵌入:EMBED 模式返回空串(image_node.py:36-38)

一句话收束: 树不是为了好看,是为了让每个碎片都携带「我在文档里的位置」——这正是 citation(03 章)和上下文扩展(02 章)的地基。


3.4 转换流水线:markdown → 树 → 带 token 计数与父子/前后关系的节点(第 ② 步核心)

它要解决的小问题: Reader 拿到的往往是一大段 Markdown 文本。怎么把它变成上面那棵树, 还要给每个节点算好 token 数、连好前后关系?

思路: 用一串可组合的 transform,每个只干一件事,顺序串起来。文本类文件的完整顺序写在 TextReader._tranformations(components/readers/text/text_reader.py:152),照抄其骨架:

MarkdownNormalizerTransform 规整缩进/格式


MarkdownTreeNodeParser markdown → 一棵树(SectionNode/TextNode/TableNode…)


SentenceTreeNodeParser 把叶子 TextNode 按句子切成 ChunkNode 挂回树下


CombineTreeTransform 多页/多根合成一棵


FlattenTreeNodesTransform 整棵树 DFS 拍平成节点列表


CreateLlamaIndexRelationshipsTransform 给节点补 SOURCE/PREVIOUS/NEXT 关系


IncludeTokenCountIntoNodesTransform 给每个节点算 token 数写进 metadata


RefreshTreeNodeTransform 最后校正 depth/idx/root_id 等引用

挑其中 4 个讲清楚(注意:前后关系与 token 计数都发生在「拍平」之后,下文按执行顺序讲):

MarkdownTreeNodeParser —— 把 markdown 建成树。 它用一个维护中的 section_stack, 遇到标题就按 header_level 决定挂到哪个父节点下(_find_parent_for_level, markdown_to_tree_transform.py:63-69);遇到表格就解析成 TableNode + TableRowNode (markdown_to_tree_transform.py:100-117)。这一步之后,「一段 markdown」变成「一棵有层级的树」。

SentenceTreeNodeParser —— 把叶子切成句块。 它 DFS 找到没有子节点的叶子 TextNode, 按句子切分,把每个切片包成 ChunkNode 挂回该节点下当子节点:

# 真实源码(节选):components/ingest/transformations/sentence_tree_node_parser.py:221-234
for i, text_chunk in enumerate(text_splits):
cloned_node = ChunkNode(**self.copy.deepcopy(node.dict()))
cloned_node.text = text_chunk
cloned_node.children = []
...
node.add_child(cloned_node) # ChunkNode 成为该 TextNode 的孩子

切块大小由嵌入模型的上下文窗口决定:回退切分器用 chunk_size = 0.9 × context_windowchunk_overlap = 0(sentence_tree_node_parser.py:154-162)。重叠设 0 是刻意的—— 注释说明重叠会把同一段内容存进多个 chunk、造成冗余(sentence_tree_node_parser.py:158-159)。 注意切块不打破树:ChunkNode 是叶子 TextNode 的孩子,层级坐标依旧在。这一步刻意设了 include_prev_next_rel=False(text_reader.py:170)——因为此刻还是嵌套树、不是平列表, 前后关系被推迟到拍平之后再连(见下面 ④)。

FlattenTreeNodesTransform —— 为落库拍平(拍平在此发生)。 向量库要的是一串节点,不是嵌套树。 它对每个 TreeNodeflatten() 展开成扁平列表(flatten_tree_nodes_transform.py:39-47)。 关键:拍平发生在「连关系、算 token」之前——建树时每个节点身上就已写好了 parent_id / root_id 这些结构坐标,拍平只是把嵌套树「换成一种 DFS 排列」,并不丢掉这些 id; 正因为拍平成了「一串平列表」,后面 ④ 才好在相邻节点间连前后关系。检索侧靠这些 id 依旧能重建树。

CreateLlamaIndexRelationshipsTransform —— 连前后关系(拍平之后)。 遍历(已拍平的)节点序列, 给每个节点补上 LlamaIndex 的三种关系:SOURCE 指向根、相邻节点互指 PREVIOUS / NEXT:

# 真实源码(节选):transformations/create_llama_index_relationships_transform.py:35-49
node.relationships[NodeRelationship.SOURCE] = root.as_related_node_info()
if prev_node and prev_node.source_node == node.source_node:
node.relationships[NodeRelationship.PREVIOUS] = prev_node.as_related_node_info()
prev_node.relationships[NodeRelationship.NEXT] = node.as_related_node_info()

有了 PREVIOUS/NEXT,检索命中一个块后就能取「它前后邻居」拼出连贯上下文。

IncludeTokenCountIntoNodesTransform —— 记 token 数(连完关系之后)。 对每个节点,用 tokenizer 数一遍 RAG 视角下的内容长度,写进 metadata,并把这个 key 排除出「喂给 LLM/嵌入」的元数据:

# 真实源码(节选):transformations/include_token_count_to_nodes_transform.py:35-37
node.metadata[MetadataNode.TOKEN_COUNT.value] = len(self.tokenizer(content))
node.excluded_llm_metadata_keys.append(MetadataNode.TOKEN_COUNT.value)
node.excluded_embed_metadata_keys.append(MetadataNode.TOKEN_COUNT.value)

token 数是上下文装配时做预算的原料;TreeNode.token_count 就是读这个字段(tree_node.py:82-84)。

关键细节: transform 的顺序不是随意的,但也不是「先连关系、算 token,最后才拍平」—— 恰恰相反:先把树建全、切好块,再拍平(FlattenTreeNodesTransform),然后才在平列表上 连前后关系、算 token,最后由一个 RefreshTreeNodeTransform 复核 depth/idx/root_id 等引用 (text_reader.py:175-182)。之所以能这样安排,是因为拍平只是「换一种排列」、不丢每个节点的 parent_id/root_id,而 PREVIOUS/NEXT 这类相邻关系本来就更适合在一串平列表上连。顺序错了,坐标就散了。


3.5 嵌入与落库:算向量、写数据库(第 ③ 步)

它要解决的小问题: 一堆拍平的节点,怎么算向量、怎么去重、怎么写进 Qdrant?

先去重(省钱省时的巧思): 在真正 transform 之前,parse_file_into_nodes 会先按文件哈希 查一遍:同一 artifact 里已有相同哈希 → 直接跳过;开关打开时,连别的 artifact 里的相同哈希节点 都能复用、只改 artifact/collection 元数据(components/ingest/ingest_component.py:67-158 parse_file_into_nodes / retrieve_ingested_nodes)。同一份文件不重复嵌入。

打元数据: transform 完成后,transform_file_into_nodes 给每个节点写上 artifact id、collection、用的 LLM/嵌入模型、文件哈希 (ingest_component.py:209-225)。这些是后面按 artifact 过滤检索、做 citation 的依据。

嵌入 + 批量写入: 落库入口 load_index 把普通索引包成 ExtendIndex,好处是把整份文件的 所有节点当一次原子操作插入、而非逐个插(ingest_component.py:234-286 load_index; base_artifact_index.py:180-186 ExtendIndex)。它先删掉这个 artifact 的旧节点避免重复 (ingest_component.py:263-267),再分批算嵌入并写入:

# 真实源码(节选):artifact_index/base_artifact_index.py:258-275
if isinstance(self._source, VectorStoreIndex):
nodes_batch = self._get_node_with_embedding(nodes_batch, show_progress) # 算向量
self._source.insert_nodes(nodes_batch, ...) # 写库

嵌入模型由 EmbeddingComponent.get_embed 提供(components/embedding/embedding_component.py:101 get_embed),在 populate 加载索引时就注入了(vector_artifact_index.py:135)。

向量库后端(默认 Qdrant): VectorStoreComponent.vector_store(collection) 把请求转给工厂 (components/vector_store/vector_store_component.py:77);默认工厂是 QdrantVectorStoreFactory(qdrant_factory.py:39 vector_store)。这里有个多租户细节: multitenancy == "logical" 时,所有 collection 共用一个 Qdrant 物理 collection、靠 payload 里的 group_id(即 collection 字段)区分;否则每个 collection 一个物理 collection (qdrant_factory.py:52-84)。它还为 ARTIFACT_ID / PROJECT_ID 建 keyword 索引, 让「按 artifact 过滤」跑得快。

节点/索引元数据另存: 向量进 Qdrant,但节点的结构信息(以及索引状态)由 NodeStoreComponent 管的 index store / doc store 负责(ingest_component.py:285persist;vector_artifact_index.py:63-65StorageContext 同时挂 vector_store 与 index_store)。写完后索引状态盖成 POPULATED(ingest_component.py:283)。

至此,一份文件从上传到「可检索」全部完成。 检索侧怎么用这些成果,交给 02 章


4. 巧妙之处(可带走的技术)

  • 树而非扁平 chunk,把上下文「结构化」保存。 每个碎片带 root_id / parent_id / idx, 检索命中后能一步定位所属文档、顺藤取回邻居和父节。地基在 tree_node.py:52-68,导航在 tree_node.py:437 find_self_or_child_by_id

  • 切块不打破树。 句块 ChunkNode 是叶子的孩子而非独立列表项 (sentence_tree_node_parser.py:222-234),所以「最小检索单位」和「层级坐标」两者兼得。

  • transform 管线顺序即语义。 先建树 → 切块 → 拍平 → 连前后关系 → 算 token → 最后刷新引用 (text_reader.py:152);拍平只换排列不丢 parent_id/root_id 坐标,所以关系和 token 能在 平列表上补齐、检索侧也能重建树。

  • 两级哈希去重。 同 artifact 跳过、跨 artifact 复用节点只改元数据 (ingest_component.py:91-158 retrieve_ingested_nodes),同一文件不重复嵌入。

  • 一份文件原子入库。 ExtendIndex 把整份文件的节点集中一次插入、并先删旧节点防重复 (base_artifact_index.py:180 ExtendIndex;ingest_component.py:263-276)。

  • chunk_overlap = 0 是有意的。 用重叠换召回会把同一内容存进多个块、造成冗余,PrivateGPT 选择不重叠(注释见 sentence_tree_node_parser.py:158-159)。


5. 边界与局限(诚实)

  • 图片暂不进嵌入。 ImageNodeEMBED 模式直接返回空串 (image_node.py:36-38),代码注释明说「在有办法处理前先禁用图片嵌入」。图内容靠 vision reader 转成文字描述才可能被检索。

  • 一个 collection 只用一个嵌入模型。 配置了多个也只取第一个,并打 warning (vector_store_component.py:54-65)。

  • 节点数有上限。 单文件解析出的节点超过 max_nodes 直接判失败 (ingest_component.py:198-207)。

  • 切块粒度绑死嵌入模型的上下文窗口。 换嵌入模型会改变切块大小 (sentence_tree_node_parser.py:153-162),已入库的旧数据不会自动重切。

  • 默认后端是 Qdrant。 其它后端经工厂注册,但 Qdrant 是内置默认 (vector_store_component.py:44)。


6. 代码地图(导航索引)

主题文件路径符号名
对外门面 / 批量摄取private_gpt/server/ingest/ingest_service.pyIngestServicebulk_ingestpopulate_vector_index
同步摄取入口private_gpt/server/ingest/ingest_router.pyingest_data_sync
单 artifact 索引private_gpt/artifact_index/vector_artifact_index.pyVectorArtifactIndexinitializepopulateindex_id_from_artifact
索引状态机 / 原子插入private_gpt/artifact_index/base_artifact_index.pyArtifactIndexStatusExtendIndex
解析与校验private_gpt/components/ingest/parse_component.pyParseComponentload_and_validate_filefile_to_nodes
去重 / transform / 落库private_gpt/components/ingest/ingest_component.pyparse_file_into_nodestransform_file_into_nodesretrieve_ingested_nodesload_index
核心数据结构private_gpt/components/readers/nodes/tree_node.pyTreeNoderoot_idfind_self_or_child_by_idflattenTreeMetadataMode
兄弟节点类型private_gpt/components/readers/nodes/DocumentRootNodeSectionNodeTextNodeChunkNodeTableNodeImageNode
transform 顺序private_gpt/components/readers/text/text_reader.pyTextReader._tranformations
markdown → 树private_gpt/components/ingest/transformations/markdown_to_tree_transform.pyMarkdownTreeNodeParser
句子切块private_gpt/components/ingest/transformations/sentence_tree_node_parser.pySentenceTreeNodeParser
拍平树private_gpt/components/ingest/transformations/flatten_tree_nodes_transform.pyFlattenTreeNodesTransform
前后/父子关系private_gpt/components/ingest/transformations/create_llama_index_relationships_transform.pyCreateLlamaIndexRelationshipsTransform
token 计数private_gpt/components/ingest/transformations/include_token_count_to_nodes_transform.pyIncludeTokenCountIntoNodesTransform
嵌入模型private_gpt/components/embedding/embedding_component.pyEmbeddingComponentget_embed
向量库门面private_gpt/components/vector_store/vector_store_component.pyVectorStoreComponentvector_store
Qdrant 后端private_gpt/components/vector_store/qdrant_factory.pyQdrantVectorStoreFactory

下一章: 02 · 语义检索与树扩展——一次查询如何把这里种下的 节点树反向利用,取回带上下文的片段。