Skip to content
LangChain.js 检索增强生成:文档加载与向量检索
为什么模型需要外部知识
大语言模型在训练结束后,知识就固化了。如果直接问它某个训练截止日期之后的事件,它要么回复“不知道”,要么编造一段听起来合理但完全虚构的内容——这就是“幻觉”。为了让模型给出更可靠、时效性更强的答案,需要在生成回答之前先找到相关材料,再把这些材料作为上下文一并喂给模型。这套“先检索、后生成”的流程就是检索增强生成(Retrieval Augmented Generation,RAG)。
在 LangChain.js 的实现中,RAG 大致分三步:从不同的数据源加载文档,将文档转为向量存入向量库;根据用户查询从向量库中找到语义最接近的片段;把这些片段注入提示词,再交给模型生成最终回答。本文只关注以文档为知识的基础 RAG 链路,不涉及多轮对话记忆、多路召回重排等扩展。
文档加载:从文件到 Document 对象
数据源可以是纯文本、PDF、Markdown、网页等。LangChain 通过 DocumentLoader 把这些资料统一成 Document 结构:
ts
interface Document {
pageContent: string;
metadata: Record<string, any>;
}pageContent 是文档的主要文本内容,metadata 存放文件名、页码、标题等附加信息。接下来看两种最常见的 Loader。
加载纯文本文件
TextLoader 从本地文件系统读取 .txt 文件,每个文件生成一个 Document,元数据里通常带有 source 字段标明文件路径。
ts
import { TextLoader } from "langchain/document_loaders/fs/text";
const loader = new TextLoader("data/example.txt");
const docs = await loader.load();
console.log(docs[0]);
// Document {
// pageContent: 'LangChain 是用于构建 LLM 应用的开源框架。',
// metadata: { source: 'data/example.txt' }
// }处理 PDF 文档
对于 PDF,LangChain 内置了 PDFLoader(底层使用 pdf-parse),同样返回 Document 数组。一个 PDF 文件通常对应一个 Document,如果开启分页,可以每页生成一条。
ts
import { PDFLoader } from "langchain/document_loaders/fs/pdf";
const loader = new PDFLoader("docs/report.pdf", {
splitPages: false, // 全部页码合并为一个 Document
});
const docs = await loader.load();
console.log(docs[0].pageContent.slice(0, 100));
// 打印 PDF 文本的前 100 个字符PDF 文本提取质量依赖原始文件的结构。扫描件或带复杂表格的 PDF 可能丢失内容,必要时需要接入 OCR 或定制解析器。
文本切分:控制块大小与重叠
加载到 Document 之后,长文本通常会被切分成更小的文本块(chunks)。下游的 Embedding 模型有最大输入长度限制(例如 OpenAI 的 text-embedding-ada-002 是 8191 个 token),同时过长的文本会稀释语义,降低检索精度。LangChain 的 TextSplitter 系列负责这项工作。
分块策略
RecursiveCharacterTextSplitter 采用递归方式切割:先用较大的分隔符(如 "\n\n" 两个换行)试着切,如果切出来的块仍然超过长度上限,再降级使用更细的分隔符("\n"、" "、"")。这种做法尽量在段落或句子边界断句,避免把单词从中间劈开。
ts
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
const text = `第一章
段落 A 的内容...
段落 B 的内容...
第二章
另一段文字`;
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 100, // 每个块最多 100 个字符
chunkOverlap: 20, // 相邻块重叠 20 个字符
});
const chunks = await splitter.createDocuments([text]);
// 输出 Document 数组,pageContent 为切分后的块createDocuments 接收字符串数组,内部会自动创建包含原始文本和切分逻辑的 TextSplitter,返回的仍是 Document 列表。
参数说明
- chunkSize:块的大小,通常指字符数(可通过
lengthFunction改为 token 计数)。太小会丢失上下文,太大则降低检索的针对性。 - chunkOverlap:相邻块之间重叠的字符数。例如切到第 80 个字符时,下一块会从第 61 个字符开始。重叠可以防止关键信息恰好落在块边界而被截断。
- 分隔符:默认按
["\n\n", "\n", " ", ""]顺序尝试,也可通过separators选项自定义。如果内容用 Markdown 编写,可以额外插入"\n## "等标题分隔符,优先在章节边界切分。
调整这些参数通常需要观察实际文档结构和检索效果才能确定。
向量化:将文本映射到语义空间
切分后的文本块不能直接用字符串相似度匹配,需要转换成稠密向量。LangChain 的 Embeddings 抽象了这个过程,所有嵌入模型都实现了 embedDocuments 和 embedQuery 两个方法:
ts
import { OpenAIEmbeddings } from "@langchain/openai";
const embeddings = new OpenAIEmbeddings({
openAIApiKey: process.env.OPENAI_API_KEY,
});
const vectors = await embeddings.embedDocuments([
"LangChain 用于构建 LLM 应用",
"检索增强生成先搜索再回答",
]);
// vectors 为 [number[], number[]],每个向量长度为模型输出维度embedQuery 用于把查询转为向量,以便后续在向量库中做相似性搜索。
向量存储与相似性搜索
有了文本块对应的向量,接下来要存入向量数据库,并为后续查询提供语义搜索能力。LangChain 提供了多种 VectorStore 实现,其中 MemoryVectorStore 将向量存在内存中,适合快速原型开发。
写入与查询
ts
import { MemoryVectorStore } from "langchain/vectorstores/memory";
// 从 Document 数组直接构建
const vectorStore = await MemoryVectorStore.fromDocuments(
chunks,
new OpenAIEmbeddings()
);
const results = await vectorStore.similaritySearch("什么是 LangChain", 2);similaritySearch 默认使用余弦相似度,第二个参数 k 指定返回的文档数量。
返回的数据结构
搜索结果是一个 Document[],和输入时结构一致:
ts
console.log(results[0]);
// Document {
// pageContent: 'LangChain 是用于构建 LLM 应用的开源框架。',
// metadata: { ... }
// }metadata 中可能包含 loc(行号)等信息,来自切分器添加的元数据。如果要获取相似度分数,需使用 similaritySearchWithScore,它会返回 [Document, number] 元组数组。
检索器:从向量库到上下文
Retriever 是 LangChain 的检索接口,它封装了向量库或搜索引擎,对外暴露统一的 invoke 方法,返回 Document[]。
创建检索器
每个 VectorStore 实例都可以通过 asRetriever() 转为检索器:
ts
const retriever = vectorStore.asRetriever({
k: 3, // 返回相关文档数量
});
const retrievedDocs = await retriever.invoke("LangChain 的用途");invoke 接收查询字符串,输出结构与 similaritySearch 完全一致。还可以为检索器添加过滤、自定义搜索类型等配置。
字段说明
返回的 Document 中,pageContent 是文本内容,metadata 包含来源信息。后续步骤会把这些内容格式化后注入提示词。
组装 RAG 链:检索 + 增强 + 生成
RAG 链需要把检索到的文档转变为可供模型阅读的上下文字符串,再与用户问题一起送入 ChatModel。
格式化上下文
检索器返回的是一组 Document,不能直接拼进提示词。编写一个转换函数:
ts
const formatDocumentsAsString = (documents: Document[]) => {
return documents.map(doc => doc.pageContent).join("\n\n");
};使用 LCEL 串联组件
借助 LCEL,把检索、格式化、填充提示词、调用模型串成一条链:
ts
import { ChatOpenAI } from "@langchain/openai";
import { PromptTemplate } from "@langchain/core/prompts";
import { RunnableSequence, RunnablePassthrough } from "@langchain/core/runnables";
const prompt = PromptTemplate.fromTemplate(
`根据以下已知信息回答问题。如果无法从中得出答案,请说"不知道"。
已知信息:
{context}
问题:
{question}
回答:`
);
const model = new ChatOpenAI({ modelName: "gpt-3.5-turbo" });
const ragChain = RunnableSequence.from([
{
context: retriever.pipe(formatDocumentsAsString),
question: new RunnablePassthrough(),
},
prompt,
model,
]);RunnablePassthrough 把用户输入(即问题)原样传递给 question 字段,retriever.pipe(formatDocumentsAsString) 则把检索到的文档拼接成 context 字符串。两者合并后传入 prompt,最后交给 model 生成答案。
链的类型是 Runnable<string, MessageContent>,invoke 方法接收用户问题字符串,返回模型消息结构。
示例:一个完整的问答流程
下面把上述步骤整合在一起。假设项目根目录下有一个 data/langchain.txt,内容为 LangChain 的简介。
ts
import { TextLoader } from "langchain/document_loaders/fs/text";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import { MemoryVectorStore } from "langchain/vectorstores/memory";
import { OpenAIEmbeddings, ChatOpenAI } from "@langchain/openai";
import { PromptTemplate } from "@langchain/core/prompts";
import { RunnableSequence, RunnablePassthrough } from "@langchain/core/runnables";
async function runRAG() {
// 1. 加载文档
const loader = new TextLoader("data/langchain.txt");
const rawDocs = await loader.load();
// 2. 切分文档
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 50,
});
const chunks = await splitter.splitDocuments(rawDocs);
// 3. 生成嵌入并存入向量库
const embeddings = new OpenAIEmbeddings();
const vectorStore = await MemoryVectorStore.fromDocuments(chunks, embeddings);
// 4. 创建检索器
const retriever = vectorStore.asRetriever(3);
// 5. 定义格式化函数
const formatDocs = (docs) => docs.map(d => d.pageContent).join("\n\n");
// 6. 构建 RAG 链
const prompt = PromptTemplate.fromTemplate(
`根据以下已知信息回答问题。如果无法从中得出答案,请说"不知道"。
已知信息:
{context}
问题:
{question}
回答:`
);
const model = new ChatOpenAI({ modelName: "gpt-3.5-turbo" });
const chain = RunnableSequence.from([
{
context: retriever.pipe(formatDocs),
question: new RunnablePassthrough(),
},
prompt,
model,
]);
// 7. 提问
const response = await chain.invoke("LangChain 的核心概念是什么?");
console.log(response.content);
}
runRAG().catch(console.error);运行可能输出:
LangChain 的核心概念包括 Chains、Agents、Tools 和 Memory。
Chains 负责组合多个步骤,Agents 让 LLM 自主选择工具……回答内容完全来源于本地文件,不再依赖模型自己记住的知识。如果询问文档中不存在的细节,模型会按提示词规则回答“不知道”。
注意点
- chunkSize 与模型 token 限制:Embedding 模型有最大输入长度,超出会被截断,信息丢失。设置 chunkSize 时需考虑模型上限。
- 重叠的必要性:不加 chunkOverlap 时,关键句子可能被分割在两块之间,导致检索时无法完整命中。但重叠过多会引入冗余,降低检索精度。
- MemoryVectorStore 的持久化:数据只存在于内存,进程重启后消失。若需长期保存,可替换为 Chroma、Pinecone 等持久化向量数据库。
- 检索结果不相关:相似度搜索是语义匹配,不保证结果一定包含答案。实际应用中可能需要设置相似度阈值或结合关键词过滤。
- PDFLoader 依赖:
PDFLoader需要pdf-parse包,安装时注意原生模块编译问题;大文件或扫描版 PDF 效果差,需特殊处理。 - 密钥与成本:使用 OpenAI Embedding 和 Chat API 会产生费用,开发阶段可考虑 local embedding 模型或缓存向量以降低成本。
小结
到这里,你获得了一个完整的 RAG 基础链路:加载本地文档,切分成块,生成向量,存入向量库,通过检索器获取上下文,最后由大模型生成答案。整个链用 LCEL 串联,各个组件可以独立替换或扩展。
