RAG 知识库问答:从概念到代码的完整实现

RAG 知识库问答:从概念到代码的完整实现

一、问题的起源:LLM 的两个致命缺陷

大语言模型(LLM)在 2024-2025 年席卷了软件开发,但每个使用过 GPT 类产品的人都能感知到两个"不舒服":

  1. 幻觉(Hallucination):模型会自信地编造不存在的信息。你问"本项目的架构是什么",它答得头头是道------全是虚构的。

  2. 知识截止:训练数据有时效性。问"React 19 有哪些新特性",如果模型训练于 2023 年,它压根不知道 React 19 的存在。

RAG(Retrieval-Augmented Generation,检索增强生成) 就是为解决这两个问题而生的。它的核心思想一句话就能说完:

在让 LLM 回答之前,先帮它"翻书"------从外部知识库中检索相关文档,塞进 prompt,再让模型基于这些真实素材作答。


二、RAG 的三步架构

vbnet 复制代码
┌──────────────────────────────────────────────┐
│                    用户提问                      │
│          "React 是什么?"                        │
└──────────────────┬───────────────────────────┘
                   │
       ┌───────────▼───────────┐
       │  Step 1: RETRIEVE     │  ← 检索
       │  向量相似度搜索         │
       │  从知识库找最相关的文档   │
       └───────────┬───────────┘
                   │  相关文档
       ┌───────────▼───────────┐
       │  Step 2: AUGMENT      │  ← 增强
       │  将文档注入 Prompt      │
       │  "请基于以下资料回答..."   │
       └───────────┬───────────┘
                   │  增强后的 Prompt
       ┌───────────▼───────────┐
       │  Step 3: GENERATE     │  ← 生成
       │  LLM 基于上下文回答问题  │
       │  输出最终答案            │
       └───────────┬───────────┘
                   │
                   ▼
              最终答案

这三个步骤的首字母就是 R-A-G。下面结合本项目代码逐一深入。


三、Step 1 --- Retrieve(检索):从知识库中"翻书"

3.1 知识库的构建

在检索之前,需要先有一个可被检索的知识库 。本项目的知识库由硬编码的三条 Document 构成(ai.service.ts:134-147):

typescript 复制代码
const vectorStore = await MemoryVectorStore.fromDocuments(
    [
        new Document({
            pageContent: 'React是一个用于构建用户界面的JavaScript库。'
        }),
        new Document({
            pageContent: 'NestJS是一个用于构建服务器端应用的JavaScript框架,擅长企业级开发。'
        }),
        new Document({
            pageContent: 'RAG通过检索外部知识增强大模型的回答能力。'
        }),
    ],
    this.embeddings,
)

MemoryVectorStore.fromDocuments() 做了什么?

这是 LangChain 提供的内存向量数据库。调用 fromDocuments(docs, embeddings) 时,内部执行了两步:

  1. 向量化(Embedding) :将每一篇 Document 的 pageContent 文本送入 OpenAIEmbeddingstext-embedding-ada-002 模型),生成一个 1536 维的浮点数向量
  2. 建索引(Indexing):将所有向量存储在内存数组中,等待后续相似度搜索

结果是一个包含三条记录的向量数据库:

Document 内容 向量
Doc #1 React 是一个用于构建用户界面的... [0.014, -0.009, 0.021, ...] (1536维)
Doc #2 NestJS 是一个用于构建服务器端... [0.008, 0.013, -0.017, ...] (1536维)
Doc #3 RAG 通过检索外部知识增强... [-0.011, 0.019, 0.006, ...] (1536维)

3.2 相似度搜索

当用户提问 "React 是什么?" 时(ai.service.ts:149):

typescript 复制代码
const docs = await vectorStore.similaritySearch(question, 1);

similaritySearch 的执行流程:

perl 复制代码
1. 将 question "React 是什么?" 送入 embedding 模型
   → 得到查询向量 Q = [0.013, -0.008, 0.022, ...]

