Appearance
adapters包设计
在前面的内容中,我们已经完成了 core 和 indexing 的设计分析。接下来,要继续往前走,就必须处理另外一个非常实际的问题:外部生态能力应该怎么接进来?
因为一个真正可用的 RAG SDK,不可能只停留在抽象层。无论是文档加载、文本切分、embedding,还是向量存储,最终都要落到具体的第三方实现上。
但是,这些第三方能力又不能直接写进核心包。否则一旦耦合进去,整个 SDK 的边界就会很快变乱。
这也正是 adapters 包存在的原因。
这一篇文章,我们就来看看 adapters 包的相关设计。
1. adapters实现了什么
虽然前面我们已经讲清楚了 adapters 这一层为什么存在,但到了真正落代码的时候,仍然有一个问题必须先回答:
这个包到底应该怎么设计?
因为第三方生态的能力是非常松散的:
不同的库会有不同的数据结构、不同的调用方式、不同的配置模型,甚至连同一种能力,在不同生态中的抽象方式都不一样。比如有的文档加载器返回的是一种文档结构,有的文本切分器返回的是另一种切分结果,有的 embedding 接口直接输出向量数组,而有的向量数据库 SDK 又要求另一套写入格式。
如果 adapters 只是简单地把这些第三方能力原样暴露出来,那么这个包就不是“适配层”,而只是“第三方能力搬运层”。这样一来,外部生态的复杂性就会直接渗透进整个 SDK,前面在 core 和 indexing 中辛苦建立起来的内部边界,也会被重新打乱。
所以,adapters 包在设计上必须坚持一个原则:
它不是为了暴露第三方 SDK,而是为了把第三方能力收敛到 SDK 内部契约上。
也就是说,adapters 真正要做的事情,是把外部能力转换成当前 SDK 可以统一消费的角色。从索引流程的角度看,这些角色其实已经在 indexing 里面定义好了:
- Loader
- Chunker
- Embedder
- VectorStore
因此,adapters 包的核心设计思路其实非常明确:围绕这些稳定的内部接口,去适配外部世界中不断变化的第三方实现。
这样设计的好处是很直接的。
- 主流程会保持稳定:indexing 并不需要知道你背后接的是 LangChain、OpenAI、Chroma,还是将来别的生态,它只需要依赖统一的组件接口。
- 第三方依赖会被隔离:外部 SDK 的安装、升级、替换,都被收敛在 adapters 这一层,而不会污染 core 和 indexing 的边界。
- 扩展方式会比较自然:后面如果要增加新的 loader、embedding provider 或向量数据库,本质上都不是去修改主流程,而是在 adapters 中继续补新的适配实现。
不过,设计到这里,其实还有一个更关键的问题:适配真正难的地方,并不在于“调用第三方 API”,而在于“边界收敛”。
因为第三方能力接进来之后,最终还是要回到我们自己的共享模型里。例如:
- 外部文档结构要收敛成内部 Document
- 外部切分结果要收敛成内部 Chunk
- 外部 embedding 结果要收敛成内部 Vector
- 外部 metadata 要变成可控、可序列化、可流转的数据
这说明 adapters 的核心工作,本质上有两层:
- 能力适配:也就是把外部实现接到内部接口上
- 数据收敛:也就是把外部世界里不稳定的结构,转换成内部稳定的数据边界
从这个角度看,adapters 包其实并不是一个简单的“封装工具包”,而是整个 SDK 里专门负责处理“外部变化”的那一层。
因此,当前这版 adapters 的设计重点,并不在于一开始就支持多少第三方生态,而在于先把这套适配方式确定下来:
- 外部能力通过 adapter 接入
- 内部流程只依赖稳定契约
- 第三方数据在进入 SDK 时先做统一收敛
只要这三个点先站稳,后面的具体实现,无论是接 LangChain,还是接 Chroma,甚至以后继续接别的 provider,本质上都只是往这套设计里继续填充能力而已。
2. 如何落地
接下来我们看这套设计在代码里是怎么落下来的。
当前这版 adapters,并不是把所有第三方能力混在一起写,而是按照两个维度组织:
- 外部生态
- 适配角色
先看整体拓扑:
txt
adapters
└── src
├── shared
│ └── json-normalizer
├── langchain
│ ├── loaders
│ │ ├── loader-adapter
│ │ ├── directory-loader-adapter
│ │ └── markdown-loader
│ ├── chunkers
│ │ ├── chunker-adapter
│ │ ├── recursive-chunker
│ │ ├── token-chunker
│ │ └── markdown-chunker
│ ├── embedders
│ │ └── embeddings-adapter
│ └── shared
│ ├── document-mapper
│ └── vector-mapper
└── chroma
└── stores
└── vector-store-adapter这里我把名字做了简化,重点是理解目录角色。
2-1. 先按生态拆开
最外层先按第三方生态拆成两块:
langchainchroma
这样做的目的很简单:先回答“这段代码是在适配谁”。其中:
langchain负责文档加载、文本切分、embeddingchroma负责向量写入存储
2-2. 再按角色拆开
进入 langchain 后,代码又继续按角色拆分:
loaderschunkersembeddersshared
这对应的就是内部稳定契约。也就是说,当前实现不是围着第三方 SDK 的类名组织代码,而是围着 SDK 自己真正关心的角色组织代码。例如:
- loader 对齐
Loader - chunker 对齐
Chunker - embedder 对齐
Embedder
这样后面即使继续扩能力,结构也不会乱。
2-3. shared 负责边界收敛
shared 这层本质上是在做一件事:边界收敛。
adapters 接第三方能力时,真正麻烦的通常不是“调一下第三方 API”,而是第三方返回的数据结构和我们 SDK 内部的数据结构不一致。比如:
- 第三方文档对象里叫 pageContent,我们内部叫 content
- 第三方 metadata 里可能有 Date、URL、bigint
- splitter 返回的结果里,不一定有我们后续流程需要的 sourceDocumentId、chunkIndex
- embedding 返回的只是 number[][],但我们内部需要的是
Vector[]
所以 shared 的作用,就是把这些公共转换逻辑统一收起来,避免散落在各个 adapter 里面。
当前这一层我们做了三类事情:
- 文档映射:把第三方文档转换成内部 Document,也把切分结果转换成内部 Chunk。
- 向量映射:把 chunk 和 embedding 结果组合成内部 Vector。
- metadata 归一化:把不稳定的值收敛成可序列化、可流转的 JSON-safe 结构。
例如,第三方 loader 可能返回这样的文档:
ts
{
id: "post-1",
pageContent: "RAG 是一种把检索和生成结合起来的方案",
metadata: {
author: "xiejie",
createdAt: new Date("2026-04-01"),
source: new URL("https://example.com/post/1")
}
}但进入 SDK 之后,会先被收敛成内部 Document:
ts
{
id: "post-1",
content: "RAG 是一种把检索和生成结合起来的方案",
metadata: {
author: "xiejie",
createdAt: "2026-04-01T00:00:00.000Z",
source: "https://example.com/post/1"
}
}上面做的事情并不复杂,但很关键:
pageContent被统一成contentDate、URL这类值被转成可序列化结果- 后续流程拿到的都是统一结构,而不是第三方 SDK 各自的对象格式
所以,shared 的作用就是把第三方世界里不稳定的数据,收敛成 SDK 内部稳定的数据边界。
3. langchain适配线
当前这版 adapters,首先落地的是 LangChain 这一条适配线。
不过要先说明一点:LangChain 只是当前 SDK 提供的一种默认策略,并不意味着系统和 LangChain 绑定死了。因为 indexing 真正依赖的不是 LangChain 本身,而是内部稳定接口:
LoaderChunkerEmbedder
LangChain 能接进来,是因为 adapters 把它的能力收敛到了这些接口上。将来如果要换成别的方案,也仍然是在 adapter 这一层扩展,而不是去改主流程。
当前 LangChain 这一条线,主要覆盖三类能力:
- 文档加载
- 文本切分
- 向量化
整体结构如下:
txt
langchain
├── loaders
│ ├── LoaderAdapter
│ ├── DirectoryLoaderAdapter
│ └── MarkdownLoader
├── chunkers
│ ├── ChunkerAdapter
│ ├── RecursiveChunker
│ ├── TokenChunker
│ └── MarkdownChunker
├── embedders
│ └── EmbeddingsAdapter
└── shared
├── document-mapper
└── vector-mapper重点不是记文件名,而是理解:LangChain 这一层并不是直接暴露第三方 SDK,而是按 loader、chunker、embedder 这些内部角色来组织实现。
3-1. loader:通用适配 + 默认实现
文档加载这一块儿,当前先提供了一个通用适配层:
ts
type LoaderLike = {
load(): Promise<ThirdPartyDocument[]>;
};
class LoaderAdapter implements Loader {
constructor(private loader: LoaderLike) {}
async load() {
const docs = await this.loader.load();
return docs.map(toInternalDocument);
}
}这里的思路很清楚:外部 loader 负责把文档读出来,adapter 负责把结果转成内部 Document。
在这个基础上,当前又补了目录加载版本,以及 markdown 目录的默认实现。也就是说,开发者既可以直接使用默认 loader,也可以替换成自己的第三方 loader,只要最终符合 Loader 接口即可。
3-2. chunker:通用适配 + 常用预设
文本切分也是一样的思路。最底层先有一个通用 chunker adapter,把任意实现了 splitDocuments() 的 splitter 接到内部 Chunker:
ts
class ChunkerAdapter implements Chunker {
constructor(private splitter: ThirdPartySplitter) {}
async chunk(document: Document): Promise<Chunk[]> {
const docs = await this.splitter.splitDocuments([
toThirdPartyDocument(document),
]);
return docs
.map((doc, index) => toInternalChunk(document, doc, index))
.filter((chunk) => chunk.content.trim().length > 0);
}
}例如,一个原始文档:
ts
{
id: "doc-1",
content: "RAG 会先加载文档,然后切分文本,最后生成向量。",
metadata: { category: "intro" }
}切分后会被收敛成内部 Chunk:
ts
[
{
id: "doc-1#0",
content: "RAG 会先加载文档,",
metadata: {
category: "intro",
sourceDocumentId: "doc-1",
chunkIndex: 0
}
}
]在通用适配器之上,当前又补了几个常见预设,例如递归切分、token 切分、markdown 切分。它们本质上只是对常见 LangChain splitter 的轻包装,用来降低接入成本。
3-3. embedder:先提供通用接法
embedding 这一块儿,当前版本更克制,只先提供了一个通用 embeddings adapter:
ts
type EmbeddingsLike = {
embedDocuments(texts: string[]): Promise<number[][]>;
};
class EmbeddingsAdapter implements Embedder {
constructor(private embeddings: EmbeddingsLike) {}
async embed(chunks: Chunk[]) {
const vectors = await this.embeddings.embedDocuments(
chunks.map((chunk) => chunk.content),
);
return chunks.map((chunk, index) => ({
id: chunk.id,
values: vectors[index],
metadata: chunk.metadata,
}));
}
}这里的重点是:adapter 负责把第三方 embeddings 的输出收敛成内部 Vector,但并不急着在这一层绑定某个具体 provider,比如 OpenAI。
换句话说,当前先解决的是“embedding 怎么接”,而不是“必须用谁”。
所以,当前 LangChain 这条线提供的并不是一种强绑定方案,而是一套默认接入方案:
- loader 有默认实现,也允许替换
- chunker 有默认预设,也允许自定义 splitter
- embedder 提供通用接法,不和某个 provider 绑定
一句话总结就是:当前版本选择 LangChain,是为了先把索引流程前半段稳定落地;但从设计上看,SDK 绑定的是内部契约,而不是 LangChain 本身。
4. Chroma 适配线
如果说前面的 LangChain 适配线,解决的是“文档怎么进来、文本怎么切、内容怎么变成向量”,那么 Chroma 这一条线解决的就是下一步:
向量最后怎么写进数据库。
当前版本在这一侧提供的是一个 VectorStore 适配实现。也就是说,SDK 内部并不会直接依赖 Chroma 的 client,而是先通过 adapter,把 Chroma 收敛成内部统一的 VectorStore 接口。
整体结构比较简单:
chroma
└── stores
└── VectorStoreAdapter这里也能看出当前版本的取舍:Chroma 这一侧先只做向量写入,不急着扩到查询。
因为对当前 indexing 流程来说,最核心的需求就是把 embedding 后的结果稳定写进去。只要这一点先打通,整条离线索引链路就闭环了。
4-1. Chroma adapter 做的事情并不复杂
它的核心逻辑可以简化理解成下面这样:
ts
class VectorStoreAdapter implements VectorStore {
constructor(private client: ChromaClient, private collectionName: string) {}
async upsert(vectors: Vector[]) {
const collection = await this.client.getOrCreateCollection({
name: this.collectionName,
});
await collection.upsert({
ids: vectors.map((v) => v.id),
embeddings: vectors.map((v) => v.values),
metadatas: vectors.map((v) => v.metadata ?? {}),
});
}
}这里 adapter 主要做了两件事:
- 获取或创建 collection
- 把内部 Vector[] 转成 Chroma 需要的写入结构
例如,SDK 内部的向量可能是这样:
ts
[
{
id: "doc-1#0",
values: [0.12, -0.34, 0.87],
metadata: {
sourceDocumentId: "doc-1",
chunkIndex: 0,
category: "intro"
}
}
]但 Chroma 在写入时,并不是直接接收这类对象,而是要求拆成这样的结构:
ts
{
ids: ["doc-1#0"],
embeddings: [[0.12, -0.34, 0.87]],
metadatas: [
{
sourceDocumentId: "doc-1",
chunkIndex: 0,
category: "intro"
}
]
}所以这里的 adapter,本质上就是在做最后一次边界转换:把 SDK 内部统一的 Vector,转换成 Chroma 能识别的写入格式。
4-2. 当前实现还有两个关键处理
除了基本写入外,当前这版实现还额外做了两件事。
第一件事,是在 adapter 内部处理 collection 的获取与复用。 也就是说,开发者不需要每次自己手动调用底层 Chroma API,直接把它当成一个 VectorStore 来用就可以。
第二件事,是对 metadata 做了一层收敛。 因为内部 metadata 结构可能比较灵活,但向量数据库通常对 metadata 类型有自己的限制。所以 adapter 会先做一次归一化,再写入 Chroma。
可以把这个过程理解成:
ts
{
section: {
title: "intro"
},
createdAt: new Date(),
tags: ["rag", "sdk"]
}在真正写入前,会被收敛成更稳定、可序列化的形式,例如:
ts
{
section: "{\"title\":\"intro\"}",
createdAt: "2026-04-18T00:00:00.000Z",
tags: ["rag", "sdk"]
}这样做的目的很简单:避免把一些过于松散的结构直接送进底层存储系统。
4-3. 为什么当前只做写入,不做查询
这里还有一个边界需要明确:当前这版 Chroma 适配,只覆盖 upsert,不覆盖查询。
这不是遗漏,而是有意收敛。
因为这一阶段 adapters 服务的重点,还是离线索引流程;而离线索引流程对 store 的核心要求,就是把向量稳定存进去。至于检索、过滤、召回这些能力,更适合等后面 runtime 的消费路径明确之后,再决定如何抽象。
所以当前版本的 Chroma 适配线,重点不是“把 Chroma 所有能力都包一遍”,而是“先把向量写入这一步稳定落地”。
一句话总结就是:LangChain 适配线负责把内容变成向量,Chroma 适配线负责把向量落到存储里。到这里,adapters 已经把离线索引流程的外部能力链路基本接通了。
5. 开发者该如何使用 adapters
站在开发者角度,adapters 的使用方式其实很直接:为索引流程分别准备好 loader、chunker、embedder、store,然后交给 runIndexing 即可。
例如,直接使用当前仓库提供的默认策略时,代码大致就是这样:
ts
import { runIndexing } from "@rag-sdk/indexing";
import {
LangChainMarkdownDirectoryLoader,
LangChainRecursiveCharacterTextSplitterAdapter,
LangChainEmbeddingsAdapter,
ChromaVectorStoreAdapter,
} from "@rag-sdk/adapters";
const loader = new LangChainMarkdownDirectoryLoader({
path: "./docs",
idPrefix: "doc",
});
const chunker = new LangChainRecursiveCharacterTextSplitterAdapter({
chunkSize: 500,
chunkOverlap: 50,
});
const embedder = new LangChainEmbeddingsAdapter({
embeddings: yourLangChainEmbeddings,
});
const store = new ChromaVectorStoreAdapter({
host: "localhost",
port: 8000,
collectionName: "rag-docs",
});
await runIndexing({
loader,
chunker,
embedder,
store,
});这套组合的意思很清楚:
- 用默认 markdown loader 加载文档
- 用默认递归切分策略切 chunk
- 用 LangChain 风格的 embeddings 生成向量
- 用 Chroma 存储向量
如果开发者只是想先把一条 indexing 链路跑通,那么这就是当前最推荐的默认用法。
不过,adapters 的意义并不只是提供默认实现,更重要的是给“替换能力”留出了统一入口。
例如,开发者完全可以只复用其中一部分,另外几部分接自己的实现。假设你不想用默认 loader,而是想接自己的第三方文档加载能力,那么只要它最终符合 Loader 接口即可:
ts
import type { Loader } from "@rag-sdk/indexing";
const loader: Loader = {
async load() {
const rows = await db.query("select id, title, content from articles");
return rows.map((row) => ({
id: row.id,
content: `${row.title}\n\n${row.content}`,
metadata: {
source: "database",
articleId: row.id,
},
}));
},
};比如你不想用 LangChain splitter,而是按段落切:
ts
import type { Chunker } from "@rag-sdk/indexing";
import type { Document } from "@rag-sdk/core";
const chunker: Chunker = {
async chunk(document: Document) {
return document.content
.split("\n\n")
.map((part) => part.trim())
.filter(Boolean)
.map((part, index) => ({
id: `${document.id}#${index}`,
content: part,
metadata: {
...(document.metadata ?? {}),
sourceDocumentId: document.id,
chunkIndex: index,
},
}));
},
};因为 Chunker 本质上只是一个“把 Document 变成 Chunk[]”的接口,并不要求必须来自 LangChain。
比如你想直接调用别的 embedding 服务:
ts
import type { Embedder } from "@rag-sdk/indexing";
import type { Chunk } from "@rag-sdk/core";
const embedder: Embedder = {
async embed(chunks: Chunk[]) {
const response = await yourEmbeddingClient.embed({
texts: chunks.map((chunk) => chunk.content),
});
return chunks.map((chunk, index) => ({
id: chunk.id,
values: response.vectors[index],
metadata: chunk.metadata,
}));
},
};这里也不需要 LangChain。你只需要保证最后返回的是 Vector[]
如果你不想把向量写进 Chroma,而是想接别的向量数据库,那么也只需要自己提供一个符合 VectorStore 接口的对象:
ts
import type { VectorStore } from "@rag-sdk/indexing";
import type { Vector } from "@rag-sdk/core";
const store: VectorStore = {
async upsert(vectors: Vector[]) {
await pinecone.index("rag-docs").upsert(
vectors.map((vector) => ({
id: vector.id,
values: vector.values,
metadata: vector.metadata,
})),
);
},
};其余部分仍然可以继续使用默认策略:
ts
await runIndexing({
loader,
chunker: new LangChainRecursiveCharacterTextSplitterAdapter({
chunkSize: 500,
chunkOverlap: 50,
}),
embedder,
store,
});所以,开发者在使用 adapters 时,可以把它理解成两种模式:
默认模式:直接使用当前仓库提供的 LangChain + Chroma 方案,快速跑通整条链路
混合模式:只使用其中一部分默认 adapter,其他部分替换成自己的第三方能力
这也是 adapters 这一层最重要的价值:它既提供默认策略,降低接入门槛;又不把开发者锁死在某一种外部生态上。
因此,对使用者来说,真正需要记住的不是某一个具体 adapter 的名字,而是这一条原则:默认实现可以直接用,但只要契约一致,任何一环都可以替换。
6. 写在最后
到这里,adapters 这一层的设计就比较清楚了。
它的价值,并不只是“接入了 LangChain 和 Chroma”,而是在整个 SDK 里,给第三方能力进入系统提供了一个统一落点。前面的 core 负责稳定的数据模型,indexing 负责稳定的流程骨架,而 adapters 则负责把变化的外部能力,以一种可控的方式接进来。
这也是为什么当前这版实现虽然还不算大,但已经很重要。因为一旦这一层的边界和接入方式先稳定下来,后面无论是继续补更多的 loader、embedder,还是接入其他向量数据库,本质上都只是沿着同一套结构继续扩展,而不需要回头打乱整个 SDK 的分层。
所以,从工程设计的角度看,adapters 真正解决的,不是“如何封装某个第三方 SDK”,而是“如何让外部生态能力稳定地进入我们自己的系统”。这一点想清楚了,后面的扩展方向也就自然清楚了。
markdown
接下来你先看一下adapter包的设计文档,我本来就打算使用langchain来对接该rag sdki的部分能力,这样就能节省很多工作量,不用写那么多loader、splitter之类的。不过我打算做一个适配器,langchain通过适配器的方式来作为外部的一种实现
如果你要继续,我建议下一步不要直接写代码,先让我把这份adapters设计文档改成“可落地到当前仓库"的版本。
我可以直接帮你产出一版修订稿,收敛成适合当前monorepo的adapters MVP实施文档。
好的,你先把这份adapters设计文档改成“可落地到当前仓库"的版本。
非常好,接下来我需要你根据这个adapter包的设计文档,出一版详细的实施规划出来,最好以todolist的形式列出接下来要做的每一个步骤
Start implementation-EOF-