Skip to content

向量存储

要点

  • 文档切成块以后,还不能直接给 Agent 用。中间要先把每个块变成向量,再存进向量库,检索时只拿最相关的块。
  • Embedding 模型不负责回答问题,它只负责把文本变成向量,让语义相近的文本在向量空间里靠得更近。
  • embedDocuments(...) 用于给知识库建索引,embedQuery(...) 用于用户提问时的检索。
  • 向量库负责存向量并支持相似度检索。教程里先用 MemoryVectorStore,生产环境再换持久化方案。
  • 检索到相关块后,要先把结果整理成可读上下文,再交给 Agent 回答。

1. 背景:文档切成块以后,还不能直接给 Agent 用

上一篇已经把文档切成了很多块。但切完之后,这些块还只是 Document[],离「可检索」还差一步。

如果现在就把几百个块直接塞给 Agent,会有两个问题:

  • 一次根本塞不下。
  • 就算塞得下,也不知道该看哪几块。

所以中间还要补一层:

  1. 先把每个块变成向量。
  2. 再把这些向量存起来。
  3. 用户提问时,先找最相关的块。
  4. 最后再把这些块交给 Agent。

这一篇处理的就是中间这两步:Embedding 和 Vector Store。

2. Embedding 做的事情很单纯

Embedding 模型不负责回答问题。它只做一件事:把一段文本变成一组数字。

typescript
import { OpenAIEmbeddings } from "@langchain/openai";

const embeddings = new OpenAIEmbeddings({
  // 这里先用最常见的小模型,把流程跑通最重要。
  model: "text-embedding-3-small",
});

// 用户问题进入检索前,先会被转成向量。
const vector = await embeddings.embedQuery("接口鉴权流程是什么");

// 向量维度是固定的,后面相似度比较就在这个数字空间里完成。
console.log(vector.length);

// 这里只是随便看几个值,实际项目里不会手动处理这些数字。
console.log(vector.slice(0, 5));

这一组数字本身没法直接拿来给人看。它真正的用途是:让两段意思接近的文本,在向量空间里也靠得更近。比如:

  • 用户问「接口鉴权流程是什么」。
  • 文档里写「客户端用 appKey 和 timestamp 生成签名,服务端校验后返回 accessToken」。

虽然两句话不完全一样,但 embedding 后,二者会比较接近。后面的向量库就能把这段文档找出来。

3. 文档和查询,调用方法不一样

这里有一个细节要先记住:

  • 文档建索引用 embedDocuments(...)
  • 用户查询用 embedQuery(...)
typescript
import { OpenAIEmbeddings } from "@langchain/openai";

const embeddings = new OpenAIEmbeddings({
  model: "text-embedding-3-small",
});

// 这里是知识库里的文档内容,属于「提前建索引」的阶段。
const docVectors = await embeddings.embedDocuments([
  "客户端用 appKey 和 timestamp 生成签名,服务端校验后返回 accessToken。",
  "默认每秒最多 100 次请求,超限返回 429。",
]);

// 这里是用户实时发来的问题,属于「查询阶段」。
const queryVector = await embeddings.embedQuery("怎么拿到 accessToken");

// 两条文档,所以会返回两组向量。
console.log(docVectors.length);

// 查询只有一句,所以只会得到一组向量。
console.log(queryVector.length);

这两个方法名字很像,但不要混着用。这里直接按场景记最省事:

  • 知识库里的内容,用 embedDocuments
  • 用户实时发来的问题,用 embedQuery

第一次看到这里,通常会有两个疑问:为什么同样都是「把文本变成向量」,还要分两个方法?项目里到底在什么地方各用一次?把时间顺序拆开看,就会清楚很多。

第一段:建索引

这一步发生在用户提问之前。比如你已经有一份 API 文档,准备把它做成知识库:

  1. 先把文档切成很多块。
  2. 再把这些块统一做 embedding。
  3. 最后写进向量库。

这一段处理的对象,都是「知识库里的文档内容」。所以这里走的是 embedDocuments(...)

第二段:用户开始提问

这一步发生在运行时。用户现在问了一句:「怎么拿到 accessToken」。系统不会重新给整份文档做 embedding。它只会把「这句问题」转成向量,再拿这个向量去向量库里做相似度比较。

