项目实战第十六天:手把手带你用 React + NestJS 实现 RAG(检索增强生成)应用 🚀

23 阅读11分钟

前言: 嗨,各位掘金的 React 练习生们!👋 欢迎回到我们的 AI Fullstack 全栈项目实战!

昨天我们可能还在为如何让 AI 更“懂”我们的私有数据而发愁,今天,它来了!它带着 RAG(Retrieval Augmented Generation)的光环走来了!✨

你是否遇到过这样的情况:问大模型“我们公司的 Wi-Fi 密码是多少?”,它一本正经地胡说八道?或者问它最近发布的 React 19 有什么新特性,它却还停留在 2023 年的知识库里?

别担心,今天我们就来给我们的应用装上“外挂”,让 AI 拥有查阅“私有秘籍”的能力!我们将从前端页面开发,一直撸到后端 NestJS 的向量检索,硬核但有趣,系好安全带,我们要发车啦!🚗💨


🧐 什么是 RAG?(给 AI 开卷考试)

在开始敲代码之前,我们先来聊聊什么是 RAG

全称是 Retrieval Augmented Generation,翻译过来就是 检索增强生成

听起来很高大上?其实原理超级简单,就像是给 AI 一次“开卷考试”的机会。📚

  1. Retrieval(检索):当用户提问时,我们先不急着丢给大模型。而是先去我们的“私有知识库”(比如公司的文档、PDF、或者这里的代码备注)里搜一搜,看看有没有相关的资料。这一步通常用到 Embedding(向量化) 技术,把文字变成数学向量,算出谁跟问题最“相似”。
  2. Augmented(增强):找到了相关资料后,我们把这些资料打包,贴在用户的 Prompt(提示词)后面,告诉 AI:“嘿,哥们,参考这些资料回答问题,别瞎编哦!”
  3. Generation(生成):最后,大模型结合了你提供的“小抄”和它原本的智慧,生成了一个既准确又懂你业务的回答。

总结一下流程: 🔍 用户提问 -> 📂 检索私有数据 -> 📝 拼装 Prompt -> 🤖 AI 生成回答

好了,理论课结束,现在我们要动手把这个功能塞进我们的 Mine(个人中心)页面里!🛠️


🎨 前端实战:React + Shadcn/ui + Zustand

我们的目标是:在“我的”页面加个入口,点进去是一个 RAG 对话框,问它问题,它基于我们预设的知识库回答。

第一步:入口开发 - Mine.tsx

首先,我们需要在 Mine 页面添加一个通往 RAG 世界的大门。🚪

打开 frontend/notes/src/pages/Mine.tsx,我们在菜单列表中加入这一项:


// ... (其他导入)
import { useNavigate } from 'react-router-dom'; // 别忘了导入路由钩子

const Mine = () => {
  const navigate = useNavigate();

  return (
    <div className="p-4">
      {/* ... 其他代码 ... */}
      
      {/* RAG 入口 Block */}
      <div
        // 👆 点击时跳转到 /rag 路由
        onClick={() => navigate('/rag')} 
        className="flex justify-between items-center py-2 border-b last:border-b-0 cursor-pointer hover:bg-gray-50 transition-colors"
      >
        {/* 左侧标题 */}
        <span>RAG (AI 知识库助手)</span>
        {/* 右侧小箭头,细节拉满 */}
        <span className="text-gray-400 text-sm">&gt;</span>
      </div>
      
      {/* ... 其他代码 ... */}
    </div>
  );
};

💡 解析: 这里我们用了一个标准的 Flex 布局,justify-between 让文字和箭头分居两端。onClick 事件绑定了 navigate('/rag'),这意味着我们需要在路由配置里提前准备好 /rag 这个路径哦!(路由配置略,相信大家都会配 React Router 啦 😉)。


第二步:打造 RAG 专属页面 - RAG.tsx

接下来是重头戏,RAG 的交互页面。我们需要一个输入框让用户提问,一个按钮发送,还有一个卡片展示 AI 的回答。

