Skip to content

indexing包设计 ​

在前面的内容中,我们已经完成了 RAG 知识框架的梳理,也明确了整个 RAG SDK 的基本拆分思路。接下来,就要开始进入更具体的包设计了。

如果从整个 RAG 流程来看,一个基础的 RAG 系统通常可以分为两个大的部分:

  1. 数据索引
  2. 数据检索与生成

其中,数据检索这一块儿,直接面向用户请求;而数据索引这一块儿,则更像是整个系统的前置准备工作。它不直接参与回答问题,但却决定了后续到底“能检索到什么”。

很多时候,一个 RAG 系统效果不好,问题并不一定出在检索阶段,也可能出在索引阶段。比如:

  1. 文档没有清洗干净
  2. chunk 切分方式不合理
  3. metadata 不完整
  4. embedding 策略不稳定
  5. 向量写入流程缺乏统一约束

这些问题本质上都属于索引设计问题。

也正因为如此,在 SDK 层面,数据索引不能只被看作几个零散的工具函数,而应该被沉淀成一个独立的能力包。这个包,就是 indexing。

这一篇文章,我们就来专门聊一聊 indexing 包应该如何设计,以及它在整个 RAG SDK 中承担什么角色。

1. 为什么要单独设计 indexing 包 ​

先来看一个最基本的问题:为什么索引这一块儿值得单独拆成一个包?

表面上看,数据索引无非就是把文档存进向量数据库,似乎没有必要专门设计一个独立模块。但如果你把这个过程真正展开,就会发现事情并没有这么简单。

一个完整的数据索引流程,往往至少包含下面这些步骤:

  1. 加载原始文档
  2. 对文档做必要的预处理
  3. 过滤无效或不需要进入索引的数据
  4. 将文档切分为 chunks
  5. 为 chunk 补充 metadata
  6. 将 chunk 转换为向量
  7. 把向量写入存储系统

你会发现,这已经不是一个单点能力,而是一条完整的数据处理流水线。而只要是一条流水线,就会天然带来下面这些工程问题:

  1. 每一步的输入输出是否统一
  2. 各个步骤之间如何解耦
  3. 某一步失败之后,流程应该如何处理
  4. 不同项目下,是否可以替换其中某些实现
  5. 后续接入真实服务时,是否还能保持原有结构稳定

如果没有一个独立的 indexing 包来承载这些问题,那么这些逻辑通常就会分散在业务代码中。短期看似乎也能工作,但长期会出现几个很典型的问题。

1. 流程逻辑四处分散:有些逻辑写在 loader 里,有些逻辑写在业务脚本里,有些逻辑直接写在某个 demo 文件中。这样一来,索引到底是怎么完成的,就很难一眼看清楚。

2. 具体实现和流程编排混在一起:例如一边写目录扫描,一边写 chunk 切分,一边写 embedding 调用,一边写向量入库。这样会导致一旦底层能力变了,整个流程都得跟着改。

3. 缺乏统一契约:今天文档是这个结构,明天 chunk 又换成另一个结构,后续不同包之间就很容易出现类型和语义不一致的问题。

4. 不利于测试和扩展:如果索引逻辑全部耦合在一起,就很难分别替换不同的 loader、chunker、embedder、store,也很难对主流程做稳定验证。

所以,从工程视角来看,indexing 包之所以需要被单独设计,核心原因就在于:数据索引并不是一个简单动作,而是一套需要长期稳定演进的流程能力。

这里还可以再往前走一步。

在 RAG 系统中,检索阶段之所以能够工作,前提就是索引阶段已经提前把知识整理好了。也就是说,索引阶段虽然发生在“离线”,但它对整个系统的影响是“在线持续生效”的。

例如:

  • chunk 切得好不好,会直接影响召回粒度
  • metadata 设计得好不好,会直接影响检索过滤能力
  • embedding 过程是否稳定,会直接影响向量质量
  • 写入存储时有没有统一规范,会直接影响后续 runtime 如何消费这些数据

从这个角度看,indexing 实际上承担的是“为后续检索阶段准备高质量知识数据”的职责。因此,更合理的做法不是把它看成一个附属工具,而是把它看成 RAG SDK 中一个独立的能力层。这个能力层有几个明显特点:

  1. 它本质上是流程驱动的
  2. 它依赖多个可替换组件协同工作
  3. 它需要稳定的输入输出契约
  4. 它需要明确的边界,避免和具体 IO、第三方实现耦合
  5. 它需要具备最小可验证能力,方便后续迭代

