从 Document 到 LLM Answer:手写 LangChain RAG 检索与生成闭环

从 Document 到 LLM Answer:手写 LangChain RAG 检索与生成闭环

RAG 的核心不是让大模型记住所有资料,而是在回答问题前,先从知识库中找到相关内容,再让模型基于这些内容生成答案。

前言

最近在学习 LangChain 时,我开始接触 RAG。

刚开始看到 RAG、Embedding、VectorStore、Retriever 这些词时,会觉得它们是几个独立的概念。真正把代码跑起来之后才发现,它们其实共同组成了一条完整的数据处理链路。

整个过程可以概括为:

复制代码
Document
   ↓
Embedding
   ↓
VectorStore
   ↓
Retriever
   ↓
Context
   ↓
LLM Answer

这篇文章会通过一个最小可运行的 LangChain Demo,完整梳理 RAG 是如何完成知识准备、语义检索和答案生成的。


一、什么是 RAG

RAG 的英文全称是:

复制代码
Retrieval-Augmented Generation

中文通常翻译为:

复制代码
检索增强生成

普通的大模型问答流程,是直接把用户问题交给模型:

复制代码
用户问题 → 大模型 → 回答

而 RAG 会在调用大模型之前,增加一个检索步骤:

复制代码
用户问题
   ↓
检索知识库
   ↓
找到相关资料
   ↓
把资料和问题一起交给大模型
   ↓
生成答案

例如,用户问:

复制代码
光光有什么性格特点?

RAG 系统不会只依赖模型自身原有的知识,而是先去知识库中搜索和"光光"有关的内容,然后把检索到的故事片段提供给模型。

这种方式可以让模型的回答更贴近指定资料,也能降低模型凭空编造内容的概率。


二、项目依赖分别负责什么

这次 Demo 中使用了以下几个主要依赖。

1. dotenv

arduino 复制代码
import 'dotenv/config';

dotenv 用来读取项目中的 .env 文件。

通常模型名称、API Key 和接口地址都不会直接写在代码里,而是放在环境变量中,例如:

ini 复制代码
MODEL_NAME=deepseek-chat
OPENAI_API_KEY=你的APIKey
BASE_URL=https://api.deepseek.com/v1
EMBEDDINGS_MODEL_NAME=text-embedding-v4

这样做有两个好处:

  1. 避免把敏感信息直接写进源码。
  2. 更换模型或接口时,不需要频繁修改业务代码。

需要注意,.env 文件不能提交到 Git 仓库。

可以在 .gitignore 中加入:

bash 复制代码
.env

2. @langchain/openai

这个包主要提供两个对象:

复制代码
ChatOpenAI
OpenAIEmbeddings

它们虽然都和模型有关,但作用完全不同。

ChatOpenAI

ChatOpenAI 负责最后的自然语言生成。

例如:

php 复制代码
const model = new ChatOpenAI({
  model: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  configuration: {
    baseURL: process.env.BASE_URL,
  },
  temperature: 0,
});

它接收问题和上下文,最后返回一段可读的回答。

OpenAIEmbeddings

OpenAIEmbeddings 负责将文本转换成向量。

例如:

arduino 复制代码
const embeddings = new OpenAIEmbeddings({
  model: process.env.EMBEDDINGS_MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  configuration: {
    baseURL: process.env.BASE_URL,
  },
});

Embedding 模型不会直接生成自然语言回答。

它的任务是把文本转换成一组数字,让程序可以比较两段文本在语义上是否相近。


3. @langchain/core

这个包中使用了 Document

javascript 复制代码
import { Document } from '@langchain/core/documents';

在 LangChain 中,Document 用来表示知识库中的一条文档。

例如:

css 复制代码
new Document({
  pageContent: '光光是一个活泼开朗、很喜欢帮助朋友的小男孩。',
  metadata: {
    chapter: 1,
    character: '光光',
    type: '角色介绍',
    mood: '活泼',
  },
});

每个 Document 主要包含两部分:

