第 32 课: 完整 RAG 应用实战

2 阅读5分钟

课程目标

从零构建端到端的 RAG 系统:文档加载 -> 分割 -> 嵌入 -> 存储 -> 检索 -> 生成。整合 RecordManager 增量索引、对话历史、streamEvents 实时追踪。


32.1 RAG 全流程架构

文档源                    处理层                    存储层                  应用层
┌────────┐    ┌───────────────────┐    ┌──────────────┐    ┌─────────────────┐
│ PDF    │    │ DocumentLoader    │    │              │    │ 用户提问         │
│ Markdown│───>│ TextSplitter     │───>│ VectorStore  │<───│ Retriever        │
│ JSON   │    │ Embeddings       │    │ (Pinecone等) │    │ Prompt + Model   │
│ Web    │    │ RecordManager    │    │              │    │ OutputParser     │
└────────┘    └───────────────────┘    └──────────────┘    └─────────────────┘

32.2 Step 1: 文档加载

基于第 29 课的 BaseDocumentLoaderDocument 数据模型:

import { Document } from "@langchain/core/documents";

// 手动构建文档(实际项目中使用 DocumentLoader)
const docs = [
  new Document({
    pageContent: "LangChain.js 是一个用于构建 LLM 应用的 TypeScript 框架。",
    metadata: { source: "intro.md", chapter: 1 },
  }),
  new Document({
    pageContent: "Runnable 是 LangChain.js 的核心抽象,所有组件都实现此接口。",
    metadata: { source: "core.md", chapter: 2 },
  }),
  new Document({
    pageContent: "VectorStore 负责向量的存储与相似度搜索。",
    metadata: { source: "retrieval.md", chapter: 5 },
  }),
];

loadAndSplit() 是 BaseDocumentLoader 提供的便捷方法,一步完成加载和分块:

// BaseDocumentLoader 的 loadAndSplit 方法签名
async loadAndSplit(textSplitter: TextSplitter): Promise<Document[]>

32.3 Step 2: 文本分割

源码位置: libs/langchain-textsplitters/src/text_splitter.ts

import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 500,        // 每个分块最大 500 字符
  chunkOverlap: 50,      // 相邻分块重叠 50 字符
  separators: ["\n\n", "\n", "。", ",", " ", ""],  // 递归分割优先级
});

const splitDocs = await splitter.splitDocuments(docs);
// splitDocs 保留原始 metadata,并添加分块位置信息

分块参数调优指南

参数建议值过大的影响过小的影响
chunkSize300-1000检索精度下降上下文碎片化
chunkOverlapchunkSize 的 10-20%冗余增加语义断裂

32.4 Step 3: 嵌入与存储

Embeddings 接口: libs/langchain-core/src/embeddings.ts

export interface EmbeddingsInterface {
  embedDocuments(documents: string[]): Promise<number[][]>;  // 批量嵌入
  embedQuery(document: string): Promise<number[]>;            // 单条查询嵌入
}

VectorStore 内部流程:

// addDocuments 的典型实现流程
async addDocuments(documents: DocumentInterface[]): Promise<string[]> {
  const texts = documents.map((doc) => doc.pageContent);
  const vectors = await this.embeddings.embedDocuments(texts);
  return this.addVectors(vectors, documents);
}

// similaritySearch 的默认实现(来自 VectorStore 基类)
async similaritySearch(query: string, k = 4): Promise<DocumentInterface[]> {
  const queryVector = await this.embeddings.embedQuery(query);
  const results = await this.similaritySearchVectorWithScore(queryVector, k);
  return results.map((result) => result[0]);
}

32.5 Step 4: 增量索引 — RecordManager

源码位置: libs/langchain-core/src/indexing/

RecordManager 解决的核心问题:当文档源更新时,如何只处理新增和修改的文档?

// RecordManagerInterface 核心方法
export interface RecordManagerInterface {
  createSchema(): Promise<void>;
  getTime(): Promise<number>;
  update(keys: string[], updateOptions: UpdateOptions): Promise<void>;
  exists(keys: string[]): Promise<boolean[]>;
  listKeys(options: ListKeyOptions): Promise<string[]>;
  deleteKeys(keys: string[]): Promise<void>;
}

indexDocs() 函数实现增量索引逻辑:

源码位置: libs/langchain-core/src/indexing/base.ts

import type { IndexingResult } from "@langchain/core/indexing";

// indexDocs 的返回值
type IndexingResult = {
  numAdded: number;     // 新增文档数
  numDeleted: number;   // 删除文档数
  numUpdated: number;   // 更新文档数
  numSkipped: number;   // 跳过(未变化)文档数
};