这一段处理的对象,是「当前这句查询」。所以这里走的是 embedQuery(...)

也就是说,整条链其实是这样的:

text
建索引阶段:

文档块 A ─┐
文档块 B ─┼─ embedDocuments(...) ─→ 写进向量库
文档块 C ─┘

查询阶段:

用户问题 ── embedQuery(...) ─→ 去向量库里找最近的块

可以把它记成这样:

  • embedDocuments:给知识库建索引时用。
  • embedQuery:用户发起检索时用。

在项目里,这两个动作经常不会直接写在同一个文件里。更常见的是下面这种分开出现的样子:

typescript
// 这一步通常发生在「准备知识库」的脚本里。
// fromDocuments 内部会先对每个 Document 做文档 embedding。
const vectorStore = await MemoryVectorStore.fromDocuments(chunks, embeddings);

// 这一步通常发生在「用户提问」的运行时逻辑里。
// similaritySearch 内部会先把 question 做查询 embedding,再去比相似度。
const docs = await vectorStore.similaritySearch(question, 3);

所以你在业务代码里,很多时候看不到 embedDocuments(...)embedQuery(...) 同时出现。但底层做的事情并没有变:

  • 文档先被转成文档向量。
  • 查询再被转成查询向量。
  • 两边进入同一个向量空间做比较。

4. 向量库就是把这些块存起来,再负责检索

有了向量,还需要一个地方存它们。这个地方不只是「存」,还要支持「找出最接近的几条」。这就是向量库。

在教程里,先用 MemoryVectorStore 最合适。它的优点很直接:

  • 不需要额外部署。
  • 代码最短。
  • 方便先把流程跑通。
typescript
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
import { OpenAIEmbeddings } from "@langchain/openai";
import { Document } from "@langchain/core/documents";

const embeddings = new OpenAIEmbeddings({
  model: "text-embedding-3-small",
});

// fromDocuments 会把文档先做 embedding,
// 再把「文档 + 向量」一起放进内存向量库。
const vectorStore = await MemoryVectorStore.fromDocuments(
  [
    new Document({
      // 这里的正文,后面会参与向量检索。
      pageContent:
        "客户端用 appKey 和 timestamp 生成签名,服务端校验后返回 accessToken。",
      // metadata 不参与语义匹配,但后面可以拿来显示来源或分类。
      metadata: { source: "api-docs.pdf", topic: "auth" },
    }),
    new Document({
      pageContent: "默认每秒最多 100 次请求,超限返回 429。",
      metadata: { source: "api-docs.pdf", topic: "rate-limit" },
    }),
    new Document({
      pageContent: "accessToken 有效期为 2 小时,过期后需要重新申请。",
      metadata: { source: "api-docs.pdf", topic: "auth" },
    }),
  ],
  embeddings,
);

这里可以把它看成一步完成了两件事:

  1. 先把文档做 embedding。
  2. 再把向量放进内存向量库。

5. 先检索,再交给 Agent

这一步是整篇里最关键的位置。用户提问以后,不是让 Agent 自己去翻整份文档,而是:

  1. 先拿用户的问题去查向量库。
  2. 找到最相关的几个块。
  3. 再把这些块交给 Agent。
typescript
// 这里还是检索阶段,Agent 还没有开始回答。
const docs = await vectorStore.similaritySearch("怎么拿到 accessToken", 2);

docs.forEach((doc) => {
  // pageContent 是真正检索命中的正文。
  console.log(doc.pageContent);
  // metadata 方便你确认它是从哪份资料、哪个主题里取回来的。
  console.log(doc.metadata);
});

到这里,拿到的还是 Document[]。Agent 还没出场。但这一步已经把范围缩得很小了。原来 Agent 面对的是整份文档,现在它只需要看最相关的两三块。

6. 检索结果要整理一下,再喂给 Agent

下面这段代码把前面的几步串起来了:

  • 切好的文档块进向量库。
  • 用户提问时先检索。
  • 把检索结果拼成上下文。
  • 最后再交给 Agent。
typescript
import { createAgent } from "langchain";
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
import { OpenAIEmbeddings } from "@langchain/openai";
import { Document } from "@langchain/core/documents";

const embeddings = new OpenAIEmbeddings({
  model: "text-embedding-3-small",
});