复制代码
pageContent
metadata

其中:

  • pageContent 是正文内容
  • metadata 是文档附加信息

pageContent 会参与 Embedding 和语义检索。

metadata 通常用来记录:

  • 章节
  • 来源
  • 作者
  • 文档类型
  • 创建时间
  • 角色
  • 标签

后续检索到某条文档时,可以通过 metadata 判断这条内容来自哪里。


4. @langchain/classic

这次使用的是内存向量库:

javascript 复制代码
import { MemoryVectorStore } from '@langchain/classic/vectorstores/memory';

MemoryVectorStore 会把文本向量保存在程序内存中。

它的特点是:

  • 配置简单
  • 适合学习
  • 适合小型 Demo
  • 不需要安装数据库
  • 程序关闭后数据会消失

因此它适合帮助我们理解 RAG 的原理,但不适合真正的生产项目。


三、准备知识库文档

RAG 的第一步是准备知识库。

例如:

go 复制代码
const documents = [
  new Document({
    pageContent: '光光是一个性格活泼的小男孩,很喜欢帮助别人。',
    metadata: {
      chapter: 1,
      character: '光光',
      type: '角色介绍',
    },
  }),

  new Document({
    pageContent: '小美遇到困难时,光光主动陪着她一起想办法。',
    metadata: {
      chapter: 2,
      character: '光光',
      type: '故事情节',
    },
  }),

  new Document({
    pageContent: '光光虽然偶尔有些冲动,但他非常重视朋友之间的感情。',
    metadata: {
      chapter: 3,
      character: '光光',
      type: '性格补充',
    },
  }),
];

这里每一条 Document 都可以理解为知识库中的一个最小检索单元。

实际项目中,知识来源可能是:

  • Markdown 文件
  • PDF
  • Word 文档
  • 数据库内容
  • 网站文章
  • 产品文档
  • 客服问答
  • 企业内部资料

无论原始资料是什么,最后通常都要转成类似 Document 的结构。


四、Embedding 到底在做什么

大模型不能直接使用自然语言计算两段文本有多相似。

因此需要先将文本转换成向量。

例如:

复制代码
光光喜欢帮助朋友

经过 Embedding 模型后,可能会变成类似下面的一组数字:

arduino 复制代码
[
  0.013,
  -0.284,
  0.721,
  0.056,
  -0.417,
  // ...
]

真实向量通常包含数百甚至上千个维度。

这些数字本身不需要人工理解,它们的作用是表示文本在语义空间中的位置。

例如下面两句话:

复制代码
光光喜欢帮助朋友

光光是一个乐于助人的人

虽然文字并不完全相同,但语义接近,因此它们生成的向量距离通常也会比较近。

而下面这句话:

复制代码
今天天气非常炎热

和前面的语义差距较大,对应向量之间的距离也会更远。

这就是语义检索能够工作的基础。


五、创建内存向量库

核心代码如下:

ini 复制代码
const vectorStore = await MemoryVectorStore.fromDocuments(
  documents,
  embeddings
);

这一行代码背后主要做了三件事:

  1. 读取每个 DocumentpageContent
  2. 调用 Embedding 模型,将文本转换成向量
  3. 将向量和原始文档保存在向量库中

可以理解为:

复制代码
原始文档
   ↓
Embedding 模型
   ↓
文本向量
   ↓
保存到 VectorStore

需要注意的是,存进向量库的不只是向量。

向量库通常还会保留:

  • 原始正文
  • metadata
  • 文档 ID
  • 向量值

这样检索时不仅能计算相似度,还能返回原始文本。


六、VectorStore 和普通数据库有什么不同

普通数据库更擅长精确匹配。

例如:

sql 复制代码
SELECT * FROM documents
WHERE character = '光光';

这种查询适合已知字段和值的情况。

但如果用户问:

复制代码
谁最乐于帮助别人?

这个问题不一定包含"光光"这个名字,也不一定和文档里的文字完全一样。

