用LangChain.js构建一个RAG应用(一)

149 阅读15分钟

翻译自:Build a Retrieval Augmented Generation (RAG) App: Part 1

大型语言模型(LLM)最强大的应用之一就是​​复杂问答(Q&A)聊天机器人​​。这类应用能够基于特定源信息回答问题,其核心技术称为​检索增强生成(Retrieval Augmented Generation, RAG)​

本教程分为多部分:

本教程将展示如何基于文本数据源构建简易问答应用,涵盖典型架构设计,并推荐进阶技术的延伸资源。我们还将使用​​LangSmith​​工具来追踪和分析应用逻辑——随着应用复杂度提升,该工具的作用会愈发显著。

若已熟悉基础检索,可参考这篇检索技术高阶指南
​注意​​:本文聚焦非结构化数据的问答。如需结构化数据(如SQL)的RAG方案,请参阅SQL问答教程

概述

典型的RAG应用包含两大模块:

​索引构建(离线流程)​

  1. ​加载​​:通过文档加载器导入原始数据
  2. ​分割​​:使用文本分割器将大文档切分为小片段,便于后续检索和模型处理(大文本难以搜索且超出模型上下文限制)
  3. ​存储​​:利用向量数据库和嵌入模型建立索引

检索与生成(实时响应)​

  1. ​检索​​:根据用户输入,通过检索器从存储中提取相关片段
  2. ​生成​​:由聊天模型/LLM结合检索结果和问题生成答案

索引构建完成后,我们将使用LangGraph框架编排检索与生成流程。

环境配置

Jupyter Notebook

建议在Jupyter notebooks中交互式运行本教程,安装方法见官方指南

依赖安装

运行本教程需安装以下依赖:

pnpm add langchain @langchain/core @langchain/langgraph

详细说明参考安装指南。

LangSmith配置

LangChain应用通常包含多步骤的LLM调用链。随着复杂度增加,使用LangSmith可视化内部运行过程至关重要。

注册后,设置环境变量启用日志追踪:

export LANGSMITH_TRACING="true"
export LANGSMITH_API_KEY="..."

# serverless环境下可以减少跟踪延迟
# export LANGCHAIN_CALLBACKS_BACKGROUND=true

组件

我们需要从LangChain的集成套件中选择三个组件。

一个聊天模型(比如OpenAi):

pnpm add @langchain/openai

添加环境变量

OPENAI_API_KEY=your-api-key

实例化模型

import { ChatOpenAI } from "@langchain/openai";

const llm = new ChatOpenAI({
  model: "gpt-4o-mini",
  temperature: 0
});

嵌入模型:

pnpm add @langchain/openai
OPENAI_API_KEY=your-api-key
import { OpenAIEmbeddings } from "@langchain/openai";

const embeddings = new OpenAIEmbeddings({
  model: "text-embedding-3-large"
});

添加向量数据(内存数据库为例):

pnpm add langchain
import { MemoryVectorStore } from "langchain/vectorstores/memory";

const vectorStore = new MemoryVectorStore(embeddings);

预览

在本指南中,我们将构建一个能回答网站内容相关问题的应用。具体使用的网站是Lilian Weng撰写的LLM驱动的自主代理博客文章,通过该应用我们可以就文章内容提问。

只需约50行代码,我们就能创建一个简单的索引管道和RAG链来实现这一功能。

import "cheerio";
import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";
import { Document } from "@langchain/core/documents";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { pull } from "langchain/hub";
import { Annotation, StateGraph } from "@langchain/langgraph";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";


// 加载并分块博客内容
const pTagSelector = "p";
const cheerioLoader = new CheerioWebBaseLoader(
  "https://lilianweng.github.io/posts/2023-06-23-agent/",
  {
    selector: pTagSelector
  }
);

const docs = await cheerioLoader.load();

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 1000, chunkOverlap: 200
});
const allSplits = await splitter.splitDocuments(docs);


// 索引分块内容
await vectorStore.addDocuments(allSplits)

// 定义问答提示模板
const promptTemplate = await pull<ChatPromptTemplate>("rlm/rag-prompt");

// 定义应用状态
const InputStateAnnotation = Annotation.Root({
  question: Annotation<string>,
});

const StateAnnotation = Annotation.Root({
  question: Annotation<string>,
  context: Annotation<Document[]>,
  answer: Annotation<string>,
});