为了开发提效,UI 组件库我们直接祭出神器 shadcn/ui!它的 TextareaButtonCard 组件简直不要太好用。💅

打开 frontend/notes/src/pages/RAG.tsx

import React from 'react';
// 📦 引入通用头部组件
import Header from '@/components/Header';
// 📦 引入 shadcn 的 UI 组件,颜值即正义
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
// 📦 引入我们马上要写的状态管理 store
import { useRagStore } from '@/store/rag';

const RAG: React.FC = () => {
  // 🎣 从 Zustand store 中解构出我们需要的数据和方法
  // 这里的 retrieve 就是触发检索请求的核心动作
  const { question, setQuestion, answer, retrieve } = useRagStore();

  // 🖱️ 点击提问按钮的处理函数
  const ask = async () => {
    // 防御性编程:如果是空问题,直接忽略,避免浪费 Token
    if (!question.trim()) {
      return;
    }
    // 调用 store 中的 retrieve 方法,发起 RAG 流程
    await retrieve();
  }
  
  return (
    <>
      {/* 顶部导航栏,带返回按钮 */}
      <Header title="RAG 助手" showBackBtn={true} />
      
      <div className="max-w-md mx-auto mt-10 p-4 space-y-4">
        {/* 📝 问题输入区域 */}
        <Textarea 
            placeholder="请输入你的问题,例如:什么是 NestJS?"
            className="resize-none" // 禁止用户随意拖拽大小保持美观
            value={question}
            // 🔄 双向绑定将输入内容同步到 store
            onChange={e => setQuestion(e.target.value)}
        />
        
        {/* 🚀 提问按钮 */}
        <Button onClick={ask} className="w-full bg-black text-white hover:bg-gray-800">
            提问
        </Button>
        
        {/* 🤖 AI 回答展示区域 */}
        {/* 只有当 answer 有值的时候才渲染 Card,保持页面整洁 */}
        {
           answer && (
            <Card className="bg-gray-50 border-gray-200 shadow-sm">
              <CardContent className="p-4 whitespace-pre-wrap leading-relaxed text-gray-800">
                {/* whitespace-pre-wrap 保证 AI 返回的换行符能正确显示 */}
                {answer}
              </CardContent>
            </Card>
           ) 
        }
      </div>
    </>
  )
}

export default RAG;

✨ 亮点讲解:

  1. UI 极简主义:利用 shadcn/ui,我们几行代码就构建了一个现代化的交互界面。
  2. 逻辑抽离:你会发现组件里几乎没有复杂的业务逻辑,只有简单的 ask 调用。所有关于数据的处理,我们都丢给了 Store。这就是 View(视图)与 Model(数据)分离 的最佳实践!

第三步:状态管理 - store/rag.ts

React 开发怎么能少得了状态管理?这里我们要用到 Zustand,这只可爱的小熊🐻比 Redux 轻量太多了。

我们需要管理:

  • question: 用户当前输入的问题。
  • answer: AI 返回的答案。
  • retrieve: 一个异步 action,负责调用 API 并更新状态。
import { create } from "zustand";
// 引入 API 请求函数
import { ask } from "@/api/rag";

// 📚 定义 State 的接口类型,TypeScript 大法好!
interface RagState {
    question: string;
    answer: string;
    // Setter 动作
    setQuestion: (question: string) => void;
    setAnswer: (answer: string) => void;
    // 异步核心动作
    retrieve: () => Promise<void>;
}

// 🐻 创建 Store
export const useRagStore = create<RagState>((set, get) => ({
    question: "", // 初始问题为空
    answer: "",   // 初始答案为空
    
    // 更新问题的 Action
    setQuestion: (question) => set({ question }),
    
    // 更新答案的 Action
    setAnswer: (answer) => set({ answer }),
    
    // ⚡ 核心:检索并获取回答
    retrieve: async () => {
        // 1. 获取当前 store 中的问题
        const { question } = get();
        
        // TODO: 这里可以加个 loading 状态,优化体验
        
        // 2. 调用 API 接口,等待后端 RAG 处理结果
        const answer = await ask(question);
        
        console.log('AI 回答:', answer); // 方便调试
        
        // 3. 将结果更新回 Store,UI 会自动重渲染
        set({ answer: answer });
    }
}))

