本文是「从零搭建私人 RAG 知识库」专栏的基础篇。 学习 LangChain 时,最容易陷入"API 很多,但不知道每个对象处在什么位置"的困境。本文不讨论复杂架构,也不展开实际项目,只用一个最小示例认识
Document、文本分块、Embedding、VectorStore 和 Retriever。
前言
LangChain 是一个用于开发大模型应用的框架。它提供了很多标准化接口,帮助我们连接文档、模型、向量数据库和检索逻辑。
在一个基础 RAG 应用中,最常见的流程是:
text
原始文本
↓
Document
↓
文本分块
↓
Embedding
↓
VectorStore
↓
Retriever
↓
与问题相关的文档片段
本文会先解释这 5 个对象,再用一个可以直接运行的 TypeScript 示例把它们串起来。
一、先认识 5 个核心抽象
| 抽象 | 作用 | 可以简单理解为 |
|---|---|---|
Document |
统一表示文本和元数据 | 一条带标签的文本 |
| Text Splitter | 把长文本切成较小片段 | 文本切片器 |
| Embeddings | 把文本转换成数字向量 | 文本的语义坐标 |
| VectorStore | 保存向量并执行相似度搜索 | 支持语义搜索的仓库 |
| Retriever | 用统一接口获取相关文档 | 面向应用的检索器 |
理解 LangChain 的关键不是记住所有类名,而是知道数据如何在这些对象之间流动。
二、Document:LangChain 中的文档对象
LangChain 使用 Document 表示一段文本。
一个 Document 主要包含两个字段:
pageContent:文档正文;metadata:文档的附加信息。
ts
import { Document } from "@langchain/core/documents";
const document = new Document({
pageContent: "TypeScript 是带有类型系统的 JavaScript。",
metadata: {
source: "typescript-intro",
category: "programming",
},
});
其中,metadata 可以保存来源、标题、分类、时间等信息。检索完成后,这些信息可以帮助我们定位原文。
需要注意,Document 只是 LangChain 对文本的统一表示,它本身不负责读取文件,也不负责生成向量。
三、文本分块:为什么不能直接处理整篇文章?
如果一篇文章很长,并且同时包含多个主题,直接为整篇文章生成一个向量,会让它的语义过于宽泛。
因此,RAG 通常会先把长文档切成多个较小的 Chunk:
text
一篇长文档
↓
片段 1:TypeScript 的定义
片段 2:TypeScript 的类型检查
片段 3:TypeScript 的编译方式
LangChain 提供了 RecursiveCharacterTextSplitter。它会优先尝试在段落、换行和空格等位置切分文本,尽量避免直接从一句话中间截断。
ts
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 100,
chunkOverlap: 20,
});
const chunks = await splitter.splitDocuments([document]);
两个常用参数分别是:
chunkSize:每个片段的目标大小;chunkOverlap:相邻片段之间保留的重叠内容。
重叠内容可以减少上下文恰好被切断的问题,但重叠过大也会增加存储量,并让检索结果出现重复。
对于不同语言、模型和文档类型,合适的分块参数并不相同。入门阶段先使用一个简单配置即可,后续再根据检索效果调整。
四、Embedding:把文本转换成向量
计算机不能直接比较两段自然语言的语义,因此需要使用 Embedding 模型把文本转换成一组数字。
text
"TypeScript 如何进行类型检查?"
↓
[0.12, -0.37, 0.88, ..., 0.24]
含义相近的文本,其向量通常也更加接近。向量检索正是利用了这一点。
LangChain 将不同厂商的 Embedding 模型封装成相似的接口。本文使用 Ollama 在本地运行 Embedding 模型:
ts
import { OllamaEmbeddings } from "@langchain/ollama";
const embeddings = new OllamaEmbeddings({
model: "nomic-embed-text",
baseUrl: "http://localhost:11434",
});
Embedding 对象常见的两个方法是:
ts
await embeddings.embedDocuments(["第一段文本", "第二段文本"]);
await embeddings.embedQuery("用户的问题");
embedDocuments()用于批量处理待索引的文档;embedQuery()用于处理用户查询。
索引文档和查询时必须使用兼容的 Embedding 模型。更换模型后,通常需要重新生成并保存全部文档向量。
五、VectorStore:保存向量并执行语义搜索
VectorStore 用来保存以下内容:
- 文档正文;
- 文档元数据;
- Embedding 模型生成的向量。
本文使用 MemoryVectorStore。它把数据保存在内存中,不需要安装数据库,适合学习和测试:
ts
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
const vectorStore = await MemoryVectorStore.fromDocuments(
chunks,
embeddings,
);
创建完成后,可以直接执行相似度搜索:
ts
const results = await vectorStore.similaritySearch(
"TypeScript 有什么作用?",
2,
);
第二个参数 2 表示最多返回两个相关文档。
需要注意,MemoryVectorStore 在程序退出后不会保留数据。正式应用通常会换成 LanceDB、Chroma、Milvus 或其他支持持久化的向量存储。
六、Retriever:统一检索入口
VectorStore 负责保存和搜索向量,Retriever 则提供了一个更统一的文档检索接口。
可以把一个 VectorStore 转换成 Retriever:
ts
const retriever = vectorStore.asRetriever({
k: 2,
});
const documents = await retriever.invoke(
"TypeScript 如何提前发现错误?",
);
Retriever 返回的是 Document[],而不是大模型生成的最终答案。
它只负责:
根据用户问题,找到最相关的文档片段。
后续如果要实现完整 RAG,还需要把这些片段和用户问题一起交给大模型。
七、一个可以运行的最小案例
下面把前面的步骤组合起来。
1. 安装依赖
创建一个 TypeScript 项目并安装依赖:
bash
npm init -y
npm install @langchain/core @langchain/textsplitters @langchain/ollama @langchain/classic
npm install -D typescript tsx @types/node
确保本地已经启动 Ollama,然后下载 Embedding 模型:
bash
ollama pull nomic-embed-text
2. 编写示例
新建 index.ts:
ts
import { Document } from "@langchain/core/documents";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { OllamaEmbeddings } from "@langchain/ollama";
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
// 1. 准备文档
const documents = [
new Document({
pageContent: `
TypeScript 是 JavaScript 的超集,它为 JavaScript 增加了静态类型系统。
开发者可以在编码阶段发现部分类型错误,而不必等到程序运行后再处理。
TypeScript 代码在运行前会被编译成 JavaScript。
`.trim(),
metadata: {
source: "typescript-intro",
},
}),
new Document({
pageContent: `
Node.js 是一个 JavaScript 运行时。
它允许开发者在浏览器之外运行 JavaScript,常用于开发服务端应用、命令行工具和构建工具。
`.trim(),
metadata: {
source: "nodejs-intro",
},
}),
];
// 2. 将文档切成较小片段
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 80,
chunkOverlap: 20,
});
const chunks = await splitter.splitDocuments(documents);
// 3. 创建 Embedding 模型
const embeddings = new OllamaEmbeddings({
model: "nomic-embed-text",
baseUrl: "http://localhost:11434",
});
// 4. 将文档片段写入内存向量库
const vectorStore = await MemoryVectorStore.fromDocuments(
chunks,
embeddings,
);
// 5. 创建 Retriever,每次最多返回两个片段
const retriever = vectorStore.asRetriever({
k: 2,
});
// 6. 使用自然语言检索
const results = await retriever.invoke(
"哪种技术可以在编码阶段发现类型错误?",
);
for (const [index, document] of results.entries()) {
console.log(`\n结果 ${index + 1}`);
console.log(`来源:${document.metadata.source}`);
console.log(`内容:${document.pageContent}`);
}
3. 运行代码
bash
npx tsx index.ts
程序会经历以下过程:
text
Document[]
↓ splitDocuments()
Chunk[]
↓ Embedding
向量
↓ MemoryVectorStore
内存向量库
↓ Retriever.invoke()
相关 Document[]
对于问题"哪种技术可以在编码阶段发现类型错误?",Retriever 应优先返回介绍 TypeScript 的片段,而不是介绍 Node.js 的片段。
这个结果说明,向量检索并不要求问题和原文使用完全相同的句子。只要语义足够接近,相关内容就有机会被召回。
八、VectorStore 和 Retriever 有什么区别?
初学者经常混淆这两个概念。
| 对比项 | VectorStore | Retriever |
|---|---|---|
| 核心职责 | 保存向量并进行相似度搜索 | 根据输入返回相关文档 |
| 是否一定使用向量 | 是 | 不一定 |
| 常用调用 | similaritySearch() |
invoke() |
| 返回内容 | 相关文档 | 相关文档 |
Retriever 是更上层的抽象。它的底层可以是向量数据库,也可以是关键词搜索、数据库查询,或者多种检索方式的组合。
因此,在应用层使用 Retriever,可以降低业务代码对具体向量数据库的依赖。
九、几个常见问题
1. Retriever 会直接生成答案吗?
不会。Retriever 只返回相关文档。生成自然语言答案还需要调用聊天模型或大语言模型。
2. Embedding 模型和聊天模型一样吗?
不一样。
- Embedding 模型负责把文本转换成向量;
- 聊天模型负责理解上下文并生成文本。
3. chunkSize 越大越好吗?
不是。片段过大可能包含多个主题,降低检索精度;片段过小又可能丢失必要上下文。需要根据实际文档和问题进行测试。
4. 为什么要保存 metadata?
metadata 可以记录来源、标题和分类。检索完成后,我们可以用它展示引用来源,或者对检索范围进行过滤。
5. 为什么示例使用 MemoryVectorStore?
因为它不需要额外安装和配置数据库,最适合展示完整流程。但它不会持久化数据,不适合直接用于正式环境。
总结
本文用一个最小案例介绍了 LangChain 在 RAG 中最常见的 5 个抽象:
Document统一表示正文和元数据;- Text Splitter 把长文档切成较小片段;
- Embeddings 把文本转换成向量;
- VectorStore 保存向量并执行相似度搜索;
- Retriever 用统一接口返回相关文档。
再次回顾这条基础链路:
text
原始文本
↓
Document
↓
文本分块
↓
Embedding
↓
VectorStore
↓
Retriever
↓
相关文档片段
只要理解了这条数据流,就掌握了 LangChain 文档检索部分最重要的基础。之后无论替换 Embedding 模型、向量数据库,还是接入大模型生成答案,本质上都是在这条链路上继续扩展。