内部流程:

  1. 对每个文档计算内容 hash(_HashedDocument
  2. 通过 RecordManager 检查哪些 hash 已存在
  3. 只对新增/修改的文档执行嵌入和存储
  4. 可选删除已不存在的文档

32.6 Step 5: 检索与生成

结合第 31 课的检索核心,构建完整 RAG 链:

import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { RunnablePassthrough, RunnableSequence } from "@langchain/core/runnables";

const retriever = vectorStore.asRetriever({ k: 4 });

const ragPrompt = ChatPromptTemplate.fromMessages([
  ["system", `你是一个知识库助手。根据以下检索到的上下文回答用户问题。
如果上下文中没有相关信息,请说明你不确定。

上下文:
{context}`],
  ["human", "{question}"],
]);

function formatDocs(docs: Document[]): string {
  return docs
    .map((doc, i) => `[${i + 1}] ${doc.pageContent}\n来源: ${doc.metadata.source}`)
    .join("\n\n");
}

const ragChain = RunnableSequence.from([
  {
    context: retriever.pipe(formatDocs),
    question: new RunnablePassthrough(),
  },
  ragPrompt,
  model,
  new StringOutputParser(),
]);

const answer = await ragChain.invoke("什么是 VectorStore?");

32.7 Step 6: 集成对话历史

结合第 10 课的 RunnableWithMessageHistory,让 RAG 支持多轮对话:

import { RunnableWithMessageHistory } from "@langchain/core/runnables";
import { ChatMessageHistory } from "@langchain/community/stores/message/in_memory";

// 改造 prompt,添加 MessagesPlaceholder
const ragPromptWithHistory = ChatPromptTemplate.fromMessages([
  ["system", `你是一个知识库助手。根据以下上下文回答问题。
上下文: {context}`],
  ["placeholder", "{chat_history}"],
  ["human", "{question}"],
]);

// 构造带历史的检索链
const chainWithHistory = RunnableSequence.from([
  {
    context: (input: { question: string }) =>
      retriever.pipe(formatDocs).invoke(input.question),
    question: (input: { question: string }) => input.question,
    chat_history: (input: { chat_history: any }) => input.chat_history,
  },
  ragPromptWithHistory,
  model,
  new StringOutputParser(),
]);

// 用 RunnableWithMessageHistory 包装
const messageHistories: Record<string, ChatMessageHistory> = {};

const ragWithHistory = new RunnableWithMessageHistory({
  runnable: chainWithHistory,
  getMessageHistory: async (sessionId: string) => {
    if (!messageHistories[sessionId]) {
      messageHistories[sessionId] = new ChatMessageHistory();
    }
    return messageHistories[sessionId];
  },
  inputMessagesKey: "question",
  historyMessagesKey: "chat_history",
});

// 多轮对话
const config = { configurable: { sessionId: "user-1" } };
await ragWithHistory.invoke({ question: "什么是 Runnable?" }, config);
await ragWithHistory.invoke({ question: "它有哪些核心方法?" }, config);  // 自动携带上下文

32.8 Step 7: streamEvents 实时追踪

streamEvents("v2") 监听 RAG 链执行的每个阶段:

const eventStream = ragChain.streamEvents("什么是 VectorStore?", {
  version: "v2",
});

for await (const event of eventStream) {
  switch (event.event) {
    case "on_retriever_start":
      console.log("开始检索...", event.data.input);
      break;
    case "on_retriever_end":
      console.log(`检索完成,找到 ${event.data.output.length} 个文档`);
      break;
    case "on_llm_stream":
      // 实时输出 LLM 生成的 token
      process.stdout.write(event.data.chunk.content);
      break;
    case "on_chain_end":
      console.log("\n生成完成");
      break;
  }
}

这在前端应用中非常有用:可以先展示"检索中...",然后逐 token 展示生成结果。


32.9 生产 RAG 最佳实践

检索质量优化

策略描述
分块大小调优从 500 字符开始,根据领域特点调整
重叠设置10-20% 重叠防止上下文断裂
MMR 搜索searchType: "mmr" 避免返回高度重复的内容
元数据过滤利用 filter 缩小搜索范围
混合检索向量搜索 + 关键词搜索互补

生成质量优化

策略描述
来源标注在 prompt 中要求模型标注信息来源
置信度评估要求模型评估回答的确定性
幻觉检测检查回答是否基于检索到的上下文
输入校验检查用户问题是否与知识库领域相关

32.10 源码精读路线

优先级文件关注点
P0langchain-core/src/vectorstores.tssimilaritySearch() 默认实现
P0langchain-core/src/retrievers/index.tsBaseRetriever 的 invoke() 模板方法
P1langchain-core/src/indexing/base.ts_HashedDocument 与增量索引逻辑
P1langchain-core/src/indexing/record_manager.tsRecordManagerInterface
P2langchain-core/src/embeddings.tsEmbeddings 接口
P2langchain-textsplitters/src/text_splitter.tsRecursiveCharacterTextSplitter

32.11 实战练习

构建一个完整的知识库问答系统:

  1. 文档准备: 创建 10+ 个 Document 对象,模拟知识库内容,每个有不同 metadata
  2. 分块处理: 使用 RecursiveCharacterTextSplitter 分块,打印分块统计
  3. 检索链: 构建 retriever -> formatDocs -> prompt -> model -> parser 完整链
  4. 对话历史: 用 RunnableWithMessageHistory 包装,支持多轮追问
  5. 事件监控: 用 streamEvents("v2") 追踪检索和生成的完整过程
  6. 测试: 使用 FakeListChatModel 编写单元测试,验证链的输入输出

本课收获总结

级别你应该掌握的
🟢 基础完成端到端 RAG 应用:从文档加载到问答生成
🔵 中阶整合 Document -> Split -> Embed -> Store -> Retrieve -> Generate 全流程
🟡 高阶理解 RecordManager 增量索引的 hash 去重机制
🟠 资深能将对话历史与 RAG 结合,支持上下文追问
🔴 架构能设计 RAG 评估方案:检索召回率、答案准确率、幻觉检测

下一课预告

第 33 课深入 MCP 协议适配 — 如何将 MCP (Model Context Protocol) 工具无缝接入 LangChain.js 的 Agent 体系。