普通字符串搜索可能找不到合适结果。

向量数据库更擅长语义匹配。

它会比较:

复制代码
用户问题的向量

和:

复制代码
知识库文档的向量

然后找出距离最近的内容。

所以向量检索解决的不是:

复制代码
文字是否完全相同

而是:

复制代码
语义是否相似

七、把 VectorStore 转成 Retriever

创建向量库之后,可以进一步把它转换成检索器:

ini 复制代码
const retriever = vectorStore.asRetriever({
  k: 3,
});

这里的:

makefile 复制代码
k: 3

表示每次查询返回最相关的 3 条文档。

Retriever 可以理解为向量库外面的一层标准接口。

原始的 VectorStore 主要负责:

复制代码
保存向量
计算相似度
返回搜索结果

Retriever 主要负责:

复制代码
接收问题
调用底层检索能力
返回相关文档

这样以后即使更换向量数据库,上层代码也不需要发生太大变化。


八、执行语义检索

用户问题可以这样定义:

ini 复制代码
const question = '光光是一个怎样的人?';

然后调用检索器:

ini 复制代码
const docs = await retriever.invoke(question);

这一行代码背后的过程是:

复制代码
用户问题
   ↓
转换成问题向量
   ↓
与知识库中的文档向量进行比较
   ↓
按照相似程度排序
   ↓
返回最相关的 3 条文档

最终的 docs 是一个文档数组。

可以查看检索结果:

javascript 复制代码
docs.forEach((doc, index) => {
  console.log(`第 ${index + 1} 条文档`);
  console.log(doc.pageContent);
  console.log(doc.metadata);
});

可能输出:

复制代码
第 1 条文档
光光是一个性格活泼的小男孩,很喜欢帮助别人。

第 2 条文档
小美遇到困难时,光光主动陪着她一起想办法。

第 3 条文档
光光虽然偶尔有些冲动,但他非常重视朋友之间的感情。

九、如何查看相似度分数

Retriever 默认更关注返回文档本身。

如果想同时查看搜索分数,可以使用:

ini 复制代码
const scoreResults =
  await vectorStore.similaritySearchWithScore(question, 3);

返回结果通常类似:

ini 复制代码
[
  [Document, 0.15],
  [Document, 0.28],
  [Document, 0.41],
];

每一项包含:

复制代码
文档
分数

可以这样遍历:

javascript 复制代码
scoreResults.forEach(([doc, score], index) => {
  console.log(`第 ${index + 1} 条`);
  console.log('内容:', doc.pageContent);
  console.log('分数:', score);
});

需要注意,不同向量库中的分数含义不一定完全相同。

有的返回:

复制代码
相似度

分数越高越相似。

有的返回:

复制代码
距离

分数越低越相似。

因此不能看到一个分数后,就默认它一定是相似度。

在这次 Demo 中,如果底层返回的是距离,可以写:

ini 复制代码
const similarity = 1 - score;

例如:

ini 复制代码
const scoredResult = scoreResults[index];

const score = scoredResult
  ? scoredResult[1]
  : null;

const similarity =
  score != null
    ? (1 - score).toFixed(4)
    : 'N/A';

这段代码的作用是:

  1. 找到对应的搜索结果
  2. 取出结果中的距离分数
  3. 使用 1 - score 转换为更直观的相似度
  4. 保留四位小数
  5. 没有分数时显示 N/A

不过,这种转换只适用于分数范围符合对应距离定义的情况。

真正使用其他向量数据库时,需要先阅读它的文档,确认分数的含义。


十、把检索结果拼成 Context

检索到文档后,还不能直接结束。

因为 Retriever 只负责找资料,真正生成回答的仍然是聊天模型。

我们需要先把文档整理成上下文:

javascript 复制代码
const context = docs
  .map(
    (doc, i) =>
      `[片段${i + 1}]\n${doc.pageContent}`
  )
  .join('\n\n-----\n\n');

例如生成结果可能是:

