Appearance
indexing包设计
在前面的内容中,我们已经完成了 RAG 知识框架的梳理,也明确了整个 RAG SDK 的基本拆分思路。接下来,就要开始进入更具体的包设计了。
如果从整个 RAG 流程来看,一个基础的 RAG 系统通常可以分为两个大的部分:
- 数据索引
- 数据检索与生成
其中,数据检索这一块儿,直接面向用户请求;而数据索引这一块儿,则更像是整个系统的前置准备工作。它不直接参与回答问题,但却决定了后续到底“能检索到什么”。
很多时候,一个 RAG 系统效果不好,问题并不一定出在检索阶段,也可能出在索引阶段。比如:
- 文档没有清洗干净
- chunk 切分方式不合理
- metadata 不完整
- embedding 策略不稳定
- 向量写入流程缺乏统一约束
这些问题本质上都属于索引设计问题。
也正因为如此,在 SDK 层面,数据索引不能只被看作几个零散的工具函数,而应该被沉淀成一个独立的能力包。这个包,就是 indexing。
这一篇文章,我们就来专门聊一聊 indexing 包应该如何设计,以及它在整个 RAG SDK 中承担什么角色。
1. 为什么要单独设计 indexing 包
先来看一个最基本的问题:为什么索引这一块儿值得单独拆成一个包?
表面上看,数据索引无非就是把文档存进向量数据库,似乎没有必要专门设计一个独立模块。但如果你把这个过程真正展开,就会发现事情并没有这么简单。
一个完整的数据索引流程,往往至少包含下面这些步骤:
- 加载原始文档
- 对文档做必要的预处理
- 过滤无效或不需要进入索引的数据
- 将文档切分为 chunks
- 为 chunk 补充 metadata
- 将 chunk 转换为向量
- 把向量写入存储系统
你会发现,这已经不是一个单点能力,而是一条完整的数据处理流水线。而只要是一条流水线,就会天然带来下面这些工程问题:
- 每一步的输入输出是否统一
- 各个步骤之间如何解耦
- 某一步失败之后,流程应该如何处理
- 不同项目下,是否可以替换其中某些实现
- 后续接入真实服务时,是否还能保持原有结构稳定
如果没有一个独立的 indexing 包来承载这些问题,那么这些逻辑通常就会分散在业务代码中。短期看似乎也能工作,但长期会出现几个很典型的问题。
1. 流程逻辑四处分散:有些逻辑写在 loader 里,有些逻辑写在业务脚本里,有些逻辑直接写在某个 demo 文件中。这样一来,索引到底是怎么完成的,就很难一眼看清楚。
2. 具体实现和流程编排混在一起:例如一边写目录扫描,一边写 chunk 切分,一边写 embedding 调用,一边写向量入库。这样会导致一旦底层能力变了,整个流程都得跟着改。
3. 缺乏统一契约:今天文档是这个结构,明天 chunk 又换成另一个结构,后续不同包之间就很容易出现类型和语义不一致的问题。
4. 不利于测试和扩展:如果索引逻辑全部耦合在一起,就很难分别替换不同的 loader、chunker、embedder、store,也很难对主流程做稳定验证。
所以,从工程视角来看,indexing 包之所以需要被单独设计,核心原因就在于:数据索引并不是一个简单动作,而是一套需要长期稳定演进的流程能力。
这里还可以再往前走一步。
在 RAG 系统中,检索阶段之所以能够工作,前提就是索引阶段已经提前把知识整理好了。也就是说,索引阶段虽然发生在“离线”,但它对整个系统的影响是“在线持续生效”的。
例如:
- chunk 切得好不好,会直接影响召回粒度
- metadata 设计得好不好,会直接影响检索过滤能力
- embedding 过程是否稳定,会直接影响向量质量
- 写入存储时有没有统一规范,会直接影响后续 runtime 如何消费这些数据
从这个角度看,indexing 实际上承担的是“为后续检索阶段准备高质量知识数据”的职责。因此,更合理的做法不是把它看成一个附属工具,而是把它看成 RAG SDK 中一个独立的能力层。这个能力层有几个明显特点:
- 它本质上是流程驱动的
- 它依赖多个可替换组件协同工作
- 它需要稳定的输入输出契约
- 它需要明确的边界,避免和具体 IO、第三方实现耦合
- 它需要具备最小可验证能力,方便后续迭代
也正因为这些特点,indexing 包的设计重点,从一开始就不应该是“先接入多少外部能力”,而应该是“先把索引流程的骨架搭好”。只有骨架先稳定下来,后续无论接入真实 loader、真实 embedding 服务,还是接入真实向量数据库,都不会轻易把整个结构冲散。
最后,我们可以用一句话来总结这一小节:
indexing包之所以需要单独设计,是因为数据索引阶段本质上是一条完整的离线数据处理流水线,它需要被沉淀为 SDK 中的独立能力,而不是散落在业务代码中的若干脚本和工具函数。
2. indexing 包的职责与整体结构
在明确了为什么要单独设计 indexing 包之后,接下来要回答另一个问题:
这个包在整个 RAG SDK 中,究竟负责什么?
如果一个包的职责边界没有提前划清楚,后面它就很容易不断“发胖”。
NOTE
例如凡是和索引稍微相关一点的东西,都塞进indexing包。时间一长,这个包就会同时承载:
- 流程编排
- 外部接入
- 具体实现
- 工具函数
- ....
最后变得难以维护。
所以,在进入具体实现之前,先把 indexing 的职责说清楚,是非常必要的。从整个 RAG SDK 的视角来看,当前几个核心层次大致如下:
- core:负责共享模型、schema、基础契约和通用错误
- indexing:负责离线索引构建流程
- adapters:负责接入外部能力
- runtime:负责在线检索、上下文组织和答案生成
在这个结构中,indexing 处于承上启下的位置。
它一方面依赖 core 提供的共享模型,例如 Document、Chunk、Vector;另一方面,又为后续 runtime 提供索引阶段产出的基础数据。至于具体的数据来源、embedding 服务、向量数据库,则交给 adapters 或业务方实现。
所以,indexing 最准确的定位是:它是一个离线索引构建的流程编排层。
这意味着,indexing 的核心任务不是实现所有具体能力,而是先把索引流程的骨架搭好,并定义清楚流程里需要哪些角色。
从职责上讲,indexing 主要负责:
- 定义索引流程中的核心组件接口
- 组织索引主流程的执行顺序
- 统一流程中的输入输出契约
- 提供少量默认实现用于 demo 和测试
- 提供统一的错误语义与结果统计
相对地,它不负责下面这些事情:
- 不负责本地文件系统扫描
- 不负责具体文件格式读取
- 不负责真实 embedding 服务接入
- 不负责直接耦合某一家向量数据库 SDK
- 不负责在线检索和答案生成
原因很简单:这些事情虽然和索引有关,但它们本质上属于“外部能力接入”,而不是“流程编排”。
所以,更合理的分工应该是:
- 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.json2-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 做的事情其实很清晰,大致可以概括为下面几个步骤:
load:加载文档transform:文档预处理filter:判断文档是否需要进入索引chunk:切分为 chunksmetadata:补充 chunk metadataembed:将 chunk 转为向量store:写入向量存储
这条流程的价值在于,它把“索引阶段到底怎么跑”这件事情彻底固定下来了。
也就是说,后续无论你:
- 接入什么数据源
- 采用什么 chunking 策略
- 使用什么 embedding 模型
- 写入哪一种向量库
整体执行顺序都不会变。变化的只是每个阶段背后的具体实现。这也是为什么 indexing 包会定义一组可插拔组件接口:
LoaderDocumentTransformerChunkerEmbedderVectorStore
这些接口的作用,就是把“流程骨架”和“具体能力”拆开。例如:
Loader只负责返回Document[]Chunker只负责把Document变成Chunk[]Embedder只负责把Chunk[]变成Vector[]VectorStore只负责写入Vector[]
这样设计之后,runIndexing 就不需要知道“你到底用了哪一家服务”,它只关心“每一步有没有按契约完成自己的工作”。
这是一种很典型的工程思路:先稳定流程,再替换组件。
另外,当前版本还提供了几个默认实现,例如简单切块器、mock embedder、内存 vector store。这些实现的意义,不是为了直接用于生产,而是为了完成两件事:
- 让整条流程具备最小可运行能力
- 让测试和 demo 可以稳定验证
换句话说,默认实现服务的是 MVP,而不是最终形态。
总结一下:
indexing的核心设计,不在于内置了多少能力,而在于通过runIndexing把索引流程固定下来,再通过可插拔组件把具体实现解耦出去。
4. 当前版本的设计取舍与后续扩展方向
看到这里,你可能会发现,当前的 indexing 包其实做得很“克制”。它已经具备了索引流程的基本骨架,但很多能力都还没有真正展开。例如:
- 没有内置真实 loader
- 没有接入真实 embedding 服务
- 没有接入真实向量数据库
- 没有做并发、重试、增量更新
- 还没有和
runtime做正式的集成协议对接
这并不是因为这些能力不重要,而是因为当前阶段更重要的任务,是先把 indexing 的边界和骨架稳定下来。
如果一开始就把所有真实能力都堆进去,短期看功能会很多,但长期很容易出现两个问题:
- 包的职责迅速失控
- 流程抽象还没稳定,就已经和大量外部实现耦合在一起
一旦走到这一步,后面要改接口、改边界、改流程,成本就会非常高。
因此,当前版本的设计取舍其实很明确:
- 先保证流程通
- 先保证抽象清楚
- 先保证 demo 和测试可验证
- 再逐步引入真实能力
从后续扩展的角度来看,比较合理的演进方向应该是:
- 保持
runIndexing这条主流程尽量稳定 - 把具体 loader 继续放在
adapters或业务侧 - 逐步补真实
Embedder和VectorStore适配 - 等
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-