从零入门 RAG:用 LangChain 搭建你的第一个检索增强生成应用

0 阅读11分钟

从零入门 RAG:用 LangChain 搭建你的第一个检索增强生成应用

本文将带你从"LLM 为什么会胡说八道"这个问题出发,一步步理解 RAG(检索增强生成)的核心原理,并通过一个完整的 Node.js 实战案例,让你亲手搭建一个基于 LangChain 的 RAG 应用。

目录


一、LLM 的"幻觉"问题

我们在使用 ChatGPT、Claude 等大语言模型时,经常会遇到一种尴尬的情况:你问了一个它"不知道"的问题,它会一本正经地胡说八道。

比如问它"我公司今年的销售数据是多少?",它可能会编造一个看似合理但完全错误的答案。这种现象在 AI 领域被称为幻觉(Hallucination)

为什么会这样?因为 LLM 的知识来源于训练时的数据集,它有以下几个局限:

  1. 知识有截止日期 —— 不知道最新发生的事
  2. 没有私有知识 —— 不知道你公司的内部文档、个人笔记
  3. 记忆有限 —— 无法记住超长上下文中的所有细节

那么,如何让 LLM 回答它原本不知道的问题呢?答案就是 RAG(Retrieval-Augmented Generation,检索增强生成)


二、RAG 是什么?

RAG 的核心思想很简单:在提问之前,先从知识库中检索出相关的文档片段,把这些片段作为"参考资料"一起喂给 LLM,让它基于这些资料来回答。

就像开卷考试一样 —— 学生不需要背诵整本教科书,只需要在考试时快速找到相关章节,基于书中的内容作答。

RAG 的流程可以拆解为三个步骤:

1. Retrieval(检索)

  • 将用户的提问转换为向量(Embedding)
  • 在预先构建好的知识库向量中,通过余弦相似度(Cosine Similarity)找出最相关的文档片段

2. Augmentation(增强)

  • 把检索到的文档片段拼接进原始 Prompt 中
  • 形成"问题 + 参考资料"的增强提示

3. Generation(生成)

  • LLM 基于增强后的 Prompt 生成答案
  • 因为答案有"据"可查,幻觉大幅降低
用户提问 → Embedding → 向量检索 → 获取相关文档 → 拼接 Prompt → LLM 生成答案

三、向量与语义搜索:RAG 的底层逻辑

在 RAG 中,检索这一步是最关键的。传统的关键词匹配(如 SQL 的 LIKE '%苹果%')有一个致命缺陷:它无法理解语义。

比如用户搜索"水果",关键词匹配找不到"苹果"和"香蕉",因为它们字面上不包含"水果"两个字。

什么是向量(Vector)?

向量是一串数字,用来在多维空间中表达一个概念。举个例子:

维度食用性硬度
水果0.90.3
苹果0.90.5
香蕉0.90.1
石头0.10.9

在这个二维空间中,"水果"、"苹果"、"香蕉"的距离很近,而"石头"离它们很远。这就是语义相似性的数学表达。

Embedding 的过程

当我们把一段文本输入 Embedding 模型(如 OpenAI 的 text-embedding-3-small),模型会将其压缩成一个高维向量(通常是 1536 维)。语义相近的文本,在向量空间中的距离也更近。

Cosine 相似度

检索时,我们计算"问题向量"和"文档向量"之间的夹角余弦值。值越接近 1,表示语义越相似。


四、实战:用 LangChain 搭建 RAG 应用

理论讲完了,接下来我们用一个完整的 Node.js 项目来实战。我们将基于 LangChain 框架,搭建一个"友情故事问答助手"。

4.1 环境准备

首先创建项目并安装依赖:

mkdir hello-rag && cd hello-rag
npm init -y
npm install langchain @langchain/openai @langchain/core @langchain/community dotenv

创建 .env 文件:

OPENAI_API_KEY=your-api-key
OPENAI_BASE_URL=https://api.openai.com/v1
MODEL_NAME=gpt-4o-mini
EMBEDDINGS_MODEL_NAME=text-embedding-3-small

