在这里插入图片描述

这一篇讲 RAG(Retrieval-Augmented Generation,检索增强生成),说白了就是给模型外挂一个「随身知识库」:回答前先去检索相关资料,再基于资料作答。

我得先泼一盆冷水:网上那种「二十行代码搞定 RAG」的教程,做出来的都是玩具。加载文档 → 切块 → 向量化 → 检索 → 塞进 Prompt,这套「朴素 RAG」确实二十行就能跑通。但你真拿它上线,会被现实按在地上摩擦——检索检不准、答案还是瞎编、专有名词死活搜不到、多轮对话一问指代就崩……

所以这篇我不打算只教你跑通 demo。我会先带你把朴素 RAG 跑起来,然后逐个拆解它在真实场景里会踩的坑,以及每个坑对应的解决方案。这才是 RAG 真正有「可比性」的地方——能不能做好,全在这些细节里。

老规矩,本文所有 API 都以官网最新文档核对过(https://docs.langchain.com/oss/javascript)。RAG 相关的进阶检索器很多,我会标清楚每个的 JS 导入路径。

一、先把朴素 RAG 跑通

RAG 分两个阶段,一定要先在脑子里分清楚:

  • 离线索引阶段(建库,一次性):加载文档 → 切分成 chunk → 向量化 → 存进向量库。
  • 在线查询阶段(每次提问):问题向量化 → 检索最相似的 chunk → 拼进 Prompt → 交给模型生成答案。

在线查询

离线索引

原始文档

DocumentLoader

TextSplitter

Embeddings

VectorStore

用户问题

Retriever

Prompt 拼接 context

ChatOllama

回答

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/memorylangchain/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 为什么不够用:真实场景的九个坑

你把上面那套跑通、信心满满地拿去接真实文档,大概率会遇到下面这些问题。我按「发生在哪一环」帮你归好类:

检索环节(最容易翻车):

  1. 语义漏召回:问题和文档用词不同,向量相似度不高,明明有答案却没检索到。
  2. 关键词/专有名词搜不到:向量检索擅长「意思相近」,但对精确的 ID、编号、产品型号、人名这类 token,反而不如老式关键词匹配。这是纯向量 RAG 的致命短板。
  3. 排序不对:Top-K 里其实有正确答案,但它排在第 8 位,而你只取了前 3 个,白白漏掉。
  4. 问题表述 ≠ 文档表述:用户问「怎么退货」,文档里写的是「售后与退换流程」,语义有距离。
  5. 多轮对话指代崩溃:用户先问「LangChain 是什么」,接着问「和 Mastra 比呢」——直接拿「它和 Mastra 比呢」去检索,啥也搜不到。

切分环节:

  1. chunk 切得太糙:固定字符数硬切,经常把一个完整语义(比如一段代码、一张表、一个论证)切成两半,检索到半截等于没检索到。

生成环节:

  1. 上下文又长又杂:为了提高召回把 k 调大,结果塞进去一堆不相关内容,既浪费 token,又干扰模型(俗称「大海捞针」失败)。
  2. 依然幻觉 / 不肯认怂:检索到的资料不相关时,模型不说「我不知道」,而是硬编一个像模像样的答案。
  3. 无法溯源:模型给了答案,但你不知道它是根据哪篇文档说的,没法核实,企业场景根本不敢用。

还有一个隐藏的大坑: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 应用的全部零件。

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