从零实现一个可运行的 RAG - LangChain、内存向量库与检索增强生成

在知识库问答、企业文档助手、产品文档搜索这些场景里,大模型本身并不会自动知道我们的私有资料。更麻烦的是,它不知道时仍可能给出看似合理的回答。

RAG(Retrieval-Augmented Generation,检索增强生成)解决的正是这个问题:在模型回答之前,先从知识库检索相关片段,再把片段作为上下文交给模型生成答案。

本文结合 rag-demo/src/index.mjs 这个最小可运行项目,完整拆解一次 RAG 请求从"文档入库"到"答案输出"的过程,并说明从 Demo 走向实际业务时要补齐什么。

一、先建立正确的心智模型

一条 RAG 链路可以概括为:

text 复制代码
原始文档
  -> 文档切块(Document)
  -> Embedding 向量化
  -> 向量库

用户问题
  -> Embedding 向量化
  -> 相似度检索 Top K 文档
  -> 将文档拼入 Prompt
  -> LLM 生成基于资料的回答

这里有两个模型角色:

  • Embedding 模型:负责把文档和问题转换成向量,用于寻找语义相近的内容。
  • 聊天/生成模型(LLM):负责阅读检索到的资料,组织最终自然语言答案。

它们的职责不同。Embedding 模型不直接写答案,LLM 也不应该承担"在大量资料中逐页找答案"的工作。

二、项目依赖和入口

项目使用的关键依赖是:

json 复制代码
{
  "@langchain/openai": "^1.5.5",
  "@langchain/core": "^1.2.3",
  "@langchain/classic": "^1.0.40",
  "dotenv": "^17.4.2"
}

其中:

  • @langchain/openai 提供 ChatOpenAIOpenAIEmbeddings
  • @langchain/core 提供标准化的 Document 类型。
  • @langchain/classic 提供 MemoryVectorStore 内存向量库。
  • dotenv.env 中的模型配置加载到 process.env

入口文件一开始创建了两个客户端:

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

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

temperature: 0 适合知识问答。它的目标是忠实、稳定地复述检索资料,而不是创作多种不同版本的回答。

说明:这段 Demo 假定聊天模型和嵌入模型共享 OpenAI 兼容的配置。实际项目中,两种模型可能来自不同平台,应分别配置 API Key、Base URL 和模型名。

三、Document:知识库的最小单位

项目没有直接把整篇故事放进 Prompt,而是拆成了 7 个 Document。一个文档由正文和元数据组成:

js 复制代码
new Document({
  pageContent: "东东和光光从幼儿园就认识了,他们一起度过了无数个快乐的时光。",
  metadata: {
    chapter: 2,
    character: "东东",
    type: "角色介绍",
    mood: "温馨",
  },
});

pageContent 是会被向量化、被检索、也会进入 Prompt 的内容。

metadata 不参与向量语义计算,但在真实业务中非常重要:

  • 可以按 chapter、产品版本、部门或时间范围过滤;
  • 可以在答案中展示来源;
  • 可以根据权限字段避免检索到无权查看的资料;
  • 可以在文档更新时定位并删除旧向量。

当前示例的每一段故事天然就是一块语义完整的文本。真实 PDF、网页和长文档通常还需要额外的加载器和文本切分器,按标题、段落和句子边界切块,并保留少量 overlap,避免关键上下文被切断。

四、Retrieval:把文档变成可语义搜索的知识库

项目的关键代码如下:

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

这一行并不只是"创建一个对象",它内部完成了以下事情:

text 复制代码
documents 中每一个 pageContent
  -> embeddings.embedDocuments(...)
  -> 得到一组数值向量
  -> 连同原文和 metadata 写入 MemoryVectorStore

向量可被理解成文本的语义坐标。比如"从幼儿园就认识"和"怎么成为朋友的"字面不完全相同,但嵌入模型会让它们在向量空间中相对接近。

这就是向量检索相对纯关键词匹配的价值:用户换一种自然表达,系统仍然有机会找到正确资料。

4.1 从向量库得到检索器

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

检索器将"向量库怎么查"的细节包装成统一接口。k: 3 表示每个问题最多返回语义最相近的 3 个文档。

真正执行检索时只需要:

js 复制代码
const question = "东东和光光是怎么成为朋友的";
const docs = await retriever.invoke(question);

它的内部流程是:

text 复制代码
question
  -> embeddings.embedQuery(question)
  -> 与已入库的文档向量计算相似度
  -> 从高到低排序
  -> 返回前 3 个 Document

针对这个问题,第二章"从幼儿园就认识"的片段应当是重要召回结果。

4.2 Top K 应该取多少?

k 没有通用最优值:

  • k 太小,答案所需资料可能没有被召回;
  • k 太大,噪声更多、Prompt 更长、模型更容易被无关信息干扰,成本也更高。