4.2 构建知识库

我们用 Document 类来定义知识库中的每一段内容,并附上 metadata(元数据)方便后续追踪来源。

import { Document } from '@langchain/core/documents'

const documents = [
  new Document({
    pageContent: `光光是一个活泼开朗的小男孩,他有一双明亮的大眼睛...`,
    metadata: { 
      chapter: 1, 
      character: "光光", 
      type: "角色介绍", 
      mood: "活泼"
    },
  }),
  new Document({
    pageContent: `东东是光光最好的朋友,他是一个安静而聪明的男孩...`,
    metadata: { 
      chapter: 2, 
      character: "东东", 
      type: "角色介绍", 
      mood: "温馨"
    },
  }),
  // ... 更多文档片段
]

💡 为什么要切片? 长文档直接 Embedding 会稀释语义,切成小段后每段都有更集中的主题,检索更精准。

4.3 Embedding 与向量存储

LangChain 提供了 OpenAIEmbeddings 来生成向量,以及 MemoryVectorStore 作为内存中的向量数据库(适合学习和测试)。

import { ChatOpenAI, OpenAIEmbeddings } from '@langchain/openai'
import { MemoryVectorStore } from 'langchain/vectorstores/memory'

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  configuration: { baseURL: process.env.OPENAI_BASE_URL },
  temperature: 0  // 降低随机性,让回答更稳定
})

const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDINGS_MODEL_NAME,
  configuration: { baseURL: process.env.OPENAI_BASE_URL }
})

// 将文档自动切片、Embedding 并存入向量库
const vectorStore = await MemoryVectorStore.fromDocuments(documents, embeddings)

4.4 检索器配置

通过 asRetriever 方法创建检索器,k=3 表示每次检索返回最相关的 3 个文档片段。

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

// 检索过程
const retrievedDocs = await retriever.invoke("东东和光光各种擅长什么?")

4.5 增强 Prompt 与生成答案

这是 RAG 的"灵魂"步骤 —— 把检索到的文档拼进 Prompt,明确告诉 LLM"请基于以下资料回答"。

const context = retrievedDocs
  .map((doc, i) => `[片段${i + 1}]\n${doc.pageContent}`)
  .join("\n\n---\n\n")

const prompt = `
你是一个讲友情故事的老师。
基于以下的故事片段回答问题,用温暖生动的语言。
如果故事中没有提及,就说"这个故事没有提及这个细节"

故事片段:
${context}

问题:
${question}

老师的回答:
`

const response = await model.invoke(prompt)
console.log(response.content)

五、运行结果分析

运行代码后,你会看到类似这样的输出:

================================================================================
问题:东东和光光各种擅长什么?
================================================================================

 文档 1 相似度: 0.82
内容: 东东是光光最好的朋友,他是一个安静而聪明的男孩。东东喜欢读书和画画...
元数据: {"chapter":2,"character":"东东","type":"角色介绍","mood":"温馨"}

 文档 2 相似度: 0.79
内容: 光光是一个活泼开朗的小男孩...光光最喜欢的事情就是和朋友们一起玩耍,他特别擅长踢足球...
元数据: {"chapter":1,"character":"光光","type":"角色介绍","mood":"活泼"}

 文档 3 相似度: 0.75
内容: 从那以后,光光和东东成为了学校里最要好的朋友。光光教东东运动,东东教光光画画...
元数据: {"chapter":6,"character":"光光和东东","type":"结局","mood":"欢乐"}

 [AI 的回答]
光光是一个充满活力的男孩,他最擅长的就是踢足球,在球场上奔跑时就像一道阳光。而东东则是一个安静聪明的男孩,他喜欢读书和画画,他的画总是充满了想象力。两个好朋友互相学习,光光教东东运动,东东教光光画画,一起变得更好!

如图

image.png