css 复制代码
[片段1]
光光是一个性格活泼的小男孩,很喜欢帮助别人。

-----

[片段2]
小美遇到困难时,光光主动陪着她一起想办法。

-----

[片段3]
光光虽然偶尔有些冲动,但他非常重视朋友之间的感情。

这里使用:

c 复制代码
.map()

把每条文档转换成带编号的字符串。

再使用:

bash 复制代码
.join()

把所有片段连接起来。

中间加入分隔线,可以帮助模型更清楚地区分不同文档。

原代码中需要注意一个语法问题。

错误写法:

javascript 复制代码
const context = docs
  .map((doc, i) => `[片段${i}]\n${doc.pageContent}`);
  .join('\n\n-----\n\n');

.map() 后面不应该提前写分号。

正确写法是:

javascript 复制代码
const context = docs
  .map(
    (doc, i) =>
      `[片段${i + 1}]\n${doc.pageContent}`
  )
  .join('\n\n-----\n\n');

因为 .join() 是继续调用 .map() 返回的数组。


十一、构建最终 Prompt

有了 context 之后,就可以把资料和用户问题一起交给模型。

javascript 复制代码
const prompt = `
你是一位擅长讲友情故事的老师。

请基于下面提供的故事片段回答问题,
使用温暖、生动、容易理解的语言。

如果故事片段中没有提到相关内容,
请回答:
"这个故事里还没有提到这个细节。"

故事片段:

${context}

用户问题:

${question}

老师的回答:
`;

这个 Prompt 主要包含四个部分:

1. 角色

复制代码
你是一位擅长讲友情故事的老师

用于控制回答风格。

2. 回答要求

复制代码
使用温暖、生动、容易理解的语言

用于控制输出表达。

3. 知识边界

复制代码
如果故事片段中没有提到,就明确说明没有提到

用于减少模型编造。

4. 检索上下文

bash 复制代码
${context}

这就是 RAG 中最关键的"增强信息"。


十二、调用聊天模型生成回答

最后调用 ChatOpenAI

ini 复制代码
const response = await model.invoke(prompt);

console.log(response.content);

模型收到的已经不只是用户问题,而是:

diff 复制代码
角色要求
+
回答规则
+
检索到的故事片段
+
用户问题

因此最终生成的回答会更加贴近知识库。

例如:

复制代码
光光是一个活泼、热心又重感情的人。

从故事中可以看出,当朋友遇到困难时,
他愿意主动陪伴并帮助对方。

虽然他有时候会有些冲动,
但他非常珍惜朋友之间的感情。

到这里,一个最小 RAG 闭环就完成了。


十三、完整执行流程

整个 Demo 的执行过程可以总结为:

markdown 复制代码
1. 准备 Document
2. 初始化 Embedding 模型
3. 将文档转换成向量
4. 把向量保存到 VectorStore
5. 创建 Retriever
6. 接收用户问题
7. 将问题转换成向量
8. 检索相似文档
9. 拼接 Context
10. 构建 Prompt
11. 调用 ChatOpenAI
12. 输出最终答案

对应流程图:

复制代码
Document
   ↓
Embedding
   ↓
MemoryVectorStore
   ↓
Retriever
   ↓
Relevant Documents
   ↓
Context
   ↓
Prompt
   ↓
ChatOpenAI
   ↓
LLM Answer

十四、调试过程中遇到的问题

这次 Demo 中遇到的几个问题都很典型。

1. MemoryVectorStore 导入路径错误

错误写法:

javascript 复制代码
import { MemoryVectorStore } from '@langchain/classic';

正确写法:

javascript 复制代码
import { MemoryVectorStore } from '@langchain/classic/vectorstores/memory';

有些包不会在根入口导出全部模块,因此需要根据官方模块路径导入。


2. 环境变量名称拼写不一致

.env 中写的是:

ini 复制代码
EMBEDDINGS_MODEL_NAME=text-embedding-v4

代码中就必须写:

arduino 复制代码
process.env.EMBEDDINGS_MODEL_NAME