// 定义应用步骤
const retrieve = async (state: typeof InputStateAnnotation.State) => {
  const retrievedDocs = await vectorStore.similaritySearch(state.question)
  return { context: retrievedDocs };
};


const generate = async (state: typeof StateAnnotation.State) => {
  const docsContent = state.context.map(doc => doc.pageContent).join("\n");
  const messages = await promptTemplate.invoke({ question: state.question, context: docsContent });
  const response = await llm.invoke(messages);
  return { answer: response.content };
};


// 编译应用并测试
const graph = new StateGraph(StateAnnotation)
  .addNode("retrieve", retrieve)
  .addNode("generate", generate)
  .addEdge("__start__", "retrieve")
  .addEdge("retrieve", "generate")
  .addEdge("generate", "__end__")
  .compile();
let inputs = { question: "什么是任务分解?" };

const result = await graph.invoke(inputs);
console.log(result.answer)
任任务分解是将复杂任务拆分为更小、更简单的步骤的过程。例如,可以通过提示大语言模型(如“XYZ的步骤是什么?”)、使用任务特定指令或借助人工输入来实现。此外,Tree of Thoughts等方法还通过多步推理和树状结构探索进一步扩展了任务分解的灵活性。

查看 LangSmith trace.

e specific elements:

详细步骤解析

让我们逐步剖析上述代码,深入理解其运行机制。

1. 数据索引 {#indexing}

本节是语义搜索教程的浓缩版。若您已熟悉文档加载器嵌入模型向量数据库的概念,可直接跳转至下一章节检索与生成

文档加载

首先需要加载博客文章内容。这里使用文档加载器,该组件能从数据源加载内容并返回文档列表。每个文档对象包含页面内容(string)和元数据(Record<string, any>)。

本示例采用CheerioWebBaseLoader,该加载器通过cheerio库从网页URL获取HTML并解析为文本。通过向构造函数传递CSS选择器,可以指定需要解析的特定DOM元素:

import "cheerio";
import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";

const pTagSelector = "p";
const cheerioLoader = new CheerioWebBaseLoader(
  "https://lilianweng.github.io/posts/2023-06-23-agent/",
  {
    selector: pTagSelector
  }
);

const docs = await cheerioLoader.load();

console.assert(docs.length === 1);
console.log(`Total characters: ${docs[0].pageContent.length}`);
Total characters: 22360
console.log(docs[0].pageContent.slice(0, 500));
Building agents with LLM (large language model) as its core controller is a cool concept. Several proof-of-concepts demos, such as AutoGPT, GPT-Engineer and BabyAGI, serve as inspiring examples. The potentiality of LLM extends beyond generating well-written copies, stories, essays and programs; it can be framed as a powerful general problem solver.In a LLM-powered autonomous agent system, LLM functions as the agent’s brain, complemented by several key components:A complicated task usually involv

深入探索

DocumentLoader:用于从数据源加载文档列表的类。

文档分割

我们加载的文档超过42,000个字符,对于许多模型的上下文窗口来说过长。即使某些模型能够容纳完整内容,过长的输入仍可能导致信息检索困难。

为解决这一问题,我们将把Document分割成多个块以便嵌入和向量存储。这有助于在运行时仅检索博客文章中最相关的部分。

与语义搜索教程类似,我们使用递归字符文本分割器。该工具会通过换行符等常见分隔符递归分割文档,直到每个块达到合适大小。这是通用文本场景推荐的文本分割工具。

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

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 1000,
  chunkOverlap: 200,
});
const allSplits = await splitter.splitDocuments(docs);
console.log(`Split blog post into ${allSplits.length} sub-documents.`);
Split blog post into 29 sub-documents.

深入探索

TextSplitter:将Document列表分割成更小片段的对象,属于DocumentTransformers的子类。

  • ​上下文感知分割器​​:保留每个片段在原始Document中的位置(“上下文”):

    • Markdown文件
    • 代码(支持15+语言)
    • 接口:基础接口的API参考文档

DocumentTransformer:对Document列表执行转换操作的对象。

文档存储

现在我们需要为66个文本片段建立索引,以便在运行时进行搜索。参照语义搜索教程,我们的方法是:

  1. 对每个文档片段的内容进行向量化
  2. 将这些向量插入向量数据库
    输入查询时,即可通过向量搜索检索相关文档。

利用教程起始部分选择的向量数据库和嵌入模型,我们只需一条命令即可完成所有文档片段的向量化存储。