关键观察

  1. 语义检索很精准:问题问的是"擅长什么",系统没有机械匹配"擅长"二字,而是理解了语义,找到了介绍两人特点的相关片段。
  2. 相似度分数:通过 similaritySearchWithScore 可以看到每段文档的相关度,帮助我们调试检索效果。
  3. 答案有据可查:LLM 的回答完全基于检索到的故事片段,没有编造不存在的信息。

六、RAG 的优化方向

这个示例是一个最基础的 RAG 实现,在实际生产环境中,你还可以从以下方向优化:

优化点说明
文档切分策略使用 RecursiveCharacterTextSplitter 自动按语义切分,避免句子被截断
持久化向量库MemoryVectorStore 替换为 Chroma、Milvus、Pinecone 等持久化数据库
混合检索向量检索 + 关键词检索(BM25)结合,提升召回率
重排序(Rerank)先用向量召回 Top-K,再用专门的 Rerank 模型排序,取 Top-N
查询改写让 LLM 先理解用户意图,生成更标准的查询语句再检索
引用溯源在回答中标注信息来源(如"根据第 2 章..."),提升可信度

七、总结

RAG 是当下最实用的 LLM 应用架构之一,它巧妙地弥补了大型语言模型的三大短板:知识时效性、私有知识、幻觉问题

通过本文,你应该已经理解了:

  • ✅ 为什么 LLM 会产生幻觉
  • ✅ RAG "检索 → 增强 → 生成" 的三步流程
  • ✅ 向量 Embedding 和 Cosine 相似度的基本原理
  • ✅ 如何用 LangChain + OpenAI 实现一个完整的 RAG 应用

如果你对这个方向感兴趣,建议下一步尝试:

  • 接入你自己的 PDF/Word 文档作为知识库
  • 尝试使用开源 Embedding 模型(如 BGE、M3E)替代 OpenAI
  • 学习 LangChain 的 RetrievalQAChaincreateRetrievalChain 进一步简化代码

附录

hello-rag.mjs完整代码

import 'dotenv/config'

import {
    ChatOpenAI,
    OpenAIEmbeddings
} from '@langchain/openai'
// 知识库中一段知识的抽象概念
import {
    Document
} from '@langchain/core/documents'
// 内存向量数据库
import {
    MemoryVectorStore
} from '@langchain/classic/vectorstores/memory'

const model= new ChatOpenAI({
    modelName:process.env.MODEL_NAME,
    apiKey:process.env.OPENAI_API_KEY,
    configuration:{
        baseURL:process.env.OPENAI_BASE_URL
    },
    temperature:0
})

const embeddings=new OpenAIEmbeddings({
    apiKey:process.env.OPENAI_API_KEY,
    model:process.env.EMBEDDINGS_MODEL_NAME,
    configuration:{
        baseURL:process.env.OPENAI_BASE_URL
    }
})

