RAG实战:从零打造智能知识库问答系统

RAG 实战:用 LangChain 从零打造智能知识库问答系统

RAG 不是让大模型重新学习一遍知识,而是在它回答之前,先把相关资料找出来,再把资料放进 Prompt。本文从大模型幻觉讲起,结合"光光和东东"的故事数据,完整拆解一个 LangChain RAG Demo。

【首图:请插入 rag-flow-cartoon.svg,放在标题下方】

一、为什么需要 RAG?

大模型能回答什么,主要取决于训练数据。如果问题涉及企业内部文档、项目规范、产品最新说明或实时业务数据,模型可能根本没有这些知识。

更麻烦的是,模型不一定会直接说"不知道",而可能生成一段听起来合理、实际上没有依据的内容,这就是大模型幻觉。

例如,用户问:

text 复制代码
光光和东东是怎么成为朋友的?

如果模型没有读过这段故事,它可能会根据常见的叙事模式自行猜测。RAG 的做法是先从故事知识库里找出相关片段,再让模型根据片段回答。

text 复制代码
用户问题
  -> 检索知识库中的相关片段
  -> 把片段加入 Prompt
  -> 大模型基于资料生成回答

RAG 的全称是 Retrieval-Augmented Generation

  • Retrieval:检索;
  • Augmented:增强上下文;
  • Generation:生成回答。

二、RAG 和微调有什么区别?

微调适合改变模型的任务能力、输出格式或表达风格;RAG 更适合给模型补充经常变化的外部知识。

对比项 RAG 微调
主要解决 让模型获得新资料 改变模型能力或风格
知识更新 更新知识库即可 通常需要重新训练
成本 相对较低 通常更高
是否方便溯源 可以关联原文和元数据 通常不容易追踪来源
典型场景 企业文档、产品手册、客服知识库 固定格式、特定任务、稳定风格

如果目标只是"让模型知道公司的内部资料",通常应该先考虑 RAG,而不是直接微调。

【配图:请插入 rag-vs-finetune-cartoon.svg,放在本节表格之后】

三、RAG 的核心链路

RAG 可以拆成入库和查询两个阶段。

3.1 入库阶段

text 复制代码
原始文档
  -> 文档切分
  -> Embedding 向量化
  -> 保存向量、原文和元数据

真实项目中的文档可能来自 PDF、网页、Markdown、数据库或企业内部系统。长文档通常需要按标题、章节、段落或固定长度切成多个片段。

切得太大,检索结果会包含大量无关内容;切得太小,又可能破坏语义。因此,文档切分是 RAG 效果的第一个关键因素。

3.2 查询阶段

text 复制代码
用户问题
  -> 问题向量化
  -> 和文档向量计算相似度
  -> 返回 Top-K 片段
  -> 拼接成上下文
  -> 交给大模型生成答案

四、向量搜索是怎么工作的?

关键词搜索依赖字面匹配,但用户问题和文档的表达方式可能不同。例如"他们的友谊是怎么变深的"和"他们在比赛中互相帮助,关系变得更加亲密",关键词不同,语义却很接近。

Embedding 模型会把文本转换成一组数字,也就是向量。语义接近的文本,通常会在向量空间中更接近。

text 复制代码
"东东喜欢画画" -> [0.12, -0.34, 0.56, ...]

查询时需要做两次向量化:

  1. 入库时,把每个文档片段转成向量;
  2. 查询时,把用户问题转成向量。

然后计算问题向量和文档向量之间的相似度。常见方法是余弦相似度:

text 复制代码
cos(A, B) = A · B / (|A| × |B|)

不要把向量维度简单理解成"维度越高,效果越好"。最终效果还取决于 Embedding 模型、文档切分、数据质量和检索策略。

【配图:请插入 rag-vector-search-cartoon.svg,放在本节公式之后】

五、项目结构和环境配置

text 复制代码
rag-demo/
├── .env
├── src/
│   ├── index.mjs
│   └── custom-embeddings.mjs
└── readme.md

.env 示例:

env 复制代码
MODEL_NAME=qwen-plus
EMBEDDINGS_MODEL_NAME=text-embedding-v3
OPENAI_API_KEY=your-api-key
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1

不要把真实 API Key 提交到 Git 仓库。生产环境还应通过后端服务保存密钥,避免直接暴露在浏览器端。

六、逐段解析核心代码

6.1 导入依赖

javascript 复制代码
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
import { CustomEmbeddings } from "./custom-embeddings.mjs";
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
import { Document } from "@langchain/core/documents";
依赖 作用
dotenv/config .env 配置加载到 process.env
ChatOpenAI 调用聊天模型生成回答
CustomEmbeddings 提供 Embedding 接口
MemoryVectorStore 在内存中保存和搜索向量
Document 封装正文和元数据

6.2 初始化聊天模型

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

temperature: 0 通常能让回答更稳定,但它不能保证模型绝对没有幻觉。是否产生幻觉,还取决于检索结果、Prompt、资料质量和模型本身。