2. 计算 Q 与每条文档向量的余弦相似度:
   cos(Q, Doc#1) = 0.95  ← 最高,语义匹配
   cos(Q, Doc#2) = 0.12
   cos(Q, Doc#3) = 0.23

3. 返回相似度最高的 1 条文档 → Doc#1

为什么是余弦相似度? 已经在搜索功能文章中详解过,这里不再展开。简单说:余弦相似度衡量两个向量在方向上的接近程度,范围 [-1, 1],值越接近 1 表示语义越相似。它不关心向量长度,只看方向------适合比较不同长度的文本。

3.3 内存向量数据库 vs 生产级向量数据库

本项目使用 MemoryVectorStore,优点是零配置、零依赖,适合学习和原型开发。但它有一个明显的局限:数据存在内存中,重启进程就丢失 。(本文的 rag() 方法每次调用都重新建库,恰好规避了这个问题,但代价是每次都重新调用 embedding API。)

生产环境中常见的向量数据库选型:

数据库 特点
Pinecone 全托管,零运维,按使用量计费
Weaviate 开源,支持 GraphQL 查询,可自托管
Qdrant Rust 实现,高性能,极低资源消耗
pgvector PostgreSQL 插件,与业务库合为一体
Chroma 开源,Python/JS 双语言 SDK,开发者友好

四、Step 2 --- Augment(增强):把知识注入 Prompt

检索到的文档不能直接喂给 LLM------需要构建一个结构化的 Prompt,告诉模型"基于这些资料来回答"。

ai.service.ts:153-159

typescript 复制代码
const context = docs.map(d => d.pageContent).join('\n');

const prompt = `
你是一个专业的JS工程师,请基于下面资料回答问题。
资料:${context}
问题:${question}
`;

这段代码完成了一次上下文组装

sql 复制代码
┌─────────────────────────────────┐
│ 你是一个专业的JS工程师,           │  ← 角色设定(System Prompt)
│ 请基于下面资料回答问题。           │  ← 约束指令
│                                 │
│ 资料:                           │
│ React是一个用于构建用户界面的       │  ← 检索到的外部知识
│ JavaScript库。                   │  ← (RAG 的核心注入点)
│                                 │
│ 问题:                           │
│ React 是什么?                   │  ← 用户原始问题
└─────────────────────────────────┘

Prompt 设计的四个要素

  1. 角色设定("专业的JS工程师"):锚定模型的回答立场和风格
  2. 约束指令("请基于下面资料回答问题"):这是 RAG 最关键的一句话------它告诉模型不要用训练数据中的记忆,而是严格基于提供的资料作答。这句话是防止幻觉的关键防线
  3. 知识注入资料:${context}):检索结果的"着陆点"
  4. 用户提问问题:${question}):保持用户原始问题的完整性

五、Step 3 --- Generate(生成):LLM 基于增强 Prompt 作答

ai.service.ts:161-163

typescript 复制代码
const res = await this.chatModel.invoke(prompt);
return res.content;

chatModel 是 DeepSeek 的实例(deepseek-v4-flash 模型),配置了 temperature: 0.7invoke(prompt) 将组装好的 prompt 发送给 DeepSeek,等待模型逐字生成回答。

整个过程的信息流

arduino 复制代码
问题: "React 是什么?"
        │
        ▼
┌──────────────┐
│  向量检索      │  知识库中找最相关的文档
│               │  → "React是一个用于构建用户界面的JavaScript库。"
└──────┬───────┘
       │ 检索结果
       ▼
┌──────────────┐
│  Prompt 组装   │  检索结果 + 角色设定 + 用户问题
│               │  → "请基于下面资料回答问题..."
└──────┬───────┘
       │ 增强后的 Prompt
       ▼
┌──────────────┐
│  DeepSeek     │  基于 Prompt 中的资料生成回答
│  LLM 生成     │  → "React 是一个用于构建用户界面的 JavaScript 库..."
└──────┬───────┘
       │
       ▼
   返回给用户

对比------有 RAG vs 无 RAG

维度 无 RAG(纯 LLM) 有 RAG
Prompt "React 是什么?" "你是一个JS工程师,请基于资料:React是一个用于构建用户界面的JavaScript库。回答问题:React是什么?"
信息来源 训练数据(可能过时) 外部知识库(实时、可控)
答题准确性 无法验证 可溯源到指定资料
幻觉风险 低(被 prompt 约束)

六、控制器层:RAG 接口

AIController:52-59

typescript 复制代码
@Post('rag')
async rag(@Body() { question }: { question: string }) {
    const answer = await this.aiService.rag(question);
    return {
        code: 0,
        answer
    }
}

与 Search 接口(GET /api/ai/search)不同,RAG 接口使用 POST 方法,因为 question 可能很长(用户可能输入多句话),不适合放在 URL 查询参数中。

返回结构标准化为 { code, answer },与 Search 的 { code, data } 保持一致的 API 风格约定。


七、RAG vs 语义搜索:两个功能的界限

本项目同时实现了语义搜索(ai.service.ts:102-117)和 RAG(ai.service.ts:130-164),它们的区别清晰:

arduino 复制代码
语义搜索                     RAG
─────────                   ─────
输入: "React"                输入: "React 是什么?"
                                     │
        │                            ▼
        ▼                      ┌──────────┐
┌────────────┐                 │ 检索文档   │
│ 向量相似度  │                 └────┬─────┘
│ 排序       │                      │
└─────┬──────┘                 ┌────▼─────┐
      │                        │ 注入LLM   │
      ▼                        └────┬─────┘
返回标题列表                         │
["如何使用React...",            ┌────▼─────┐
 "如何在React中..."]           │ LLM生成   │
                               │ 自然语言  │
                               │ 回答      │
                               └──────────┘

输出: 文章列表                 输出: 一段完整的文字回答

搜索 告诉你"有哪些相关文章";RAG 直接回答你的问题。两者都基于向量检索,但 RAG 多了一步------把检索结果交给 LLM 做二次加工。


八、终端演示:语义搜索命令行工具

demo/embedding-demo/semantic-search.mjs 是一个独立的命令行搜索工具,演示了语义搜索的最简实现:

javascript 复制代码
// 加载带向量的文章数据
const posts = JSON.parse(await fs.readFile('./data/posts-embedding.json', 'utf-8'));

// 交互式输入循环
rl.question('\n 请输入搜索内容: ', handleInput);

const handleInput = async (input) => {
    // 1. 将用户输入转为向量
    const { embedding } = (await client.embeddings.create({
        model: 'text-embedding-ada-002',
        input,
    })).data[0];

    // 2. 计算余弦相似度 → 排序 → Top 3
    const results = posts.map(item => ({
        ...item,
        similarity: cosineSimilarity(embedding, item.embedding),
    }))
    .sort((a, b) => a.similarity - b.similarity)
    .reverse()
    .slice(0, 3)
    .map((item, i) => `${i + 1}. ${item.title}, ${item.category}`)
    .join('\n');

    console.log(`\n${results}\n`);
    rl.question('\n 请输入搜索内容: ', handleInput);
};

运行效果示例:

markdown 复制代码
请输入搜索内容: 移动端开发
  1. 如何使用 Flutter 开发一个简单的计数器应用, 移动开发
  2. 如何使用 React Native 开发跨平台移动应用, 移动开发
  3. 如何使用 Kotlin 和 Android Studio 开发一个简单的 Todo 应用, 移动开发

请输入搜索内容:

这个 Demo 的价值在于:它是 RAG 中"R"步骤的教学版本------不涉及 LLM 调用,只演示向量检索,让人专注于理解"如何从一堆文章中找到语义最匹配的那几篇"。


九、生产环境中的 RAG 进阶

本项目实现的是一个最小可用的 RAG 示例------三条硬编码文档、内存向量库、单次 LLM 调用。从这出发,生产级 RAG 系统通常会在以下方向扩展:

9.1 文档分块(Chunking)

长文档不能整篇作为单个 Document------超过 embedding 模型的上下文限制(text-embedding-ada-002 单次最多 8191 token),且检索精度下降。常见做法是按语义边界切块

yaml 复制代码
10000 字的文章
    │
    ▼ 语义分块 (semantic-splitter)
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│Chunk1│ │Chunk2│ │Chunk3│ │Chunk4│ │Chunk5│
│ 1800字│ │ 2000字│ │ 1700字│ │ 2100字│ │ 2400字│
└──────┘ └──────┘ └──────┘ └──────┘ └──────┘

每个 chunk 独立向量化,检索时返回与问题最相似的 K 个 chunk。

9.2 持久化向量库

如第三节所述,MemoryVectorStore 只存在于内存。生产环境使用 Pinecone、Qdrant 等持久化方案,初始化一次后持续服务。

9.3 混合检索(Hybrid Search)

纯向量检索有时会漏掉精确关键词匹配。混合检索 同时做向量搜索和关键词搜索(如 BM25 算法),将两边结果融合排序。

9.4 Rerank(重排序)

初步检索返回 Top N 条文档后,用一个专门的重排序模型(如 Cohere Rerank)对候选文档做更精细的相关性判断,精选出最合适的 Top K 条送入 LLM。这能显著提升回答质量。

9.5 引用溯源

在生成的回答中标注每句话的信息来源------这是 RAG 区别于纯 LLM 的核心优势。实现方式是要求 LLM 在生成时输出引用标记,前端渲染为可点击的脚注链接。


十、总结

RAG 的本质是一个给 LLM 配了一个"参考书管理员"的架构。当用户提问时,不是直接把问题丢给模型,而是先检索相关文档,把文档内容注入 prompt,再让模型基于这些真实素材作答。

项目代码中,三步对应关系清晰:

步骤 代码位置 核心操作
Retrieve MemoryVectorStore.fromDocuments() + similaritySearch() 文档向量化 + 余弦相似度检索
Augment Prompt 模板字符串拼接 检索结果 + 角色 + 问题注入 prompt
Generate chatModel.invoke(prompt) DeepSeek 基于增强 prompt 生成答案

理解了这三步,你就掌握了 RAG 的全部基本原理。剩下的------换更强大的检索策略、换更大的知识库、换更复杂的 prompt 模板------都是在这个骨架上的皮肉填充。

相关推荐
侃谈科技圈1 小时前
2026年幻视AI数字工牌与全域零售AI解决方案官方介绍
人工智能·零售
chushiyunen1 小时前
ai人工智能方案-3d
人工智能
易知微EasyV数据可视化1 小时前
数序重构・智启新生|袋鼠云发布Data+AI智能飞轮战略,2026春季发布会圆满落幕
大数据·人工智能·经验分享·数字孪生·空间智能
名不经传的养虾人2 小时前
从0到1:企业级AI项目迭代日记 Vol.26|用AI是借力,教AI才是复制自己
人工智能·ai编程·skill·教ai复制自己
GEO从入门到精通2 小时前
GEO资料免费和付费的差距大吗?
人工智能
咪的Coding2 小时前
为什么在 DeepSeek 输入 <think>,它竟吐出别人的“记忆碎片”!?
后端·deepseek
沪漂阿龙在努力2 小时前
面试题详解:GPT 系列、Llama 系列、Qwen 系列全解析——GPT-1 到 GPT-3、Llama1 到 Llama3、Qwen3 架构与训练流程一次讲透
人工智能
计算机安禾2 小时前
【c++面向对象编程】第22篇:输入输出运算符重载:<< 与 >> 的友元实现
java·前端·c++
dunky2 小时前
AI Agent 的 2026:从"能干活"到"会思考",中间还差什么
人工智能·agent