const documents=[
    new Document({
        pageContent: `光光是一个活泼开朗的小男孩,他有一双明亮的大眼睛,总是带着灿烂的笑容。光光最喜欢的事情就是和朋友们一起玩耍,他特别擅长踢足球,每次在球场上奔跑时,就像一道阳光一样充满活力。`,
        metadata: { 
          chapter: 1, 
          character: "光光", 
          type: "角色介绍", 
          mood: "活泼"
        },
      }),
    new Document({
        pageContent: `东东是光光最好的朋友,他是一个安静而聪明的男孩。东东喜欢读书和画画,他的画总是充满了想象力。虽然性格不同,但东东和光光从幼儿园就认识了,他们一起度过了无数个快乐的时光。`,
        metadata: { 
          chapter: 2, 
          character: "东东", 
          type: "角色介绍", 
          mood: "温馨"
        },
      }),
    new Document({
        pageContent: `有一天,学校要举办一场足球比赛,光光非常兴奋,他邀请东东一起参加。但是东东从来没有踢过足球,他担心自己会拖累光光。光光看出了东东的担忧,他拍着东东的肩膀说:"没关系,我们一起练习,我相信你一定能行的!"`,
        metadata: {
          chapter: 3,
          character: "光光和东东",
          type: "友情情节",
          mood: "鼓励",
        },
      }),
    new Document({
        pageContent: `接下来的日子里,光光每天放学后都会教东东踢足球。光光耐心地教东东如何控球、传球和射门,而东东虽然一开始总是踢不好,但他从不放弃。东东也用自己的方式回报光光,他画了一幅画送给光光,画上是两个小男孩在球场上一起踢球的场景。`,
        metadata: {
          chapter: 4,
          character: "光光和东东",
          type: "友情情节",
          mood: "互助",
        },
      }),
    new Document({
        pageContent: `比赛那天终于到了,光光和东东一起站在球场上。虽然东东的技术还不够熟练,但他非常努力,而且他用自己的观察力帮助光光找到了对手的弱点。在关键时刻,东东传出了一个漂亮的球,光光接球后射门得分!他们赢得了比赛,更重要的是,他们的友谊变得更加深厚了。`,
        metadata: {
          chapter: 5,
          character: "光光和东东",
          type: "高潮转折",
          mood: "激动",
        },
      }),
    new Document({
        pageContent: `从那以后,光光和东东成为了学校里最要好的朋友。光光教东东运动,东东教光光画画,他们互相学习,共同成长。每当有人问起他们的友谊,他们总是笑着说:"真正的朋友就是互相帮助,一起变得更好的人!"`,
        metadata: {
          chapter: 6,
          character: "光光和东东",
          type: "结局",
          mood: "欢乐",
        },
      }),
    new Document({
        pageContent: `多年后,光光成为了一名职业足球运动员,而东东成为了一名优秀的插画师。虽然他们走上了不同的道路,但他们的友谊从未改变。东东为光光设计了球衣上的图案,光光在每场比赛后都会给东东打电话分享喜悦。他们证明了,真正的友情可以跨越时间和距离,永远闪闪发光。`,
        metadata: {
          chapter: 7,
          character: "光光和东东",
          type: "尾声",
          mood: "温馨",
        },
      }),
]

const vectorStore=await MemoryVectorStore.fromDocuments(documents,embeddings);

// 检索器,k=2表示检索2个最相关的文档
const retriever=vectorStore.asRetriever({k:3});

const questions=["东东和光光各种擅长什么?"]

for (const question of questions){
    console.log("=".repeat(80));
    console.log(`问题:${question}`);
    console.log("=".repeat(80));
    // 先将question 转换为向量
    // 再通过向量搜索,consine 知道最相似的文档
    const retrievedDocs=await retriever.invoke(question);
    console.log(retrievedDocs)   
    const scoreResults=await vectorStore.similaritySearchWithScore(question,3);
    console.log(scoreResults)
    console.log("\n[检索到的文档及相关度评分]")
    retrievedDocs.forEach((doc,i)=>{
        const scoreResult=scoreResults.find(
            ([scoredDoc])=>scoredDoc.pageContent===doc.pageContent
        )
        const score=scoreResult ? scoreResult[1]:null;
        const similarity=score?(1-score).toFixed(2):"N/A";
        console.log(`\n 文档 ${i+1} 相似度:${similarity}`);
        console.log(`内容:${doc.pageContent}`);
        console.log(`元数据:${JSON.stringify(doc.metadata)}`);

    })

    const context=retrievedDocs
    .map((doc,i)=>`[片段${i+1}\n ${doc.pageContent}]`)
    .join("\n\n---\n\n");

    const prompt=`
    你是一个将友情故事的老师。
    基于以下的故事片段回答问题,用温暖生动的语言。
    如果故事中没有提及,就说"这个故事没有提及这个细节"

    故事片段:
    ${context}
    
    问题:
    ${question}

    老师的回答:
    `; 
    console.log(`\n [AI 的回答]`);
    const response=await model.invoke(prompt);
    console.log(response.content);
    console.log("\n");

   

}

工作流程如图

b37dc5b5-9268-44de-af43-4898bc7c8086.png 如果这篇文章对你有帮助,欢迎点赞、收藏、关注! 有任何问题欢迎在评论区交流,我们一起学习进步 🚀