baseURL 允许 LangChain 通过兼容 OpenAI 格式的接口访问不同厂商的模型服务。

6.3 初始化 Embedding

javascript 复制代码
const embeddings = new CustomEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDINGS_MODEL_NAME,
  baseURL: process.env.OPENAI_BASE_URL,
});

这里的 embeddings 不是负责写答案的聊天模型,而是负责把文本转换成向量。

入库和查询必须使用同一套向量空间。也就是说,文档和问题最好使用同一个 Embedding 模型,否则它们的向量不一定可以直接比较。

6.4 创建 Document

javascript 复制代码
const documents = [
  new Document({
    pageContent: `光光是一个活泼开朗的小男孩,他特别擅长踢足球。`,
    metadata: {
      chapter: 1,
      character: "光光",
      type: "角色介绍",
      mood: "活泼",
    },
  }),
  new Document({
    pageContent: `东东是光光最好的朋友,他们从幼儿园就认识了。`,
    metadata: {
      chapter: 2,
      character: "东东",
      type: "角色介绍",
      mood: "温馨",
    },
  }),
];

一个 Document 主要有两个部分:

  • pageContent:正文,会参与向量化和检索;
  • metadata:附加信息,可用于过滤、分类和显示来源。

例如,可以用 chapter 标记章节,用 character 标记角色。元数据通常不参与 Embedding,但在调试和回答溯源时非常有用。

【配图:请插入 document-structure-cartoon.svg,放在本节代码之后】

6.5 创建向量存储

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

这行代码会遍历 documents,调用 Embedding 模型把每个 pageContent 转成向量,然后把向量、原文和元数据保存起来。

text 复制代码
Document[]
  -> embedDocuments()
  -> 向量数组
  -> { content, embedding, metadata }

MemoryVectorStore 只把数据放在当前进程的内存里,程序重启后数据会丢失。因此它适合学习和测试,生产环境应使用持久化向量数据库。

【配图:请插入 vector-db-compare-cartoon.svg,放在本节最后】

6.6 创建 Retriever

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

Retriever 可以理解成"向量检索接口"。k: 3 表示每次返回最相关的 3 个文档片段。

k 太小,可能漏掉必要信息;k 太大,又会把无关内容带进 Prompt。实际值需要结合片段长度、问题类型和模型上下文窗口调整。

6.7 执行检索

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

调用 invoke 后,内部大致会完成:

text 复制代码
问题
  -> embedQuery(question)
  -> 和所有文档向量计算相似度
  -> 排序
  -> 返回前 k 个 Document

调用者不需要手动处理向量化和排序,只需要处理返回的 Document[]

6.8 查看相似度评分

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

返回结果通常是:

javascript 复制代码
[
  [document1, score1],
  [document2, score2],
]

这里必须注意:不同向量库、不同版本的实现可能使用不同的评分定义。有的返回相似度,分数越大越相关;有的返回距离,分数越小越相关。

因此,不能看到一个分数就直接称为"百分比"。使用前应查看当前向量库的实现或官方文档,确认分数的含义和方向。

对于本示例,如果确认返回的是距离,可以这样转换:

javascript 复制代码
const similarity = score != null
  ? (1 - score).toFixed(4)
  : "N/A";

但这个转换只适用于明确知道 score 是距离且范围符合预期的情况。

6.9 调试检索结果

javascript 复制代码
docs.forEach((doc, index) => {
  console.log(`[文档 ${index + 1}]`);
  console.log(`内容:${doc.pageContent.substring(0, 50)}...`);
  console.log(`章节:${doc.metadata.chapter}`);
  console.log(`角色:${doc.metadata.character}`);
});

调试 RAG 时,不要只看最后的回答。先检查检索出来的文档是否真正相关,能够更快定位问题到底出在检索、Prompt 还是生成阶段。

七、为什么示例使用 CustomEmbeddings?

学习代码中的自定义 Embedding 通过正弦函数生成向量,核心逻辑类似这样:

javascript 复制代码
export class CustomEmbeddings {
  constructor() {
    this.dimension = 1536;
  }

  async embedDocuments(texts) {
    return texts.map((text, index) => {
      const seed = text.length * 1000 + index;
      return Array.from(
        { length: this.dimension },
        (_, i) => Math.sin(seed + i) * 0.5,
      );
    });
  }

  async embedQuery(text) {
    const seed = text.length * 1000;
    return Array.from(
      { length: this.dimension },
      (_, i) => Math.sin(seed + i) * 0.5,
    );
  }
}

这段代码的价值是演示 LangChain 所需要的 Embedding 接口,而不是提供真正的语义理解能力。

它有一个非常重要的限制:主要根据文本长度生成向量,两个长度相同但意思完全不同的句子,可能得到相同或非常接近的向量。因此,使用它得到的检索结果不能代表真实 RAG 效果。