也正因为这些特点,indexing 包的设计重点,从一开始就不应该是“先接入多少外部能力”,而应该是“先把索引流程的骨架搭好”。只有骨架先稳定下来,后续无论接入真实 loader、真实 embedding 服务,还是接入真实向量数据库,都不会轻易把整个结构冲散。

最后,我们可以用一句话来总结这一小节:

indexing 包之所以需要单独设计,是因为数据索引阶段本质上是一条完整的离线数据处理流水线,它需要被沉淀为 SDK 中的独立能力,而不是散落在业务代码中的若干脚本和工具函数。

2. indexing 包的职责与整体结构 ​

在明确了为什么要单独设计 indexing 包之后,接下来要回答另一个问题:

这个包在整个 RAG SDK 中,究竟负责什么?

如果一个包的职责边界没有提前划清楚,后面它就很容易不断“发胖”。

NOTE

例如凡是和索引稍微相关一点的东西,都塞进indexing包。时间一长,这个包就会同时承载:

  1. 流程编排
  2. 外部接入
  3. 具体实现
  4. 工具函数
  5. ....

最后变得难以维护。

所以,在进入具体实现之前,先把 indexing 的职责说清楚,是非常必要的。从整个 RAG SDK 的视角来看,当前几个核心层次大致如下:

  1. core:负责共享模型、schema、基础契约和通用错误
  2. indexing:负责离线索引构建流程
  3. adapters:负责接入外部能力
  4. runtime:负责在线检索、上下文组织和答案生成

在这个结构中,indexing 处于承上启下的位置。

它一方面依赖 core 提供的共享模型,例如 Document、Chunk、Vector;另一方面,又为后续 runtime 提供索引阶段产出的基础数据。至于具体的数据来源、embedding 服务、向量数据库,则交给 adapters 或业务方实现。

所以,indexing 最准确的定位是:它是一个离线索引构建的流程编排层。

这意味着,indexing 的核心任务不是实现所有具体能力,而是先把索引流程的骨架搭好,并定义清楚流程里需要哪些角色。

从职责上讲,indexing 主要负责:

  1. 定义索引流程中的核心组件接口
  2. 组织索引主流程的执行顺序
  3. 统一流程中的输入输出契约
  4. 提供少量默认实现用于 demo 和测试
  5. 提供统一的错误语义与结果统计

相对地,它不负责下面这些事情:

  1. 不负责本地文件系统扫描
  2. 不负责具体文件格式读取
  3. 不负责真实 embedding 服务接入
  4. 不负责直接耦合某一家向量数据库 SDK
  5. 不负责在线检索和答案生成

原因很简单:这些事情虽然和索引有关,但它们本质上属于“外部能力接入”,而不是“流程编排”。

所以,更合理的分工应该是:

  • indexing 只定义“流程需要什么能力”
  • adapters 或业务代码负责提供“这些能力的具体实现”

也就是说,indexing 关心的是接口和流程,而不是具体接哪一个外部世界。

明确 indexing 包的职责后,接下来来看一下整个包的设计。整个包大致就可以分成这么几个部分:

.
├── src/
│   ├── chunkers/
│   │   ├── index.ts
│   │   └── simple-chunker.ts
│   ├── defaults/
│   │   └── index.ts
│   ├── embedders/
│   │   ├── embedder.ts
│   │   ├── index.ts
│   │   └── mock-embedder.ts
│   ├── errors/
│   ├── loaders/
│   │   ├── index.ts
│   │   └── loader.ts
│   ├── pipeline/
│   │   ├── index.ts
│   │   └── run-indexing.ts
│   ├── stores/
│   ├── transformers/
│   │   ├── document-transformer.ts
│   │   └── index.ts
│   ├── types/
│   └── index.ts
├── package.json
├── README.md
└── tsconfig.json

2-1. types ​

这一层负责包内的类型契约。

例如:

  • IndexingOptions
  • IndexingResult
  • IndexingContext

此外,它还会对 core 中的共享模型做类型转发,例如 Document、Vector 等。

这一层的作用很明确,就是让调用方、组件接口、主流程三者围绕同一套类型协作,避免各自定义、各自理解。

2-2. loaders ​

这一层只负责一个问题:文档从哪里进来。

但要注意,这里强调的是“抽象”,不是“具体实现”。也就是说,loaders 关注的是 Loader 接口,而不是“本地 markdown loader”、“数据库 loader”这类具体能力。