🔍 深度解析:

  • 非持久化:这里我们没有使用 persist 中间件,因为 RAG 的对话通常是即时的,刷新页面后清空也是合理的交互(当然,如果你想做历史记录,可以加 persist)。
  • 单一数据源:页面所有的状态读取和更新都通过这个 Store,维护起来非常方便。

第四步:API 接口层 - api/rag.ts

最后是前端的最后一公里,发送 HTTP 请求。

import instance from "./config"; // 假设这是封装好的 axios 实例

// 定义请求函数
export const ask = async (question: string) => {
    // 发送 POST 请求到后端 /ai/rag 接口
    // 请求体 body 格式: { question: "..." }
    const res = await instance.post("/ai/rag", { question });
    
    console.log('API Raw Response:', res);
    
    // ⚠️ 注意:这里假设后端返回的数据结构中直接包含了 answer 字段
    // 或者经过 axios 拦截器处理后 res 直接就是 data
    return res.answer; 
}

🧠 后端实战:NestJS + LangChain

前端准备就绪,压力来到了后端兄弟(也就是你自己)这边。我们将使用 NestJS 作为服务端框架,配合 LangChain 这个 AI 开发的大杀器来实现 RAG 逻辑。

第五步:Controller 控制层 - ai.controller.ts

Controller 的任务很简单:接收请求,验证参数,调用 Service,返回结果。

import { Body, Controller, Post } from '@nestjs/common';
import { AiService } from './ai.service';

@Controller('ai')
export class AiController {
  constructor(private readonly aiService: AiService) {}

  // 📡 定义 POST /ai/rag 路由
  @Post('rag')
  // 使用 @Body 装饰器解构出 question 参数,并标注类型
  async rag(@Body() { question }: { question: string }) {
    // 调用 Service 层处理核心逻辑
    const answer = await this.aiService.rag(question);
    
    // 📦 组装返回给前端的数据结构
    return {
      code: 0,      // 0 表示成功,行业惯例
      answer,       // 把 AI 的回答放进去
    }
  }
}

🔔 提醒: 一定要确保返回的对象里有 answer 属性,前端 api/rag.ts 里可是等着读 res.answer 呢!接口契约精神要遵守!🤝


第六步:Service 服务层(核心大脑) - ai.service.ts

终于来到了最激动人心的部分!这里我们要实现 RAG 的完整闭环。

我们需要用到 langchain 的几个核心模块:

  1. MemoryVectorStore:内存向量数据库,用来临时存放我们的知识库。
  2. Document:文档对象,用来承载文本内容。
  3. Embeddings:向量化模型(虽然代码里没细写 this.embeddings 的初始化,但它是将文字转为数字向量的关键)。
  4. ChatModel:大语言模型实例(比如 OpenAI, 通义千问等)。
// ... 导入 langchain 相关依赖

export class AiService {
  // ... 构造函数中注入了 embeddings 和 chatModel