生产环境必须替换成真实 Embedding 模型,并确保文档向量和问题向量使用同一模型、同一维度和兼容的向量空间。

八、构建上下文并生成回答

先把检索到的文档片段拼接成上下文:

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

然后把上下文放进 Prompt:

javascript 复制代码
const prompt = `
请严格根据下面的资料回答问题。
如果资料中没有答案,请直接说"资料中没有找到相关信息",不要编造。

资料:
${context}

问题:
${question}

请用自然、友好的语言回答,并尽量说明答案来自哪一个片段。
`;

最后调用模型:

javascript 复制代码
const answer = await model.invoke(prompt);

console.log("[AI 回答]");
console.log(answer.content);

到这里,RAG 的三个步骤就完整了:

  • retriever.invoke:检索;
  • context 加入 Prompt:增强;
  • model.invoke:生成。

九、完整代码主线

把前面的步骤压缩起来,核心逻辑就是:

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

const retriever = vectorStore.asRetriever({ k: 3 });
const docs = await retriever.invoke(question);

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

const prompt = `
请根据资料回答问题,不要编造信息。

资料:
${context}

问题:${question}
`;

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

十、RAG 不等于绝对不会幻觉

RAG 能为模型提供外部资料,但不能保证答案百分之百正确。常见问题包括:

  • 文档切分破坏了完整语义;
  • 检索没有找到真正相关的片段;
  • k 设置不合理;
  • 知识库本身过期或存在错误;
  • Prompt 没有明确要求模型以资料为依据;
  • 模型对资料理解错误。

因此,RAG 效果是多个环节共同决定的:

text 复制代码
知识库质量 × 切分策略 × Embedding 模型
× 检索策略 × Prompt 设计 × 生成模型

十一、生产环境优化方向

11.1 使用真实 Embedding 模型

javascript 复制代码
import { OpenAIEmbeddings } from "@langchain/openai";

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

真实 Embedding 模型才具备语义表示能力。选择时要确认模型接口、向量维度、语言覆盖和价格是否适合项目。

11.2 使用持久化向量数据库

方案 特点
Chroma 轻量,适合开发和小型项目
PostgreSQL + pgvector 适合已有 PostgreSQL 技术栈的项目
Milvus 面向较大规模向量检索
Pinecone 托管服务,减少基础设施维护

11.3 改进检索质量

常见方向包括:

  • 调整切分长度和重叠区间;
  • 使用元数据过滤;
  • 关键词检索和向量检索混合召回;
  • 对召回结果进行重排序;
  • 设置最低相关性阈值;
  • 为回答增加来源和引用片段。

不要直接假设所有向量库都支持相同的 filter 或重排序 API。具体写法应以当前使用的向量库和 LangChain 版本文档为准。

十二、总结

RAG 的本质是:

大模型回答之前,先把与问题相关的外部知识找出来,再让模型基于这些资料生成回答。

组件 作用 类比
Document 封装文本和元数据 书本的一页
Embedding 把文本转换成向量 语义翻译官
VectorStore 保存和搜索向量 图书馆
Retriever 找出相关片段 图书管理员
ChatModel 生成自然语言回答 老师
Prompt 组织问题和背景资料 答题纸

完整链路可以记成:

text 复制代码
文档切分
  -> Embedding 向量化
  -> VectorStore 存储
  -> Retriever 检索
  -> Prompt 增强
  -> ChatModel 生成

学习 RAG 时,先抓住"检索、增强、生成"这条主线,再去理解 LangChain 的 API。框架只是把这些步骤组织起来,真正决定效果的,仍然是知识库、检索策略和提示词设计。

相关推荐
李明卫杭州9 分钟前
Vue2 与 Vue3 自定义指令详解:钩子变化、迁移指南与实战示例
前端·javascript·vue.js
七月丶21 分钟前
线上出 Bug,你还在让用户"清一下缓存"?我用一个插件让每个请求自报版本
前端·javascript·面试
不简说25 分钟前
JS 代码技巧 vol.3 — 10 个防抖节流深挖,小白到老炮的踩坑路
前端·javascript·面试
小智社群34 分钟前
VR漫游视频完整版
前端·javascript·音视频
nicole bai2 小时前
vue 自定义模块内容
前端·javascript·vue.js
西西学代码2 小时前
Flutter---StreamBuilder案例
javascript·windows·flutter
何时梦醒4 小时前
🤖 RAG 文档分割器(Splitter)深度学习笔记 —— 从零搭建知识库文档处理流水线
前端·javascript·人工智能
ss2734 小时前
Vue 完整版本发展史时间线(2013–2026)|版本迭代里程碑+核心特性对照表
前端·javascript·vue.js
用户938515635074 小时前
知识库预处理实战:从URL加载到语义分块的全链路解析
javascript·人工智能·全栈
weedsfly4 小时前
前端开发中的命令模式——从点餐退单到异步任务调度
前端·javascript·面试