Nodejs也能写Agent - 13.LangChain篇 - RAG从入门到避坑(完整版)

这一篇讲 RAG(Retrieval-Augmented Generation,检索增强生成),说白了就是给模型外挂一个「随身知识库」:回答前先去检索相关资料,再基于资料作答。
我得先泼一盆冷水:网上那种「二十行代码搞定 RAG」的教程,做出来的都是玩具。加载文档 → 切块 → 向量化 → 检索 → 塞进 Prompt,这套「朴素 RAG」确实二十行就能跑通。但你真拿它上线,会被现实按在地上摩擦——检索检不准、答案还是瞎编、专有名词死活搜不到、多轮对话一问指代就崩……
所以这篇我不打算只教你跑通 demo。我会先带你把朴素 RAG 跑起来,然后逐个拆解它在真实场景里会踩的坑,以及每个坑对应的解决方案。这才是 RAG 真正有「可比性」的地方——能不能做好,全在这些细节里。
老规矩,本文所有 API 都以官网最新文档核对过(https://docs.langchain.com/oss/javascript)。RAG 相关的进阶检索器很多,我会标清楚每个的 JS 导入路径。
一、先把朴素 RAG 跑通
RAG 分两个阶段,一定要先在脑子里分清楚:
- 离线索引阶段(建库,一次性):加载文档 → 切分成 chunk → 向量化 → 存进向量库。
- 在线查询阶段(每次提问):问题向量化 → 检索最相似的 chunk → 拼进 Prompt → 交给模型生成答案。
RAG 有五个核心零件,我们挨个过一遍,最后拼成一条链。
1. Document Loader:把各种数据源变成统一的 Document
不管来源是 txt、PDF 还是网页,LangChain 都把它们统一成 Document 对象:
interface Document {
pageContent: string; // 正文
metadata: Record<string, unknown>; // 来源、页码、标题等元数据(后面大有用处)
}
常用 Loader 大多来自 @langchain/community,比如 TextLoader(txt)、PDFLoader(PDF)、CheerioWebBaseLoader(网页)。教程里为了省事,也可以直接手搓内存 Document:
import { Document } from "@langchain/core/documents";
const docs = [
new Document({
pageContent: "LangChain.js 是 Node.js 生态里的 LLM 应用框架。",
metadata: { source: "intro" },
}),
];
别小看
metadata。后面讲「元数据过滤」时你会发现,来源、日期、分类这些字段是精准检索的关键。建库时能塞的元数据尽量塞。
2. Text Splitter:把长文切成 chunk
模型的上下文窗口有限,整篇文档塞不进去,得切成小块。最常用的是 RecursiveCharacterTextSplitter,它会按「段落 → 句子 → 字符」递归地切,尽量不把一句话拦腰砍断:
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 500, // 每块最大字符数
chunkOverlap: 50, // 相邻块的重叠字符数
});
const chunks = await splitter.splitDocuments(docs);
chunkOverlap(重叠)是为了防止关键信息正好被切在两块的边界上而丢失语义。
记住这句话,后面第三节要用它开刀:「无脑按固定字符数切」是朴素 RAG 最大的质量瓶颈之一。 chunk 怎么切,几乎直接决定了检索的天花板。
3. Embeddings:把文本变成向量
Embedding 模型把文本转成高维向量(比如 768 维),语义相近的文本,向量距离也近。本地方案我用 Ollama 的 nomic-embed-text(记得先 ollama pull nomic-embed-text):
import { OllamaEmbeddings } from "@langchain/ollama";
const embeddings = new OllamaEmbeddings({
model: "nomic-embed-text",
baseUrl: "http://localhost:11434",
});
铁律:索引和查询必须用同一个 embedding 模型。 换了模型,整个向量库就得重建,否则向量空间对不上,检索结果全是乱的。
4. Vector Store + Retriever:存起来,再搜出来
Vector Store 存向量、做相似度搜索。教程阶段用内存版 MemoryVectorStore(零配置、重启即丢);生产环境要换持久化的(本文第五节讲 pgvector)。
Retriever 是向量库对外的检索接口:给它一个问题字符串,还你 Top-K 个最相关的 Document:
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
const vectorStore = await MemoryVectorStore.fromDocuments(chunks, embeddings);
const retriever = vectorStore.asRetriever({ k: 3 }); // 取最相似的 3 块
MemoryVectorStore的导入路径在不同版本里可能是@langchain/classic/vectorstores/memory或langchain/vectorstores/memory,以你装的版本为准。
k 值是个需要权衡的旋钮:太小容易漏掉关键信息,太大又会把噪声一起塞进上下文。一般 3~5 起步。
5. RAG Chain:用 LCEL 串起来
最后用 LCEL 把「检索 → 拼 Prompt → 模型 → 解析」串成一条链:
import {
RunnableSequence,
RunnablePassthrough,
} from "@langchain/core/runnables";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { ChatOllama } from "@langchain/ollama";
import type { Document } from "@langchain/core/documents";
const llm = new ChatOllama({ model: "qwen2.5:7b", temperature: 0 });
// 把检索到的 Document[] 拼成一段 context 文本
const formatDocs = (docs: Document[]) =>
docs.map((d) => d.pageContent).join("\n\n");
const ragPrompt = ChatPromptTemplate.fromMessages([
[
"system",
"你是一个严谨的问答助手。只依据下面提供的上下文回答;如果上下文里没有答案,就直说「根据现有资料我无法回答」,不要编造。",
],
["human", "上下文:\n{context}\n\n问题:{question}"],
]);
const ragChain = RunnableSequence.from([
{
context: retriever.pipe(formatDocs),
question: new RunnablePassthrough(),
},
ragPrompt,
llm,
new StringOutputParser(),
]);
const answer = await ragChain.invoke("LangChain.js 是什么?");
console.log(answer);
到这,一条能跑的 RAG 就有了。但它只是及格线。 下面才是重头戏。
二、朴素 RAG 为什么不够用:真实场景的九个坑
你把上面那套跑通、信心满满地拿去接真实文档,大概率会遇到下面这些问题。我按「发生在哪一环」帮你归好类:
检索环节(最容易翻车):
- 语义漏召回:问题和文档用词不同,向量相似度不高,明明有答案却没检索到。
- 关键词/专有名词搜不到:向量检索擅长「意思相近」,但对精确的 ID、编号、产品型号、人名这类 token,反而不如老式关键词匹配。这是纯向量 RAG 的致命短板。
- 排序不对:Top-K 里其实有正确答案,但它排在第 8 位,而你只取了前 3 个,白白漏掉。
- 问题表述 ≠ 文档表述:用户问「怎么退货」,文档里写的是「售后与退换流程」,语义有距离。
- 多轮对话指代崩溃:用户先问「LangChain 是什么」,接着问「它和 Mastra 比呢」——直接拿「它和 Mastra 比呢」去检索,啥也搜不到。
切分环节:
- chunk 切得太糙:固定字符数硬切,经常把一个完整语义(比如一段代码、一张表、一个论证)切成两半,检索到半截等于没检索到。
生成环节:
- 上下文又长又杂:为了提高召回把
k调大,结果塞进去一堆不相关内容,既浪费 token,又干扰模型(俗称「大海捞针」失败)。 - 依然幻觉 / 不肯认怂:检索到的资料不相关时,模型不说「我不知道」,而是硬编一个像模像样的答案。
- 无法溯源:模型给了答案,但你不知道它是根据哪篇文档说的,没法核实,企业场景根本不敢用。
还有一个隐藏的大坑:10. 没有评估。 你调了一堆参数,到底是变好了还是变差了?没有量化指标,全靠感觉,那就是在瞎调。
看到没——朴素 RAG 能不能「跑起来」和能不能「用得好」,中间隔着这十个坑。下面我们一层一层把它们填上。
三、分层解法:把每个坑填上
业界(2026 年)比较成熟的做法,是把 RAG 拆成几个「可插拔的层」,每一层针对性地解决上面的问题。你不用一次全上,按需叠加即可。
第 1 层:更聪明的分块(治「chunk 切得太糙」)
固定字符切分是 baseline,但真实文档要更讲究:
- 结构感知切分:Markdown、代码、HTML 用对应的专用 splitter(如
RecursiveCharacterTextSplitter.fromLanguage("markdown")),沿着标题、函数边界切,别把结构切碎。 - 按文档类型调
chunkSize:FAQ 短问答可以切小(200300),长篇论述适当切大(8001000)。没有万能值。 - Parent-Child(父子文档):这是我最推荐的一招——用小 chunk 去检索(精准),但返回给模型的是它所在的大 chunk / 整篇父文档(上下文完整)。既保证检索命中率,又不丢上下文。LangChain.js 直接有现成的
ParentDocumentRetriever:
import { ParentDocumentRetriever } from "langchain/retrievers/parent_document";
import { InMemoryStore } from "@langchain/core/stores";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
const retriever = new ParentDocumentRetriever({
vectorstore: vectorStore, // 存小 chunk 的向量
byteStore: new InMemoryStore(), // 存父文档
parentSplitter: new RecursiveCharacterTextSplitter({ chunkSize: 1000 }),
childSplitter: new RecursiveCharacterTextSplitter({ chunkSize: 200 }),
});
await retriever.addDocuments(docs);
// 检索命中小 chunk,但返回它所属的大父块,上下文更完整
const results = await retriever.invoke("你的问题");
第 2 层:混合检索 Hybrid Search(治「语义漏召回」「关键词/专有名词搜不到」)
这是提升检索质量性价比最高的一招。核心思想:向量检索(懂语义)+ 关键词检索 BM25(抓精确词)两条腿走路,再把结果融合。
- 向量检索:召回「意思相近」的(解决语义漏召回)。
- BM25(稀疏检索):召回「词对得上」的,专治 ID、型号、专有名词(解决关键词/专有名词搜不到)。
LangChain.js 里用 BM25Retriever + EnsembleRetriever(内部用 RRF 倒数排名融合算法合并两路结果):
import { BM25Retriever } from "@langchain/community/retrievers/bm25";
import { EnsembleRetriever } from "langchain/retrievers/ensemble";
const vectorRetriever = vectorStore.asRetriever({ k: 10 });
const bm25Retriever = BM25Retriever.fromDocuments(chunks, { k: 10 });
const hybridRetriever = new EnsembleRetriever({
retrievers: [vectorRetriever, bm25Retriever],
weights: [0.6, 0.4], // 两路权重,按你的数据调
});
const results = await hybridRetriever.invoke("LangChain v0.3 的破坏性变更");
中文场景注意:BM25 依赖分词,默认的英文分词对中文不友好。中文文档要么先做中文分词预处理,要么更依赖向量那一路(调高向量权重)。这是很多人做中文 RAG 忽略的点。
第 3 层:查询改写(治「问题表述 ≠ 文档表述」「多轮对话指代崩溃」)
问题不好,检索再强也白搭。在检索之前先把用户的问题「翻译」得更适合检索:
- Multi-Query(多查询):让 LLM 把用户问题改写成 3~5 个不同说法,分别检索再合并,大幅提高召回。LangChain.js 有
MultiQueryRetriever:
import { MultiQueryRetriever } from "langchain/retrievers/multi_query";
const mqRetriever = MultiQueryRetriever.fromLLM({
llm,
retriever: vectorStore.asRetriever({ k: 4 }),
});
- HyDE(假设性文档嵌入):先让 LLM「脑补」一个假想答案,再拿这个假想答案去检索(因为「答案」和「文档」的表述更接近,而不是「问题」和「文档」)。JS 里有
HydeRetriever。 - 多轮对话的 query 重构(history-aware):这是解决多轮对话指代崩溃的关键。把「它和 Mastra 比呢」结合前面的历史,先重写成一个独立完整的问题「LangChain 和 Mastra 相比怎么样」,再拿去检索。做法就是在检索前加一个「带历史的改写」小链。
第 4 层:重排 Reranking(治「排序不对」「上下文又长又杂」)
第一轮检索追求「召回」(宁可多抓,k 设大一点,比如 50),然后用一个更精准的 reranker(交叉编码器) 对这几十个候选重新打分,只把最相关的 top 5~10 交给模型。
为什么有效?向量检索是「问题」和「文档」各自独立编码再比距离,丢失了两者的交互信息;而 reranker 把「问题 + 文档」一起喂进模型算真实相关性,精度高得多。
LangChain.js 用 ContextualCompressionRetriever 包一层压缩/重排器:
import { ContextualCompressionRetriever } from "langchain/retrievers/contextual_compression";
// 云端可用 Cohere Rerank:import { CohereRerank } from "@langchain/cohere";
const compressionRetriever = new ContextualCompressionRetriever({
baseCompressor: reranker, // 如 CohereRerank,或本地的 EmbeddingsFilter / LLMChainExtractor
baseRetriever: hybridRetriever, // 前面的混合检索作为第一轮
});
想全程本地、不接云服务?可以用
EmbeddingsFilter(按相似度阈值过滤)或LLMChainExtractor(让 LLM 抽出每个 chunk 里真正相关的句子)当压缩器。效果不如专用 reranker,但零外部依赖。
第 5 层:元数据过滤(治检索范围问题)
还记得第一节强调的 metadata 吗?很多检索其实带明确约束:「只在 2024 年之后的文档里找」「只查产品 A 的手册」。这时候纯语义检索是浪费——先按元数据过滤,再在子集里做向量检索,又快又准:
const retriever = vectorStore.asRetriever({
k: 4,
filter: { source: "product-A-manual", year: 2024 }, // 具体语法取决于向量库
});
进阶还有 SelfQueryRetriever:让 LLM 自动从用户的自然语言里解析出「过滤条件 + 检索词」(比如把「2024 年关于退货的规定」拆成 filter: {year:2024} + query「退货规定」)。
第 6 层:生成阶段的 grounding(治「依然幻觉/不肯认怂」「无法溯源」)
检索做好了,最后一步别在生成环节翻车:
- 强约束 Prompt:明确要求「只依据上下文回答,没有就说不知道」(前面基础示例里已经这么写了,这是底线)。
- 强制引用来源:让模型在答案里标注它用了哪些 chunk(利用
metadata.source),既能溯源,又能反过来抑制幻觉。可以配合上一篇的结构化输出,让模型返回{ answer, sources }。 - 拒答机制:结合 reranker 的分数,如果最高分都低于阈值,直接返回「资料不足」,别硬答。
第 7 层:评估(治「没有评估」,最容易被忽略但最重要)
不做评估的 RAG 优化,等于闭着眼睛开车。 你得能量化「这次改动到底有没有变好」。常用两类指标:
- 检索质量:
Recall@k(该找到的找到没)、nDCG@k/MRR(排序好不好)。 - 生成质量:
faithfulness(答案是否忠于上下文、有没有编)、answer relevance(切不切题)。
工程上,建一个几十到几百条的「问题 + 标准答案」测试集,每次改动都跑一遍回归。LangChain 生态里可以用 LangSmith 做数据集管理、追踪和自动评估(还记得第一篇说的「LCEL 天然可观测」吗?就是接这个的)。
四、生产化:别再用内存向量库了
教程用 MemoryVectorStore 图个方便,但它进程一重启数据就全没了,绝不能上生产。生产环境要换持久化的向量库。对 Node.js 后端来说,PostgreSQL + pgvector 是我的首选——因为你的业务数据大概率已经在 Postgres 里了,向量和业务数据同库,省一套运维:
import { PGVectorStore } from "@langchain/community/vectorstores/pgvector";
const vectorStore = await PGVectorStore.initialize(embeddings, {
postgresConnectionOptions: { connectionString: process.env.DATABASE_URL },
tableName: "documents",
columns: { contentColumnName: "content", vectorColumnName: "embedding" },
});
await vectorStore.addDocuments(chunks); // 持久化写入
const retriever = vectorStore.asRetriever({ k: 4 });
换库之后,前面所有的检索器(Ensemble、ParentDocument、Rerank……)都能无缝套上去——这又是「一切皆 Runnable / Retriever」的红利。其它可选:Chroma、Qdrant、Pinecone、Weaviate 等,@langchain/community 里都有集成。
生产化还要考虑:增量更新(文档变了怎么只更新受影响的 chunk,而不是全量重建)、成本与延迟权衡(多查询 + rerank 会增加 LLM 调用和延迟,要按业务取舍)、缓存(高频问题缓存结果)。
五、该上哪些层?给你三套组合
别一上来就把七层全堆上,按阶段来:
| 阶段 | 组合 | 适用 |
|---|---|---|
| 起步 | 固定切分 + 向量检索 + 强约束 Prompt | 跑通、内部 demo |
| 进阶 | Parent-Child 切分 + 混合检索(向量+BM25) + Multi-Query + 引用来源 | 大多数中文企业知识库,性价比之选 |
| 生产 | 上面全部 + Rerank + 元数据过滤 + pgvector 持久化 + LangSmith 评估回归 | 对准确率、可溯源要求高的正式产品 |
一条完整的进阶流水线,跑起来大概是这样:
用户问题
→ 多轮改写成独立问题(history-aware)
→ Multi-Query 生成多个变体
→ 混合检索(向量 + BM25,RRF 融合)取 top 50
→ Rerank 重排,压到 top 5~10
→ 拼进强约束 Prompt(要求引用来源、允许拒答)
→ LLM 生成带 sources 的答案
→ (离线)用测试集持续评估
到这里,LangChain 的核心能力(Runnable、模型与 Prompt、Tools、RAG)就全部打通了。你已经具备了做一个「会调工具、能查知识库」的 AI 应用的全部零件。
更多推荐



所有评论(0)