如果误写成:

arduino 复制代码
process.env.EMBEDDINS_MODEL_NAME

最终读取到的就是:

javascript 复制代码
undefined

环境变量名、函数名和变量名都必须保持完全一致。


3. question 和 questions 混用

例如:

ini 复制代码
const question = '光光是怎样的人?';

后面却写:

arduino 复制代码
retriever.invoke(questions);

此时 questions 并不存在,会直接报错。

正确写法:

arduino 复制代码
retriever.invoke(question);

4. scoreResults 和 scoredResults 混用

定义变量:

ini 复制代码
const scoreResults =
  await vectorStore.similaritySearchWithScore(
    question,
    3
  );

后续就应该继续使用:

复制代码
scoreResults

不能突然改成:

复制代码
scoredResults

JavaScript 不会自动判断它们是同一个变量。


5. 不要直接打印完整 vectorStore

下面这种调试方式不够安全:

arduino 复制代码
console.log(vectorStore);

因为对象内部可能包含:

  • API Key
  • 模型配置
  • 请求客户端
  • 接口地址
  • 内部缓存

更安全的方式是只打印需要的信息:

php 复制代码
console.log({
  documentCount: documents.length,
  retrieverK: 3,
  question,
});

或者只打印检索结果摘要:

javascript 复制代码
docs.forEach((doc, index) => {
  console.log({
    index: index + 1,
    preview: doc.pageContent.slice(0, 50),
    metadata: doc.metadata,
  });
});

十五、RAG 中每个模块的关系

通过这个 Demo,可以更清楚地理解每个模块的职责。

Document

负责表示知识库中的原始内容。

复制代码
它解决的是:
知识以什么结构保存?

Embedding

负责把自然语言转换成数字向量。

复制代码
它解决的是:
怎样让机器比较文本语义?

VectorStore

负责保存向量并执行相似度搜索。

复制代码
它解决的是:
向量存在哪里,怎样快速搜索?

Retriever

负责把搜索能力包装成统一的文档检索接口。

复制代码
它解决的是:
怎样根据问题取回相关文档?

Context

负责整理检索结果。

复制代码
它解决的是:
怎样把资料清晰地提供给大模型?

Prompt

负责定义角色、回答要求和知识边界。

复制代码
它解决的是:
怎样告诉模型应该如何使用资料?

ChatOpenAI

负责根据上下文生成最终自然语言答案。

复制代码
它解决的是:
怎样把资料组织成可读回答?

它们之间不是互相替代的关系,而是一个完整流水线中的不同环节。


十六、为什么 RAG 不是让模型"学会"知识

很多人第一次接触 RAG 时,会以为把文档放进向量库之后,大模型就已经学会了这些内容。

其实并不是。

RAG 通常不会修改模型参数,也不会重新训练模型。

它做的是:

复制代码
用户提问时临时检索资料

然后:

复制代码
把资料放进模型的上下文窗口

因此 RAG 更接近"开卷考试"。

普通大模型问答像闭卷考试,模型只能根据已有参数回答。

RAG 像开卷考试,回答之前可以先从指定资料中查找相关内容。


十七、MemoryVectorStore 的局限

MemoryVectorStore 很适合入门,但也有明显限制。

1. 数据无法持久化

程序关闭后,内存中的向量就会消失。

下次运行还需要重新进行 Embedding。

2. 不适合大量数据

当文档数量越来越多时,全部存储在内存中会占用较多资源。

3. 缺少复杂搜索能力

生产级向量数据库通常还支持:

  • metadata 过滤
  • 分布式存储
  • 索引优化
  • 权限管理
  • 持久化
  • 多租户
  • 混合搜索
  • 数据更新和删除

因此真正做项目时,可以考虑:

  • PostgreSQL + pgvector
  • Chroma
  • Milvus
  • Qdrant
  • Pinecone
  • Elasticsearch 向量检索

十八、下一步应该学习什么