因为对索引主流程而言,它不需要知道文档是怎么来的,它只需要知道最终能拿到一批统一结构的 Document。

2-3. transformers ​

这一层负责文档在进入正式索引前的预处理。

例如清洗、裁剪、格式标准化、内容增强,都可以被视为文档转换的一部分。

把它单独拆出来有一个好处,就是主流程不用背负过多细节。它只需要知道:在 chunking 之前,文档可以先经过若干转换步骤。

2-4. chunkers ​

这一层负责把文档切成 chunks。

这是索引阶段最核心的能力之一,所以必须被单独建模。切块策略未来可能会不断变化,例如:

  • 固定长度切分
  • 递归切分
  • 语义切分
  • 标题结构切分
  • 等....

但无论内部策略怎么变,对主流程来说,它都只是“给我文档,返回 chunks”。

2-5. embedders ​

这一层负责把 chunks 转成向量。

这里的重点同样不是某家 embedding 服务本身,而是先定义出统一的 Embedder 能力边界。这样后续无论你接 OpenAI、本地模型还是 mock 实现,主流程都不需要改动。

2-6. stores ​

这一层负责向量写入。

和 embedders 的道理一样,stores 关心的是统一写入接口,而不是具体要写到哪一家向量数据库。这样做的目的,就是把“存储实现差异”隔离在流程之外。

2-7. defaults ​

这一层负责默认值和默认策略。例如:

  • 默认 chunk size
  • 默认 overlap
  • 默认 batch size
  • 默认 metadata 构建逻辑
  • ....

都适合放在这里统一收口。否则这些默认行为散落在各个实现中,后面会很难维护。

2-8. errors ​

这一层负责统一错误语义。

索引流程中的错误,不能只是简单抛一个异常字符串,因为我们往往还需要知道错误发生在哪个阶段,例如是 load 出错、chunk 出错,还是 embed 出错。

因此,这一层的职责就是让错误具备明确的领域上下文。

2-9. pipeline ​

这一层是整个 indexing 包的核心。

如果说前面的目录负责的是不同角色的定义,那么 pipeline 负责的就是把这些角色按照固定顺序串起来,形成一条真正可执行的索引流水线。

也就是说,前面的层次都在回答“有哪些角色”,而 pipeline 回答的是“这些角色应该如何协同工作”。

从这个结构你会发现,indexing 包并不是简单地堆功能,而是按照“索引流程中的不同职责”来分层。总结一下,它的整体结构背后其实有两个核心设计思想:

1. 按角色拆分,而不是按实现堆叠:也就是说,先区分输入、转换、切分、向量化、写入、错误、流程这些不同角色,再分别放到对应目录中。

2. 把抽象和具体实现分开:接口、流程、默认实现、外部适配,各自处于不同层次,不混在一起。这样后续即使不断扩展能力,整体结构也不容易失控。

最后用一句话总结indexing 包的本质:

是一个离线索引构建的流程编排层。它通过清晰的职责边界和分层结构,把“文档输入、预处理、切块、向量化、写入存储”这些角色拆解开来,再由主流程把它们组织成一条完整的索引流水线。

3. 核心设计:runIndexing 与可插拔组件 ​

在 indexing 包里面,真正的核心不是某一个默认实现,而是 pipeline 包下面的主流程 runIndexing。

因为这个包最重要的事情,不是“自己实现了多少能力”,而是“能不能把索引这条流水线稳定地组织起来”。

ts
export async function runIndexing(
  options: IndexingOptions,
): Promise<IndexingResult> {
  const loader = options.loader;
  const chunker = options.chunker ?? new SimpleChunker();
  const shouldIndex = options.shouldIndex ?? defaultShouldIndex;
  const metadataBuilder = options.metadataBuilder ?? defaultMetadataBuilder;
  const transformers = options.transformers ?? [];
  const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;

  if (batchSize <= 0) {
    throw new Error("batchSize must be greater than 0");
  }

  // 加载文档
  const documents = await runStage("load", () => loader.load());

  // 最终返回的结果
  const result: IndexingResult = {
    documentsTotal: documents.length,
    documentsIndexed: 0,
    chunksTotal: 0,
    vectorsTotal: 0,
    skippedDocuments: 0,
    failedDocuments: 0,
  };

  // 遍历每一个文档,然后做相应的处理
  for (const rawDocument of documents) {
    // ...
  }

  return result;
}

