Skip to content

adapters包设计 ​

在前面的内容中,我们已经完成了 core 和 indexing 的设计分析。接下来,要继续往前走,就必须处理另外一个非常实际的问题:外部生态能力应该怎么接进来?

因为一个真正可用的 RAG SDK,不可能只停留在抽象层。无论是文档加载、文本切分、embedding,还是向量存储,最终都要落到具体的第三方实现上。

但是,这些第三方能力又不能直接写进核心包。否则一旦耦合进去,整个 SDK 的边界就会很快变乱。

这也正是 adapters 包存在的原因。

这一篇文章,我们就来看看 adapters 包的相关设计。

1. adapters实现了什么 ​

虽然前面我们已经讲清楚了 adapters 这一层为什么存在,但到了真正落代码的时候,仍然有一个问题必须先回答:

这个包到底应该怎么设计?

因为第三方生态的能力是非常松散的:

不同的库会有不同的数据结构、不同的调用方式、不同的配置模型,甚至连同一种能力,在不同生态中的抽象方式都不一样。比如有的文档加载器返回的是一种文档结构,有的文本切分器返回的是另一种切分结果,有的 embedding 接口直接输出向量数组,而有的向量数据库 SDK 又要求另一套写入格式。

如果 adapters 只是简单地把这些第三方能力原样暴露出来,那么这个包就不是“适配层”,而只是“第三方能力搬运层”。这样一来,外部生态的复杂性就会直接渗透进整个 SDK,前面在 core 和 indexing 中辛苦建立起来的内部边界,也会被重新打乱。

所以,adapters 包在设计上必须坚持一个原则:

它不是为了暴露第三方 SDK,而是为了把第三方能力收敛到 SDK 内部契约上。

也就是说,adapters 真正要做的事情,是把外部能力转换成当前 SDK 可以统一消费的角色。从索引流程的角度看,这些角色其实已经在 indexing 里面定义好了:

  1. Loader
  2. Chunker
  3. Embedder
  4. VectorStore

因此,adapters 包的核心设计思路其实非常明确:围绕这些稳定的内部接口,去适配外部世界中不断变化的第三方实现。

这样设计的好处是很直接的。

  1. 主流程会保持稳定:indexing 并不需要知道你背后接的是 LangChain、OpenAI、Chroma,还是将来别的生态,它只需要依赖统一的组件接口。
  2. 第三方依赖会被隔离:外部 SDK 的安装、升级、替换,都被收敛在 adapters 这一层,而不会污染 core 和 indexing 的边界。
  3. 扩展方式会比较自然:后面如果要增加新的 loader、embedding provider 或向量数据库,本质上都不是去修改主流程,而是在 adapters 中继续补新的适配实现。

不过,设计到这里,其实还有一个更关键的问题:适配真正难的地方,并不在于“调用第三方 API”,而在于“边界收敛”。

因为第三方能力接进来之后,最终还是要回到我们自己的共享模型里。例如:

  • 外部文档结构要收敛成内部 Document
  • 外部切分结果要收敛成内部 Chunk
  • 外部 embedding 结果要收敛成内部 Vector
  • 外部 metadata 要变成可控、可序列化、可流转的数据

这说明 adapters 的核心工作,本质上有两层:

  1. 能力适配:也就是把外部实现接到内部接口上
  2. 数据收敛:也就是把外部世界里不稳定的结构,转换成内部稳定的数据边界

从这个角度看,adapters 包其实并不是一个简单的“封装工具包”,而是整个 SDK 里专门负责处理“外部变化”的那一层。

因此,当前这版 adapters 的设计重点,并不在于一开始就支持多少第三方生态,而在于先把这套适配方式确定下来:

  • 外部能力通过 adapter 接入
  • 内部流程只依赖稳定契约
  • 第三方数据在进入 SDK 时先做统一收敛

只要这三个点先站稳,后面的具体实现,无论是接 LangChain,还是接 Chroma,甚至以后继续接别的 provider,本质上都只是往这套设计里继续填充能力而已。

2. 如何落地 ​

接下来我们看这套设计在代码里是怎么落下来的。

当前这版 adapters,并不是把所有第三方能力混在一起写,而是按照两个维度组织:

  1. 外部生态
  2. 适配角色

先看整体拓扑:

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. 先按生态拆开 ​

最外层先按第三方生态拆成两块:

  • langchain
  • chroma

这样做的目的很简单:先回答“这段代码是在适配谁”。其中:

  • langchain 负责文档加载、文本切分、embedding
  • chroma 负责向量写入存储

2-2. 再按角色拆开 ​

进入 langchain 后,代码又继续按角色拆分:

  • loaders
  • chunkers
  • embedders
  • shared

这对应的就是内部稳定契约。也就是说,当前实现不是围着第三方 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 里面。

当前这一层我们做了三类事情:

  1. 文档映射:把第三方文档转换成内部 Document,也把切分结果转换成内部 Chunk。
  2. 向量映射:把 chunk 和 embedding 结果组合成内部 Vector。
  3. 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 被统一成 content
  • Date、URL 这类值被转成可序列化结果
  • 后续流程拿到的都是统一结构,而不是第三方 SDK 各自的对象格式

所以,shared 的作用就是把第三方世界里不稳定的数据,收敛成 SDK 内部稳定的数据边界。

3. langchain适配线 ​

当前这版 adapters,首先落地的是 LangChain 这一条适配线。

不过要先说明一点:LangChain 只是当前 SDK 提供的一种默认策略,并不意味着系统和 LangChain 绑定死了。因为 indexing 真正依赖的不是 LangChain 本身,而是内部稳定接口:

  • Loader
  • Chunker
  • Embedder

LangChain 能接进来,是因为 adapters 把它的能力收敛到了这些接口上。将来如果要换成别的方案,也仍然是在 adapter 这一层扩展,而不是去改主流程。

当前 LangChain 这一条线,主要覆盖三类能力:

  1. 文档加载
  2. 文本切分
  3. 向量化

整体结构如下:

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 主要做了两件事:

  1. 获取或创建 collection
  2. 把内部 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 时,可以把它理解成两种模式:

  1. 默认模式:直接使用当前仓库提供的 LangChain + Chroma 方案,快速跑通整条链路

  2. 混合模式:只使用其中一部分默认 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-