实际项目通常从 3 到 5 开始,用真实用户问题组成的评测集调整,而不是凭感觉固定一个数字。

4.3 如何看待相似度分数

项目额外调用了:

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

这样做适合调试:可以把"模型为什么找到了这些文档"展示出来。

需要注意,当前版本的 MemoryVectorStore 默认使用余弦相似度,返回的 score 是相似度,数值越大表示越相近 。因此展示时可以直接输出 score;不能把它一概当成"距离"再计算 1 - score。不同向量数据库和 API 对 score 的定义可能不同,接入新存储时必须先确认指标语义。

五、Augmented:把检索结果变成模型上下文

检索器返回的是 Document[]。项目将其拼接成上下文:

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

然后放入 Prompt:

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

故事片段:
${context}

问题:${question}

老师的回答:`;

这一步就是 Augmented(增强):通过外部资料增强当前请求,而不是期待模型"记住"所有文档。

其中最重要的约束是:资料没有提到时明确说不知道。这不能彻底消除幻觉,但能给模型划清回答边界。生产场景还应要求模型返回引用的文档 ID 或链接,让用户能核验答案。

六、Generation:调用 LLM 生成最终答案

最后一步非常直接:

js 复制代码
const response = await model.invoke(prompt);
console.log(response.content);

模型现在看到的不是一个孤立问题,而是"问题 + 检索到的故事片段 + 回答规则"。它可以据此输出类似下面的答案:

东东和光光从幼儿园就认识。虽然一个爱运动,一个喜欢读书画画,但他们一起经历了许多快乐时光,并在互相帮助中成为了最好的朋友。

到这里,Retrieval、Augmented、Generation 三部分全部闭环。

七、Demo 到生产:缺的不是一行模型调用

这个项目很好地展示了核心流程,但生产化还需要补齐以下能力:

Demo 做法 生产化方向
手写 7 个 Document 接入 PDF、网页、数据库、对象存储,并建立增量同步流程
内存向量库 使用 pgvector、Milvus、Qdrant、Pinecone 等持久化存储
仅向量召回 加入关键词检索、元数据过滤、查询改写、rerank
仅输出文本 返回引用来源、置信提示、结构化答案
无评测 用真实问题评测召回率、忠实度、引用正确性和延迟
无权限控制 按用户权限在检索前过滤文档

特别是"答案不准"时,不要立刻换一个更大的生成模型。应先定位问题:

text 复制代码
最终答案错误
  -> 检索到的资料是否正确?
       否:检查文档质量、切块、Embedding、Top K、过滤、rerank
       是:检查 Prompt、上下文是否超长、模型是否遵循约束
  -> 知识库是否包含正确资料?
       否:补充或更新文档,并重新建立索引

这套排查顺序能避免"模型答错了就盲目换模型"的低效做法。

八、面试中如何讲这个项目

可以这样概括:

我实现过一个最小 RAG 问答链路:使用 LangChain 的 Document 承载文本和元数据,通过 Embedding 将文档写入向量库;用户提问后将问题向量化,召回 Top K 相关片段;再把片段作为上下文传给 LLM,并约束模型在资料不足时明确拒答。这个 Demo 使用内存向量库验证流程;若进入生产,我会增加文档切分、持久化向量库、权限过滤、混合检索、重排序、引用和离线评测。

这段回答体现的重点不是"会调用一个 SDK",而是你理解 RAG 是一个需要持续评估的检索与生成系统。

总结

RAG 的价值不在于把文档简单塞进 Prompt,而在于建立一套可检索、可追踪、可评估的知识访问流程:

  1. 用合适粒度组织文档;
  2. 用 Embedding 建立语义索引;
  3. 用检索器找出最相关证据;
  4. 用 Prompt 约束模型基于证据回答;
  5. 用日志、引用和评测持续优化。

掌握这条链路后,再替换模型、向量数据库或框架,核心的工程判断仍然成立。

相关推荐
mONESY1 小时前
Vue3流式调用大模型接口完整实践
javascript
饼饼饼1 小时前
React 组件间传参好痛苦?试试这一套"优雅组合拳":Context 与 Refs 实战
前端
qq_419854052 小时前
空间网格管理器
前端·javascript·html
qingmiaozhuan2 小时前
电脑录屏没有声音还能补救吗?音频采集链路详解及4种技术方案
前端·电脑·音视频·开发工具·电脑录屏·电脑录屏软件
天若有情6732 小时前
Dev_Portfolio 个人主页html网页源码分享!!!
前端·html·源码·个人主页
李剑一3 小时前
ant-design-vue 1.x 版本换肤功能开发
前端·vue.js·ant design
京东云开发者3 小时前
【Coding生态】从代码托管到 AI 能力底座:与Coding一起共建 AI 研发生态
前端
guhy fighting3 小时前
x-www-form-urlencoded,form-data,raw 表单请求格式的区别
前端·表单请求格式