// 这里先准备一份很小的知识库。
// 实际项目里,这些 Document 往往来自前面那篇的切块结果。
const vectorStore = await MemoryVectorStore.fromDocuments(
  [
    new Document({
      pageContent:
        "客户端用 appKey 和 timestamp 生成签名,服务端校验后返回 accessToken。",
      metadata: { source: "api-docs.pdf", topic: "auth" },
    }),
    new Document({
      pageContent: "accessToken 有效期为 2 小时,过期后需要重新申请。",
      metadata: { source: "api-docs.pdf", topic: "auth" },
    }),
    new Document({
      pageContent: "默认每秒最多 100 次请求,超限返回 429。",
      metadata: { source: "api-docs.pdf", topic: "rate-limit" },
    }),
  ],
  embeddings,
);

const agent = createAgent({
  model: "openai:gpt-4.1-mini",
  tools: [],
});

async function answerWithKnowledge(question: string) {
  // 1. 先检索最相关的文档块。
  const docs = await vectorStore.similaritySearch(question, 2);

  // 2. 再把检索结果拼成一段可读上下文。
  // 这里不直接把 Document[] 原样塞给 Agent,是为了让 system 更清楚。
  const context = docs
    .map((doc, index) => {
      return `资料 ${index + 1}:${doc.pageContent}`;
    })
    .join("\n\n");

  const result = await agent.invoke({
    messages: [
      {
        // system 负责告诉 Agent:哪些内容是参考资料,以及回答时要遵守什么边界。
        role: "system",
        content: `你是技术文档助手。

请优先根据下面的参考资料回答问题。

如果资料里没有明确答案,就直接说不知道,不要编造。

参考资料:

${context}`,
      },
      {
        // user 仍然是用户原始问题,不需要改写。
        role: "user",
        content: question,
      },
    ],
  });

  // 3. 最后一条消息就是本轮回答。
  return result.messages.at(-1)?.text ?? "";
}

const answer = await answerWithKnowledge("怎么拿到 accessToken?");

// 这里看到的结果,已经是「检索 + 回答」合起来后的最终输出。
console.log(answer);

这段代码里,每一层的职责要分开看:

  • OpenAIEmbeddings:负责把文本变成向量。
  • MemoryVectorStore:负责存向量和做相似度检索。
  • similaritySearch(...):负责先把候选块找出来。
  • agent.invoke(...):负责基于这些资料组织最终回答。

这样写以后,Agent 不需要「自己会检索」,它只需要吃已经缩小范围的上下文。

7. 相似度搜索和 MMR,先用哪一个

向量库最常见的检索方式有两种:

  • similaritySearch
  • maxMarginalRelevanceSearch

刚开始直接用 similaritySearch 就够了。

typescript
// 最直接的方式:只按相似度排序。
const similarDocs = await vectorStore.similaritySearch(
  "React 里怎么处理副作用",
  3,
);

// 如果你不想返回的 3 段资料都太像,可以试试 MMR。
const diverseDocs = await vectorStore.maxMarginalRelevanceSearch(
  "React 里怎么处理副作用",
  {
    // 最终返回 3 段。
    k: 3,
    // 先多取一些候选,再从里面挑更分散的结果。
    fetchK: 10,
    // 越接近 1 越偏向相关性,越接近 0 越偏向多样性。
    lambda: 0.5,
  },
);

两者区别可以简单记成:

  • similaritySearch:只看谁最像。
  • MMR:既看谁像,也尽量别让结果太重复。

如果你发现返回的 3 段资料总是在反复说同一件事,再试 MMR。前面先把链路跑通,参数后面再慢慢调。

8. 什么时候该换持久化向量库

MemoryVectorStore 很适合教程和本地调试,但它有一个很明显的限制:服务一重启,向量就没了。

所以流程跑通以后,下一步通常会换成真正可持久化的方案,比如:

  • Pinecone
  • Chroma
  • Cloudflare Vectorize

这一段先把迁移思路讲清楚就够了:

  1. 文档切块的代码不动。
  2. embedding 的代码基本不动。
  3. 主要变化发生在 vector store 这一层。

也就是说,前面那条「切块 -> embedding -> 检索 -> Agent」的链不需要重画,通常只是把中间那块存储后端换掉。

基于 MIT 协议开源