完成最小 RAG 闭环后,下一步可以继续学习以下内容。

1. 文档切分

真实文档通常很长,不能把整篇文章直接作为一个 Document

需要把它切成多个较小的 Chunk。

例如:

yaml 复制代码
一篇 5000 字文章
   ↓
切成 20 个文本片段
   ↓
每个片段单独生成 Embedding

需要继续理解:

  • chunkSize
  • chunkOverlap
  • 递归字符切分
  • 语义切分

2. metadata 过滤

有时不仅要根据语义搜索,还需要限制文档范围。

例如:

yaml 复制代码
只搜索第三章
只搜索角色为光光的内容
只搜索 2026 年后的文档

这就需要使用 metadata 过滤。


3. 相似度阈值

并不是所有搜索结果都适合直接提供给模型。

可以设置一个阈值,只保留相关性足够高的文档。

否则即使知识库里没有答案,检索器也可能强行返回几条不相关内容。


4. Rerank

向量检索召回的结果不一定排序最准确。

可以先召回较多文档:

css 复制代码
Top 20

再使用 Rerank 模型重新排序,选出最相关的几条:

css 复制代码
Top 3

流程变成:

复制代码
向量召回
   ↓
Rerank 重排
   ↓
最终上下文

5. 对话历史

现在的 Demo 每次只处理一个独立问题。

后续可以加入多轮对话:

复制代码
用户:光光是一个怎样的人?
助手:他活泼、热心,也很重感情。

用户:那他有什么缺点?

第二个问题里的"他"指的是光光。

系统需要结合历史消息,才能理解用户真正的问题。


6. 来源引用

真实项目中,回答最好附带资料来源。

例如:

复制代码
根据第 2 章和第 3 章的内容,
光光是一个热心并重视友情的人。

这样用户可以知道答案来自哪里,也方便验证。


总结

通过这次 LangChain Demo,我真正理解了 RAG 的完整执行过程:

复制代码
Document
→ Embedding
→ VectorStore
→ Retriever
→ Context
→ LLM Answer

其中最重要的不是单独记住某一个类或方法,而是理解这些模块为什么要连接在一起。

Document 负责组织知识。

Embedding 负责把文本转换成可以比较的向量。

VectorStore 负责保存和搜索向量。

Retriever 负责根据问题取回相关资料。

ContextPrompt 负责把资料交给模型。

最后由 ChatOpenAI 生成自然语言回答。

RAG 的本质不是让模型记住更多内容,而是让模型在回答之前,先找到正确的资料。

这也是后续学习企业知识库、智能客服、文档问答和 Agent 系统的重要基础。

相关推荐
蓝悦无人机1 小时前
自动驾驶论文解读:深度学习自动驾驶系统预测与规划集成综述
人工智能·深度学习·自动驾驶
AC赳赳老秦1 小时前
GTD 工作法落地:用 OpenClaw 搭建收集-整理-执行-复盘全流程自动化工作流
大数据·运维·人工智能·信息可视化·自动化·deepseek·openclaw
Σίσυφος19001 小时前
halcon 细节(可用于面试)
人工智能
DataX_ruby821 小时前
DCMM 2.0 贯标评估全解读:数据中台如何支撑九大能力域(2026版)
大数据·运维·人工智能·数据治理·数据中台
前端进阶之旅1 小时前
Next.js 16.3 新特性全解析 AI 驱动开发与 Instant Navigations 实战指南
开发语言·javascript·人工智能
狂师1 小时前
还在手写测试报告?推荐一款 AI 测试Skill:只需一句指令,自动生成专业定制化测试报告!
人工智能·测试·全栈
Leo.yuan1 小时前
数据目录和数据字典有什么区别?一文讲清
大数据·数据库·人工智能
fuquxiaoguang2 小时前
合规的“智能化”拐点:当金融监管遇到AI中间件
人工智能·金融监管·金融合规
phltxy2 小时前
LangChain从模型调用到应用编排
人工智能·深度学习·语言模型·langchain