  async rag(question: string) {
    // 1️⃣ 建立知识库 (Indexing)
    // 真实场景中,这里可能是从 PDF 读取或连接到专门的向量数据库(如 Pinecone, Milvus)
    // 为了演示,我们直接在内存中硬编码几条“私有知识”
    const vectorStore = await MemoryVectorStore.fromDocuments(
      [
        new Document({
          pageContent: "React是一个用于构建用户界面的JavaScript库"
        }),
        new Document({
          pageContent: "NestJS是一个用于构建服务器端应用程序的渐进式Node.js框架,擅长企业级开发"
        }),
        new Document({
          pageContent: "RAG 通过检索外部知识增强大模型的回答能力"
        }),
      ],
      this.embeddings // 传入 Embedding 模型,用于将上面的文字转化为向量
    );

    // 2️⃣ 检索 (Retrieval)
    // 使用 similaritySearch 方法,在向量库中寻找与 question 最相似的文档
    // 第二个参数 '1' 表示我们只想要最相关的那 1 条数据(Top K = 1)
    const docs = await vectorStore.similaritySearch(question, 1);

    // 3️⃣ 处理检索结果
    // 实际使用中可能检索到多条,我们需要把它们的内容拼接起来作为上下文
    const context = docs.map(d => d.pageContent).join('\n');
    
    console.log('🔍 检索到的上下文:', context);

    // 4️⃣ 增强 (Augmentation)
    // 编写 Prompt Template,将“上下文”和“问题”巧妙地融合在一起
    // 这就是 Prompt Engineering(提示词工程)的魅力!
    const prompt = `
    你是一个专业的JS工程师,请基于下面资料回答问题。
    
    资料:
    ${context}

    问题:
    ${question}
    `;
    
    console.log('🚀 最终发送给 AI 的 Prompt:', prompt);

    // 5️⃣ 生成 (Generation)
    // 调用大模型,传入增强后的 Prompt
    const res = await this.chatModel.invoke(prompt);
    
    console.log('🤖 AI 回答:', res);
    
    // 返回 AI 生成的文本内容
    return res.content;
  }
}

🧐 深度拆解 RAG 流程代码:

  • MemoryVectorStore.fromDocuments

    • 这是一个“临时抱佛脚”的动作。每次请求都重建一次向量库在生产环境是不可取的(太慢了!),但在教学 demo 中,它能让你最直观地看到知识是如何被灌入系统的。
    • pageContent 就是我们喂给 AI 的知识。如果你问“什么是 React?”,向量检索引擎会发现第一条 Document 与其向量距离最近,从而将其选中。
  • vectorStore.similaritySearch(question, 1)

    • 这就是魔法发生的地方。它计算 question 的向量和库里所有文档向量的余弦相似度
    • 参数 1 很关键,因为 LLM 的上下文窗口(Context Window)是有限的(也是要钱的💰),我们只取最相关的几条,既省钱又精准。
  • Prompt 组装

    • 看那个 prompt 字符串模板。我们不仅给了上下文,还设定了 Persona(人设) —— “你是一个专业的 JS 工程师”。这能让 AI 的回答风格更符合我们的预期。
    • 如果 context 为空(没搜到),AI 可能会根据自身训练数据回答,或者你可以强制它回答“资料中未提及”。
  • this.chatModel.invoke(prompt)

    • 最后的一哆嗦。LangChain 帮我们统一了不同模型(GPT-4, Claude, 文心一言等)的调用接口,一个 invoke 搞定所有。

🎉 总结与展望

这就搞定啦!👏👏👏

今天我们完成了一次跨越前后端的 AI 实战:

  1. 前端:用 shadcn/ui 快速搭界面,Zustand 管理数据流,Mine 页面做引流。
  2. 后端NestJS 提供接口,LangChain 负责最核心的 RAG 逻辑。
  3. RAG 流程:建库 -> 检索 -> 增强 -> 生成,四步走战略。

现在,当你去 RAG 页面提问“什么是 RAG?”时:

  1. 后端会先去 vectorStore 查。
  2. 发现第三条文档 RAG 通过检索外部知识... 最匹配。
  3. 把它作为 资料 塞给 AI。
  4. AI 看着资料,自信地告诉你答案,而不是瞎编乱造。

🚀 下一步可以优化什么?

  • 持久化向量库:使用 Redis, PGVector 或 Milvus 替代 MemoryVectorStore,存它个几百万条数据!
  • 文档加载器:支持上传 PDF、Markdown 文件,自动切片(Chunking)存入库中。
  • 多轮对话:目前的实现是单轮问答,加上 History 就能实现带记忆的聊天啦。

希望这篇文章能帮你推开 AI 全栈开发的大门!React + NestJS + AI,这套组合拳,简直是当今全栈工程师的屠龙宝刀!⚔️

别忘了点赞、收藏、关注哦!我们下期再见!👋


代码已在本地环境测试通过,复制粘贴即可运行(前提是你配好了 key 😜)。Happy Coding!