从流程上看,runIndexing 做的事情其实很清晰,大致可以概括为下面几个步骤:

  1. load:加载文档
  2. transform:文档预处理
  3. filter:判断文档是否需要进入索引
  4. chunk:切分为 chunks
  5. metadata:补充 chunk metadata
  6. embed:将 chunk 转为向量
  7. store:写入向量存储

这条流程的价值在于,它把“索引阶段到底怎么跑”这件事情彻底固定下来了。

也就是说,后续无论你:

  • 接入什么数据源
  • 采用什么 chunking 策略
  • 使用什么 embedding 模型
  • 写入哪一种向量库

整体执行顺序都不会变。变化的只是每个阶段背后的具体实现。这也是为什么 indexing 包会定义一组可插拔组件接口:

  • Loader
  • DocumentTransformer
  • Chunker
  • Embedder
  • VectorStore

这些接口的作用,就是把“流程骨架”和“具体能力”拆开。例如:

  • Loader 只负责返回 Document[]
  • Chunker 只负责把 Document 变成 Chunk[]
  • Embedder 只负责把 Chunk[] 变成 Vector[]
  • VectorStore 只负责写入 Vector[]

这样设计之后,runIndexing 就不需要知道“你到底用了哪一家服务”,它只关心“每一步有没有按契约完成自己的工作”。

这是一种很典型的工程思路:先稳定流程,再替换组件。

另外,当前版本还提供了几个默认实现,例如简单切块器、mock embedder、内存 vector store。这些实现的意义,不是为了直接用于生产,而是为了完成两件事:

  1. 让整条流程具备最小可运行能力
  2. 让测试和 demo 可以稳定验证

换句话说,默认实现服务的是 MVP,而不是最终形态。

总结一下:

indexing 的核心设计,不在于内置了多少能力,而在于通过 runIndexing 把索引流程固定下来,再通过可插拔组件把具体实现解耦出去。

4. 当前版本的设计取舍与后续扩展方向 ​

看到这里,你可能会发现,当前的 indexing 包其实做得很“克制”。它已经具备了索引流程的基本骨架,但很多能力都还没有真正展开。例如:

  1. 没有内置真实 loader
  2. 没有接入真实 embedding 服务
  3. 没有接入真实向量数据库
  4. 没有做并发、重试、增量更新
  5. 还没有和 runtime 做正式的集成协议对接

这并不是因为这些能力不重要,而是因为当前阶段更重要的任务,是先把 indexing 的边界和骨架稳定下来。

如果一开始就把所有真实能力都堆进去,短期看功能会很多,但长期很容易出现两个问题:

  1. 包的职责迅速失控
  2. 流程抽象还没稳定,就已经和大量外部实现耦合在一起

一旦走到这一步,后面要改接口、改边界、改流程,成本就会非常高。

因此,当前版本的设计取舍其实很明确:

  • 先保证流程通
  • 先保证抽象清楚
  • 先保证 demo 和测试可验证
  • 再逐步引入真实能力

从后续扩展的角度来看,比较合理的演进方向应该是:

  1. 保持 runIndexing 这条主流程尽量稳定
  2. 把具体 loader 继续放在 adapters 或业务侧
  3. 逐步补真实 Embedder 和 VectorStore 适配
  4. 等 runtime 开始消费索引产物后,再补 integration 和 smoke

这样做的好处是,整个系统会先拥有稳定骨架,再逐步长出真实能力,而不是一开始就把所有复杂度压进核心包里。

5. 写在最后 ​

indexing 包的本质,不是去承载所有和索引有关的具体实现,而是先搭建一套稳定、清晰、可扩展的离线索引流水线。只有这条流水线先被设计清楚,后续整个 RAG SDK 的检索与生成能力,才会有可靠的基础。

markdown
接下来我们要进入到indexing包的开发工作了,请你先仔细阅读该设计文档,然后评价一下该设计文档是否合理,有没有需要修
改的地方

接下来我需要你根据该设计文档,结合你刚才得出的风险点以及建议修改项,整理一份具体的实施计划表,方便后面进行具体的代
码实施。

4.3 indexing中允许定义的内容
1,包内部执行上下文类型
2,默认组件的配置对象
3,pipeline运行结果结构
4.仅indexing内部使用的辅助类型
】我目前看到core包好像没有任何的更新,
理论上来讲,应该是需要新增和lindexing包相关的契约的,你帮我检查一下这一块儿
是否有问题


接下来请你修正这个点1.在core包新增相应契约2.将indexing包改为从@rag-sdk/core复用

我现在需要你把整个indexing的目录收敛一下,目前残留了很多早期创建的无用的空目录

-EOF-