await vectorStore.addDocuments(allSplits);

深入探索

Embeddings(嵌入模型封装器):用于将文本转换为嵌入向量的文本嵌入模型封装器。

  • 文档:详细说明如何使用嵌入模型。
  • 集成方案:提供30多种集成选项。
  • 接口:基础接口的API参考文档。

VectorStore(向量数据库封装器):用于存储和查询嵌入向量的向量数据库封装器。

  • 文档:详细说明如何使用向量数据库。
  • 集成方案:提供40多种集成选项。
  • 接口:基础接口的API参考文档。

至此,我们已完成流程中的​​索引(Indexing)​​部分。此时,我们已拥有一个可查询的向量数据库,其中存储了博客文章的分块内容。针对用户提问,理论上应能返回博客文章中解答该问题的相关片段。

2. 检索与生成 {#orchestration}

现在让我们编写实际的应用程序逻辑。我们需要创建一个简单的应用,该应用能够:

  1. 接收用户问题
  2. 搜索与该问题相关的文档
  3. 将检索到的文档和原始问题传递给模型
  4. 返回最终答案

在生成环节,我们将使用本教程初始部分选定的聊天模型。

我们将采用已提交至LangChain提示中心的RAG提示模板(查看详情)。该模板经过专门优化,适用于检索增强生成场景。

import { pull } from "langchain/hub";  
import { ChatPromptTemplate } from "@langchain/core/prompts";  
  
const promptTemplate = await pull<ChatPromptTemplate>("rlm/rag-prompt");  
  
// 样例:  
const example_prompt = await promptTemplate.invoke({  
context: "(context goes here)",  
question: "(question goes here)",  
});  
const example_messages = example_prompt.messages;  
  
console.assert(example_messages.length === 1);  
example_messages[0].content;
You are an assistant for question-answering tasks. Use the following pieces of retrieved context to answer the question. If you don't know the answer, just say that you don't know. Use three sentences maximum and keep the answer concise.  
Question: (question goes here)  
Context: (context goes here)  
Answer:

我们将使用LangGraph将检索和生成步骤整合为一个统一的应用。这将带来多项优势:

  • 只需定义一次应用逻辑,即可自动支持多种调用模式,包括流式传输、异步和批量调用
  • 通过LangGraph Platform实现简化的部署流程
  • LangSmith将自动追踪应用各步骤的执行轨迹
  • 只需少量代码修改即可轻松添加关键功能,包括持久化存储人工审核介入

使用LangGraph需要定义三个核心要素:

  1. 应用状态
  2. 应用节点(即处理步骤)
  3. 应用控制流(各步骤的执行顺序)

状态定义:

应用的状态决定了输入数据、步骤间传递数据以及最终输出的数据结构。

对于简单的RAG应用,我们只需跟踪以下内容:

  • 输入问题
  • 检索到的上下文
  • 生成的答案

更多关于定义图状态的说明可参阅此处

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

const InputStateAnnotation = Annotation.Root({
  question: Annotation<string>,
});

const StateAnnotation = Annotation.Root({
  question: Annotation<string>,
  context: Annotation<Document[]>,
  answer: Annotation<string>,
});

节点(应用步骤)

我们从一个简单的两步流程开始:​​检索​​和​​生成​​。

import { concat } from "@langchain/core/utils/stream";

const retrieve = async (state: typeof InputStateAnnotation.State) => {
  const retrievedDocs = await vectorStore.similaritySearch(state.question)
  return { context: retrievedDocs };
};


const generate = async (state: typeof StateAnnotation.State) => {
  const docsContent = state.context.map(doc => doc.pageContent).join("\n");
  const messages = await promptTemplate.invoke({ question: state.question, context: docsContent });
  const response = await llm.invoke(messages);
  return { answer: response.content };
};

我们的检索步骤仅需对输入问题执行相似性搜索,而生成步骤则将检索到的上下文与原始问题整合为对话模型的提示模板。

控制流

最终,我们将应用逻辑编译为一个统一的 graph 对象。在本例中,只需将检索与生成步骤串联为单一执行序列即可。

import { StateGraph } from "@langchain/langgraph";

const graph = new StateGraph(StateAnnotation)
  .addNode("retrieve", retrieve)
  .addNode("generate", generate)
  .addEdge("__start__", "retrieve")
  .addEdge("retrieve", "generate")
  .addEdge("generate", "__end__")
  .compile();

LangGraph 还内置了可视化工具,可直观展示应用程序的控制流程:

// Note: tslab only works inside a jupyter notebook. Don't worry about running this code yourself!
import * as tslab from "tslab";

const image = await graph.getGraph().drawMermaidPng();
const arrayBuffer = await image.arrayBuffer();

await tslab.display.png(new Uint8Array(arrayBuffer));

image.png

构建RAG应用并非必须使用LangGraph。实际上,我们可以通过直接调用各个组件来实现相同的应用逻辑:

let question = "..."

const retrievedDocs = await vectorStore.similaritySearch(question)
const docsContent = retrievedDocs.map(doc => doc.pageContent).join("\n");
const messages = await promptTemplate.invoke({ question: question, context: docsContent });
const answer = await llm.invoke(messages);

我是否需要使用LangGraph

使用LangGraph的优势在于:

  • ​多模式调用支持​​:若需要流式输出令牌或分步流式传输结果,传统逻辑需重写代码
  • ​开箱即用的集成​​:自动支持LangSmith追踪和LangGraph Platform部署
  • ​高级功能扩展​​:原生支持持久化存储、人工审核等企业级特性

许多应用场景(如基于对话的上下文感知问答系统)需要维持会话状态。正如我们将在教程第二部分看到的,LangGraph的状态管理和持久化功能能极大简化这类应用的开发。

使用方式

让我们来测试一下应用程序!LangGraph 支持多种调用模式,包括同步、异步和流式调用。

调用:

let inputs = { question: "What is Task Decomposition?" };

const result = await graph.invoke(inputs);
console.log(result.context.slice(0, 2));
console.log(`\nAnswer: ${result["answer"]}`);

流式步骤:

console.log(inputs)
console.log("\n====\n");
for await (
  const chunk of await graph.stream(inputs, {
    streamMode: "updates",
  })
) {
  console.log(chunk);
  console.log("\n====\n");
}
{ question: '什么是任务分解?' }                                       
                                                                       
====

{
  retrieve: { context: [ [Document], [Document], [Document], [Document] ] }
}

====

{
  generate: {
    answer: '任务分解是将复杂任务拆分为更小、更简单的步骤的过程。例如,通过提示大语言模型(如“XYZ的步骤是什么?”)或使用特定任务指令来实现。此外,也可以借助外部工具(如经典规划器)或人工输入 进行分解。'
  }
}

====

流式传输 tokens(需满足 @langchain/core >= 0.3.24 和 @langchain/langgraph >= 0.2.34 版本要求,并采用上述实现方式):

const stream = await graph.stream(
  inputs,
  { streamMode: "messages" },
);

for await (const [message, _metadata] of stream) {
  process.stdout.write(message.content + "|");
}
任务|分解|是将|复杂|任务|拆|分为|更|小|、|更|简单的|步骤|的过程|。|例如|,|通过|提示|大|语言|模型|(|LL|M|)|生成|子|目标|,|或|结合|任务|特定|指令|(|如|“|写|

在当前实现中,若要在 generate 步骤使用 .invoke 方法流式传输 tokens,需满足 @langchain/core >= 0.3.24 和 @langchain/langgraph >= 0.2.34 版本要求。具体说明请参阅此文档

返回来源

请注意,通过将检索到的上下文存储在图的 state 中,我们可以在 state 的 "context" 字段中获取模型生成答案的来源信息。详情请参阅本指南

深入探索

聊天模型 接收消息序列并返回一条消息。

​自定义提示词​
如上所示,我们可以从提示词中心加载提示词(例如 这个 RAG 提示词)。提示词也可以轻松自定义,例如:

const template = `Use the following pieces of context to answer the question at the end.
If you don't know the answer, just say that you don't know, don't try to make up an answer.
Use three sentences maximum and keep the answer as concise as possible.
Always say "thanks for asking!" at the end of the answer.

{context}

Question: {question}

Helpful Answer:`

const promptTemplateCustom = ChatPromptTemplate.fromMessages(
  [
    ["user", template]
  ]
)

查询分析

目前,我们直接使用原始输入查询执行检索。但让模型生成用于检索的查询具有一些优势,例如:

  • 除了语义搜索,我们还可以构建结构化筛选条件(例如“查找2020年之后的文档”);
  • 模型可以将用户的多方面查询或包含无关语言的查询重写为更有效的搜索语句。

查询分析 利用模型对原始用户输入进行转换或构建优化的搜索查询。我们可以轻松地将查询分析步骤集成到应用中。

为了便于演示,让我们在向量存储的文档中添加一些元数据。我们将为文档添加一些(人为设计的)章节标签,以便后续进行筛选。

const totalDocuments = allSplits.length;
const third = Math.floor(totalDocuments / 3);

allSplits.forEach((document, i) => {
    if (i < third) {
        document.metadata["section"] = "beginning";
    } else if (i < 2 * third) {
        document.metadata["section"] = "middle";
    } else {
        document.metadata["section"] = "end";
    }
});

allSplits[0].metadata;
{  
source: 'https://lilianweng.github.io/posts/2023-06-23-agent/',  
loc: { lines: { from: 1, to: 1 } },  
section: 'beginning'  
}

我们需要更新向量存储中的文档。这里我们将使用简单的 MemoryVectorStore,因为它具备我们需要的特定功能(例如元数据筛选)。请根据您选择的向量存储,参考对应的 集成文档 了解相关功能特性。

import { MemoryVectorStore } from "langchain/vectorstores/memory";

const vectorStoreQA = new MemoryVectorStore(embeddings);
await vectorStoreQA.addDocuments(allSplits)

接下来我们需要为搜索查询定义一个结构模式。这里我们将使用结构化输出来实现。我们定义查询包含两个部分:字符串查询和文档段落(可选"开头"、"中间"或"结尾"),当然您也可以根据需要自定义这个结构。

import { z } from "zod";


const searchSchema = z.object({
  query: z.string().describe("Search query to run."),
  section: z.enum(["beginning", "middle", "end"]).describe("Section to query."),
});

const structuredLlm = llm.withStructuredOutput(searchSchema)

最后,我们在 LangGraph 应用中新增一个步骤,用于根据用户原始输入生成查询:

const StateAnnotationQA = Annotation.Root({
  question: Annotation<string>,
  search: Annotation<z.infer<typeof searchSchema>>,
  context: Annotation<Document[]>,
  answer: Annotation<string>,
});

const analyzeQuery = async (state: typeof InputStateAnnotation.State) => {
  const result = await structuredLlm.invoke(state.question)
  return { search: result }
};

const retrieveQA = async (state: typeof StateAnnotationQA.State) => {
  const filter = (doc) => doc.metadata.section === state.search.section;
  const retrievedDocs = await vectorStore.similaritySearch(
    state.search.query,
    2,
    filter
  )
  return { context: retrievedDocs };
};

const generateQA = async (state: typeof StateAnnotationQA.State) => {
  const docsContent = state.context.map(doc => doc.pageContent).join("\n");
  const messages = await promptTemplate.invoke({ question: state.question, context: docsContent });
  const response = await llm.invoke(messages);
  return { answer: response.content };
};

const graphQA = new StateGraph(StateAnnotationQA)
  .addNode("analyzeQuery", analyzeQuery)
  .addNode("retrieveQA", retrieveQA)
  .addNode("generateQA", generateQA)
  .addEdge("__start__", "analyzeQuery")
  .addEdge("analyzeQuery", "retrieveQA")
  .addEdge("retrieveQA", "generateQA")
  .addEdge("generateQA", "__end__")
  .compile();
import * as tslab from "tslab";

const image = await graphQA.getGraph().drawMermaidPng();
const arrayBuffer = await image.arrayBuffer();

await tslab.display.png(new Uint8Array(arrayBuffer));

image.png

我们可以通过特别提问末尾的上下文来测试我们的实现。需要注意的是,模型在其答案中包含了不同的信息。

let inputsQA = { question: "What does the end of the post say about Task Decomposition?" };

console.log(inputsQA)
console.log("\n====\n");
for await (
  const chunk of await graphQA.stream(inputsQA, {
    streamMode: "updates",
  })
) {
  console.log(chunk);
  console.log("\n====\n");
}

在分步流式处理和LangSmith追踪记录中,我们现在可以观察到输入检索步骤的结构化查询。

查询分析是一个涉及多种方法的丰富课题。更多示例请参阅操作指南

后续步骤

我们已经介绍了构建基础数据问答应用的完整流程:

在本教程的第二部分中,我们将扩展当前实现,支持对话式交互和多步骤检索流程。

延伸阅读: