实现RAG功能学习笔记

一、前言:为什么要学习RAG?

在大语言模型(LLM)飞速发展的当下,我们在使用大模型时常常会遇到两个核心痛点:一是大模型的知识库存在"时效性滞后"问题,无法获取训练数据之后的新信息,也无法访问企业内部的私有文档、行业专属数据;二是大模型容易产生"幻觉",即脱离事实生成看似合理但错误的内容,尤其在需要精准引用文档、依托特定知识库回答问题的场景(如企业客服、知识库查询、专业领域咨询)中,这种"幻觉"会严重影响使用体验和可信度。

而RAG(Retrieval-Augmented Generation,检索增强生成)技术正是解决这两个痛点的关键方案。它将"检索外部知识库"与"大模型生成"相结合,让大模型在生成回答前,先从指定的知识库中检索出与问题最相关的内容,将这些内容作为上下文补充到提示词(Prompt)中,再由大模型基于这些真实、精准的信息生成回答。这种方式既保留了大模型流畅生成自然语言的优势,又通过检索确保了回答的准确性、时效性和针对性,同时无需对大模型进行昂贵的微调(Fine-tuning),大幅降低了企业和开发者的使用成本。

本次学习将围绕RAG功能的完整实现展开,参考提供的前端(React)、后端(NestJS)、核心服务(LangChain+大模型+向量数据库)代码,从基础概念拆解、技术栈解析、代码逐行解读,到全流程调试、常见问题复盘,全面掌握RAG的实现逻辑和实操技巧,最终能够独立搭建一个简单但可用的RAG问答系统。本笔记总字数将达到5000字以上,兼顾理论深度和实操细节,适合有一定前端、后端基础,想要入门RAG技术的开发者参考学习。

二、RAG核心概念与核心流程拆解

2.1 核心概念辨析

在开始代码解析前,首先需要明确RAG技术中的几个核心概念,避免混淆,为后续的代码学习打下基础:

  • 检索(Retrieval):核心是"找到与问题最相关的信息"。这里的检索并非传统的关键词匹配(如数据库的模糊查询),而是基于"向量相似度"的检索。简单来说,就是将问题和知识库中的文档都转换成计算机能够理解的"向量"(通过Embedding模型实现),再计算问题向量与每个文档向量的相似度,相似度越高,说明文档与问题的相关性越强,最终筛选出Top N个最相关的文档作为检索结果。

  • 增强(Augmented):核心是"补充上下文"。将检索到的相关文档内容,按照一定的格式拼接成提示词(Prompt)的一部分,为大模型提供"额外的知识支撑",让大模型知道"回答问题需要基于这些信息",从而避免生成脱离事实的内容,实现"增强"效果。

  • 生成(Generation):核心是"基于上下文生成流畅回答"。将包含检索结果的提示词输入到大语言模型中,由大模型结合自身的语言能力和检索到的精准信息,生成自然、连贯、准确的回答。这里的生成过程与传统大模型生成的区别在于,提示词中多了"检索到的上下文",约束了大模型的回答范围。

  • Embedding(嵌入):将文本(问题、文档)转换成高维向量的过程,是实现"向量检索"的基础。Embedding模型能够捕捉文本的语义信息,语义越相似的文本,转换后的向量距离越近(相似度越高)。本次学习中使用的是OpenAI的text-embedding-ada-002模型,属于轻量、高效的Embedding模型,适合入门使用。

  • 向量数据库(Vector Store):用于存储文档转换后的向量以及对应的文档内容,方便后续快速检索相似向量。本次学习中使用的是LangChain提供的MemoryVectorStore(内存向量数据库),它将向量存储在内存中,无需额外部署独立的向量数据库(如Pinecone、Milvus),适合入门调试和小型项目;在实际生产环境中,通常会使用分布式向量数据库来支持大规模文档的存储和检索。

  • LangChain:一个用于构建大语言模型应用的开发框架,封装了大模型调用、Embedding转换、向量数据库操作、检索逻辑等常用功能,能够大幅简化RAG系统的开发流程。本次代码中大量使用了LangChain的相关API,如ChatDeepSeek(大模型调用)、OpenAIEmbeddings(Embedding转换)、MemoryVectorStore(向量存储)等。

2.2 RAG核心流程(重中之重)

结合参考代码,RAG功能的完整实现流程可以拆解为5个核心步骤,这5个步骤贯穿了前端、后端、核心服务的所有代码,理解这个流程,就相当于掌握了RAG实现的核心逻辑:

  1. 步骤1:准备知识库:将需要用于检索的文档(如本次代码中的React、NestJS、RAG相关文档)整理好,作为RAG系统的"知识来源"。本次代码中,知识库是在后端服务中直接定义的3个Document对象(后续可以扩展为从本地文件、数据库中加载文档)。

  2. 步骤2:文档Embedding与向量存储:通过Embedding模型(text-embedding-ada-002)将知识库中的每个文档转换成向量,然后将这些向量和对应的文档内容存储到向量数据库(MemoryVectorStore)中,完成"知识库的向量化存储"。这一步是实现"精准检索"的前提,代码中在AiService的构造函数中完成了这一操作。

  3. 步骤3:接收用户问题(前端交互):用户在前端页面(React组件)的输入框中输入问题(如"什么是RAG模型?"),前端通过接口将问题发送到后端的RAG接口。这一步对应前端的RAG组件、zustand状态管理以及接口调用逻辑。

  4. 步骤4:问题检索与上下文拼接:后端接收用户问题后,首先通过Embedding模型将问题转换成向量,然后在向量数据库中检索出与该向量最相似的Top N个文档(本次代码中N=1);接着将检索到的文档内容拼接成"上下文",再结合用户问题,生成最终的提示词(Prompt)。这一步是"检索增强"的核心,对应AiService中的rag方法。

  5. 步骤5:大模型生成回答并返回:将拼接好的提示词输入到大语言模型(本次代码中使用的是DeepSeek的deepseek-chat模型)中,由大模型基于提示词生成回答;后端将生成的回答返回给前端,前端展示在页面上,完成整个RAG问答流程。

补充说明:整个流程的核心亮点的是"检索在前,生成在后",检索确保了知识的准确性和针对性,生成确保了回答的流畅性和自然性,二者结合,完美解决了传统大模型的"幻觉"和"知识滞后"问题。

三、技术栈详解:前端+后端+核心依赖

本次RAG系统的实现采用了"前端React+后端NestJS+LangChain+大模型+向量数据库"的技术栈,每个技术栈都有其明确的作用,下面分别详解每个技术栈的核心作用、版本适配以及在RAG系统中的具体应用,帮助大家理清技术选型的逻辑。

3.1 前端技术栈

前端的核心作用是"提供用户交互界面",接收用户输入的问题,展示后端返回的回答,实现简单、直观的问答体验。参考代码中使用的前端技术栈如下:

  • React:核心前端框架,用于构建用户界面(UI)。本次代码中使用的是函数式组件(React.FC),结合Hooks(如useState的替代方案zustand)实现状态管理,组件化开发让代码更具可维护性。React的核心优势是组件复用、虚拟DOM提升渲染性能,适合构建中小型交互界面(如RAG问答界面)。

  • Zustand:轻量级的状态管理库,用于管理前端的全局状态(如用户输入的问题、后端返回的回答)。相比于Redux,Zustand的API更简洁、上手难度更低,无需编写大量的模板代码,适合中小型项目使用。本次代码中,useRagStore存储了question(用户问题)、answer(回答),以及setQuestion、setAnswer、retrieve(调用接口)等方法,实现了状态的统一管理和方法的复用。

  • UI组件库:参考代码中使用了自定义的UI组件(如Textarea、Button、Card、Header),这些组件通常来自于开源UI组件库(如Shadcn UI、Ant Design等),用于快速构建美观、规范的界面,减少重复的CSS编写工作。例如,Textarea用于用户输入问题,Button用于触发提问操作,Card用于展示回答结果。

  • 接口调用:通过自定义的api/rag模块中的ask方法,实现前端与后端的接口通信(POST请求),将用户问题发送到后端的RAG接口,并接收后端返回的回答。接口调用采用异步方式(async/await),确保界面不会因为接口请求而卡顿。

前端技术栈总结:选型偏向"轻量、简洁、易上手",适合快速开发RAG的交互界面,核心关注"用户输入"和"回答展示"两个核心场景,无需复杂的业务逻辑,重点是与后端接口的顺畅通信和状态管理。

3.2 后端技术栈

后端的核心作用是"处理业务逻辑",包括接收前端请求、调用核心服务(Embedding、检索、大模型生成)、处理数据、返回结果,是RAG系统的"核心中枢"。参考代码中使用的后端技术栈如下:

  • NestJS:基于Node.js的后端框架,采用TypeScript开发,支持依赖注入、模块化开发,适合构建企业级的后端服务。NestJS的核心优势是"模块化、规范化",能够将业务逻辑拆分成控制器(Controller)、服务(Service)等模块,让代码更具可维护性和扩展性。本次代码中,AiController负责接收前端请求,AiService负责处理核心业务逻辑(RAG、Chat、Search等)。

  • TypeScript:强类型编程语言,是JavaScript的超集,能够提供类型检查、接口定义等功能,减少开发过程中的类型错误,提升代码的可读性和可维护性。本次前端和后端代码均使用TypeScript开发,例如,定义了RagState、Message、Post等接口,明确了数据的类型,避免了"传参错误""数据格式不匹配"等问题。

  • NestJS控制器(Controller):负责接收前端的HTTP请求(GET、POST),解析请求参数,调用对应的服务方法,然后将服务返回的结果封装后返回给前端。本次代码中,AiController定义了/ai/chat、/ai/search、/ai/rag等接口,其中/ai/rag接口是RAG功能的核心接口,接收前端发送的question参数,调用AiService的rag方法,返回回答结果。

  • NestJS服务(Service):负责封装核心业务逻辑,是后端的"业务处理核心"。本次代码中,AiService封装了RAG功能的所有核心逻辑,包括Embedding模型初始化、向量数据库操作、文档加载、问题检索、大模型调用等,控制器只负责"转发请求",不处理具体的业务逻辑,符合"单一职责原则"。

  • 文件操作(fs/promises):用于加载本地知识库文件(如posts-embedding.json),将文件中的数据读取到后端服务中,作为检索的知识来源。本次代码中,loadPosts方法通过fs.readFile读取指定路径下的JSON文件,解析后存储到posts数组中,后续可以扩展为加载Markdown、Word等格式的文档。

  • 路径处理(path):用于处理文件路径,确保后端服务能够正确找到本地知识库文件的位置。由于NestJS将TypeScript编译为JavaScript后,文件路径会发生变化(如dist目录),因此需要使用path.join方法拼接绝对路径,避免路径错误。

后端技术栈总结:选型偏向"规范化、可扩展",NestJS的模块化设计能够很好地适配RAG系统的业务逻辑拆分(控制器负责请求处理,服务负责业务逻辑),TypeScript的强类型特性能够减少开发过程中的错误,文件操作和路径处理则为"加载本地知识库"提供了支持。

3.3 核心依赖(LangChain+大模型+向量数据库)

这部分是RAG系统的"核心灵魂",负责实现"检索"和"生成"的核心逻辑,也是本次学习的重点。参考代码中使用的核心依赖如下:

3.3.1 LangChain

LangChain是一个专门用于构建大语言模型应用的开发框架,它封装了大量的常用功能,避免了开发者直接调用大模型API、Embedding API、向量数据库API的繁琐操作,能够大幅提升开发效率。本次代码中使用的LangChain相关依赖如下:

  • @langchain/deepseek:LangChain封装的DeepSeek大模型集成模块,用于调用DeepSeek的大模型API(如deepseek-chat),实现文本生成功能。通过ChatDeepSeek类,可以快速初始化大模型实例,设置API密钥、模型名称、温度(temperature)等参数,调用stream方法实现流式生成,调用invoke方法实现普通生成。

  • @langchain/openai:LangChain封装的OpenAI相关集成模块,包含OpenAIEmbeddings(Embedding转换)和DallEAPIWrapper(图片生成)两个核心类。其中,OpenAIEmbeddings用于将文本(问题、文档)转换成向量,DallEAPIWrapper用于调用OpenAI的DALL·E模型生成头像(本次代码中avatar方法用到,非RAG核心,但属于大模型应用扩展)。

  • @langchain/core:LangChain的核心模块,提供了一些基础类和工具,如Document(文档类,用于封装知识库中的文档内容)、HumanMessage/AIMessage/SystemMessage(消息类,用于封装聊天消息,适配大模型的消息格式)。

  • @langchain/classic:LangChain的经典模块,包含MemoryVectorStore(内存向量数据库)等常用组件。MemoryVectorStore是一个轻量级的向量数据库,无需额外部署,将向量存储在内存中,适合入门调试和小型项目,本次代码中用于存储知识库文档的向量。

LangChain的核心优势:模块化、可扩展、集成度高。开发者可以根据需求,灵活组合LangChain的各种组件,快速构建RAG、聊天机器人、文档总结等大模型应用,无需关注底层API的调用细节。

3.3.2 大模型(DeepSeek+OpenAI)

大模型是RAG系统中"生成回答"的核心,本次代码中使用了两个不同的大模型,分别用于不同的功能:

  • DeepSeek(deepseek-chat):主要用于文本生成,包括RAG功能中的回答生成和聊天功能(chat方法)中的消息生成。DeepSeek是一款开源的大语言模型,性能接近GPT-3.5,API调用成本较低,适合中小型项目使用。代码中通过ChatDeepSeek类初始化模型实例,设置temperature为0.7(温度越低,回答越精准;越高,回答越灵活),stream为true(支持流式生成,适合聊天场景,实现"边输入边展示"的效果)。

  • OpenAI(text-embedding-ada-002):主要用于Embedding转换,将文本(问题、文档)转换成向量。text-embedding-ada-002是OpenAI推出的一款轻量、高效的Embedding模型,生成的向量维度为1536维,兼顾了性能和效果,适合入门使用。代码中通过OpenAIEmbeddings类初始化模型实例,设置API密钥和基础URL,调用embedQuery方法将问题转换成向量,调用embedDocuments方法(MemoryVectorStore内部使用)将文档转换成向量。

补充说明:大模型的选型可以根据项目需求灵活调整,例如,文本生成可以替换为GPT-3.5/GPT-4、智谱清言、通义千问等,Embedding转换可以替换为DeepSeek的Embedding模型、智谱清言的Embedding模型等,LangChain的封装让大模型的替换变得非常简单,只需修改少量代码即可。

3.3.3 向量数据库(MemoryVectorStore)

向量数据库的核心作用是"存储文档向量"和"快速检索相似向量",是实现RAG检索功能的基础。本次代码中使用的MemoryVectorStore是LangChain内置的内存向量数据库,其核心特点如下:

  • 轻量无依赖:无需额外部署独立的服务,将向量存储在内存中,启动速度快,适合入门调试和小型项目。

  • API简洁:通过fromDocuments方法,可以快速将Document数组转换成向量并存储;通过similaritySearch方法,可以根据问题向量检索出最相似的文档,使用非常便捷。

  • 局限性:由于向量存储在内存中,服务重启后,存储的向量会丢失;不支持大规模文档的存储和检索(适合文档数量较少的场景)。在实际生产环境中,通常会使用分布式向量数据库,如Pinecone、Milvus、Chroma等,这些数据库支持大规模向量存储、持久化、分布式检索等功能。

补充:向量检索的核心算法是"余弦相似度计算",本次代码中自定义了cosineSimilarity方法,用于计算两个向量的相似度(取值范围为[-1,1],值越接近1,说明两个向量的语义越相似)。MemoryVectorStore内部也是通过类似的相似度算法,实现相似文档的检索。

3.4 环境配置与依赖安装

在开始运行代码前,需要完成环境配置和依赖安装,这是实操过程中必不可少的一步,也是容易出现问题的地方,下面详细说明:

3.4.1 环境配置

  • Node.js:要求Node.js版本在16.x及以上(NestJS和React的最低要求),建议使用18.x版本,避免版本过低导致依赖安装失败。

  • API密钥配置:需要获取DeepSeek和OpenAI的API密钥,配置到环境变量中(.env文件)。具体步骤:

    • 注册DeepSeek账号,获取API密钥(platform.deepseek.com/);

    • 注册OpenAI账号,获取API密钥(platform.openai.com/);

    • 在后端项目根目录下创建.env文件,添加以下内容:

      ini 复制代码
      DEEPSEEK_API_KEY=你的DeepSeek API密钥
      DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
      OPENAI_API_KEY=你的OpenAI API密钥
      OPENAI_BASE_URL=https://api.openai.com/v1
    • 确保后端服务能够读取到.env文件中的环境变量(NestJS默认支持dotenv模块,无需额外配置)。

  • 知识库文件配置:如果需要加载本地知识库文件(如posts-embedding.json),需要在后端项目根目录下创建data文件夹,将JSON文件放入其中,确保文件路径与代码中path.join拼接的路径一致(本次代码中路径为../../data/posts-embedding.json,需根据实际项目结构调整)。

3.4.2 依赖安装

分别在前端项目和后端项目中安装对应的依赖:

前端项目依赖安装

bash 复制代码
# 进入前端项目根目录
cd frontend
# 安装核心依赖
npm install react react-dom zustand @/api/rag # 实际项目中需替换为真实的依赖名称
# 安装UI组件库(如Shadcn UI)
npm install @shadcn/ui react-hook-form zod @hookform/resolvers
# 启动前端服务
npm run dev

后端项目依赖安装

bash 复制代码
# 进入后端项目根目录
cd backend
# 安装NestJS核心依赖
npm install @nestjs/common @nestjs/core @nestjs/platform-express reflect-metadata rxjs
# 安装LangChain相关依赖
npm install langchain @langchain/deepseek @langchain/openai @langchain/core @langchain/classic
# 安装其他依赖
npm install dotenv fs-extra path typescript ts-node @types/node @types/express
# 启动后端服务
npm run start:dev

注意事项:依赖安装过程中,可能会出现版本不兼容的问题,建议根据错误提示调整依赖版本;如果无法访问OpenAI的API,可以使用国内的代理服务,修改.env文件中的OPENAI_BASE_URL为代理地址。

四、代码逐行解读:从前端到后端,吃透RAG实现细节

这部分是本次学习笔记的核心,将结合参考代码,从前端、后端、核心服务三个层面,逐行解读代码的作用、逻辑和实现细节,让大家能够清晰地知道"每一行代码在做什么""为什么要这么写""如何修改和扩展"。建议大家在阅读过程中,对照参考代码,逐行梳理,遇到不懂的地方可以暂停思考,结合前面的核心流程和技术栈解析,逐步理解。

4.1 前端代码解读(React+Zustand)

前端代码的核心功能:构建RAG问答界面,接收用户输入的问题,调用后端接口,展示回答结果。参考代码中前端部分包含两个核心文件:store/rag.ts(状态管理)和pages/RAG.tsx(界面组件),下面分别解读。

4.1.1 状态管理文件(store/rag.ts)

typescript 复制代码
import { create } from 'zustand';
import { ask } from '@/api/rag';

// 定义RAG状态的接口,明确状态和方法的类型(TypeScript强类型)
interface RagState {
    question: string; // 用户输入的问题
    answer: string; // 后端返回的回答
    setQuestion: (question: string) => void; // 设置用户问题的方法
    setAnswer: (answer: string) => void; // 设置回答的方法
    retrieve: () => Promise<void>; // 调用后端接口,获取回答的方法
}

// 创建RAG状态存储,使用zustand的create方法
export const useRagStore = create<RagState>((set, get) => ({
    // 初始状态:问题和回答均为空字符串
    question: '',
    answer: '',

    // 设置用户问题:接收question参数,通过set方法更新状态
    setQuestion: (question: string) => set({ question }),

    // 设置回答:接收answer参数,通过set方法更新状态
    setAnswer: (answer: string) => set({ answer }),

    // 调用后端接口,获取回答(核心方法)
    retrieve: async () => {
        // 获取当前状态中的question(用户输入的问题)
        const { question } = get();
        // 调用api/rag中的ask方法,发送POST请求到后端/ai/rag接口,获取回答
        const answer = await ask(question);
        // 打印回答到控制台(用于调试)
        console.log(answer, '------------');
        // 更新状态中的answer,让前端界面展示最新的回答
        set({ answer });
    }
}));

逐行解读:

  1. 导入依赖:导入zustand的create方法(用于创建状态存储)和api/rag的ask方法(用于调用后端接口)。

  2. 定义RagState接口:使用TypeScript定义状态和方法的类型,确保数据类型的一致性。其中,question和answer是状态变量,setQuestion、setAnswer、retrieve是方法,明确了方法的参数和返回值类型(如retrieve返回Promise,表示异步无返回值)。

  3. 创建状态存储(useRagStore):通过create方法创建状态存储,参数是一个函数,接收set和get两个参数:

    • set:用于更新状态,接收一个对象,对象中的属性是需要更新的状态变量(如set({ question })表示更新question状态)。
    • get:用于获取当前的状态(如get().question获取当前用户输入的问题)。
  4. 初始状态:question和answer均初始化为空字符串,符合"用户未输入问题时,无回答"的场景。

  5. setQuestion方法:接收用户输入的question参数,通过set方法更新状态中的question,当用户在输入框中输入内容时,会调用这个方法,实时更新状态。

  6. setAnswer方法:接收后端返回的answer参数,通过set方法更新状态中的answer,当接口请求成功后,会调用这个方法,将回答更新到状态中,进而驱动前端界面重新渲染,展示回答。

  7. retrieve方法(核心):异步方法,负责调用后端接口,获取回答:

    • 通过get()获取当前状态中的question,确保获取到的是最新的用户输入。
    • 调用ask(question),发送POST请求到后端的/ai/rag接口,将question作为参数传递给后端,等待后端返回回答。
    • console.log用于调试,打印后端返回的回答,方便开发过程中排查问题(如接口返回错误、回答格式异常等)。
    • 通过set({ answer })更新状态中的answer,驱动前端界面展示回答。

补充说明:ask方法(来自@/api/rag)的实现通常如下(参考代码中未给出,此处补充,帮助理解):

typescript 复制代码
// api/rag.ts
import axios from 'axios';

// 调用后端RAG接口,发送用户问题,获取回答
export const ask = async (question: string) => {
    try {
        const response = await axios.post('/api/ai/rag', { question });
        // 后端返回的格式是{ code: 0, answer: '回答内容' },因此返回response.data.answer
        return response.data.answer;
    } catch (error) {
        console.error('调用RAG接口失败:', error);
        return '抱歉,获取回答失败,请重试!';
    }
};

4.1.2 界面组件文件(pages/RAG.tsx)

tsx 复制代码
import Header from "@/components/Header";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { useRagStore } from "@/store/rag";

// 定义RAG组件,函数式组件(React.FC)
const RAG: React.FC = () => {
    // 从useRagStore中解构出需要的状态和方法(zustand的使用方式)
    const { question, setQuestion, answer, retrieve } = useRagStore();

    // 提问方法:触发检索和回答生成
    const ask = async () => {
        // 校验用户输入:如果问题为空(去除空格后),则不调用接口
        if (!question.trim()) {
            return;
        }
        // 调用retrieve方法,获取回答
        await retrieve();
    }

    // 渲染组件:返回JSX,构建界面
    return (
        <>
            {/* 头部组件:显示标题"RAG",显示返回按钮 */}
            <Header title="RAG" showBackButton={true} />
            
            {/* 主体内容:居中布局,包含输入框、按钮、回答展示区域 */}
            <div className="max-w-xl mx-auto mt-10 space-y-4 p-4">
                {/* 文本输入框:用户输入问题 */}
                <Textarea
                    placeholder="请你的输入问题,例如:什么是RAG模型?"
                    value={question} // 绑定状态中的question,实现"双向绑定"
                    onChange={e => setQuestion(e.target.value)} // 输入变化时,调用setQuestion更新状态
                />
                
                {/* 提问按钮:点击时调用ask方法 */}
                <Button onClick={ask}>提问</Button>
                
                {/* 回答展示区域:如果answer不为空,显示Card组件,展示回答 */}
                {
                    answer && (
                        <Card>
                            <CardContent className="p-4 whitespace-pre-wrap">
                                {answer}
                            </CardContent>
                        </Card>
                    )
                }
            </div>
        </>
    )
}

// 导出组件,供其他页面导入使用
export default RAG;

逐行解读:

  1. 导入依赖:导入Header(头部组件)、Textarea(文本输入框)、Button(按钮)、Card(卡片组件)等UI组件,以及useRagStore(状态存储)。

  2. 定义RAG组件:使用React.FC定义函数式组件,这是React中最常用的组件定义方式,组件内部可以使用Hooks(此处使用zustand的useRagStore获取状态)。

  3. 解构状态和方法:通过useRagStore()解构出question(用户问题)、setQuestion(设置问题)、answer(回答)、retrieve(调用接口),这样组件内部就可以直接使用这些状态和方法,无需重复调用useRagStore()。

  4. 定义ask方法:用于触发提问操作,核心逻辑是"校验输入 + 调用接口":

    • 输入校验:通过question.trim()判断用户输入的问题是否为空(去除前后空格后),如果为空,则不调用接口,避免无效的接口请求。
    • 调用retrieve方法:await retrieve()表示等待接口请求完成,确保回答获取成功后再更新状态,避免界面出现异常。
  5. 渲染组件(JSX部分):核心是构建用户交互界面,分为三个部分:

    • Header组件:显示页面标题"RAG",showBackButton={true}表示显示返回按钮,方便用户返回上一页(具体实现由Header组件内部完成)。
    • 主体内容容器:使用Tailwind CSS的类名(max-w-xl mx-auto mt-10 space-y-4 p-4)实现居中布局、设置宽度、间距和内边距,让界面更美观。
    • Textarea输入框 :用户输入问题的区域,核心属性:
      • placeholder:提示用户输入的内容,引导用户正确输入。
      • value={question}:将输入框的值与状态中的question绑定,实现"双向绑定"------状态变化时,输入框的值会更新;输入框的值变化时,状态也会更新。
      • onChange={e => setQuestion(e.target.value)}:输入框内容变化时,触发onChange事件,获取输入框的值(e.target.value),调用setQuestion方法更新状态中的question,实现实时同步。
    • Button按钮:提问按钮,onClick={ask}表示点击按钮时,调用ask方法,触发接口请求。
    • 回答展示区域 :使用条件渲染(answer && ...),只有当answer不为空时,才显示Card组件,展示回答:
      • Card组件:用于包裹回答内容,让回答区域更美观、有层次感。
      • CardContent组件:Card的内容容器,设置内边距(p-4)。
      • whitespace-pre-wrap:CSS类名,用于保留回答中的换行和空格,确保回答格式与后端返回的一致(避免换行丢失,导致回答杂乱)。
      • {answer}:渲染状态中的answer,即后端返回的回答内容。
  6. 导出组件:export default RAG将组件导出,供其他页面(如路由配置中)导入使用,例如在路由中配置"/rag"路径对应RAG组件,用户访问该路径时,即可看到RAG问答界面。

前端代码总结:前端代码逻辑非常简洁,核心是"状态管理 + 界面渲染 + 接口调用",没有复杂的业务逻辑,重点是与用户的交互和后端接口的通信。通过zustand实现状态的统一管理,通过UI组件构建美观的交互界面,通过ask方法调用后端接口,实现"用户输入问题 → 调用接口 → 展示回答"的完整流程。

4.2 后端代码解读(NestJS)

后端代码的核心功能:接收前端请求,处理RAG核心业务逻辑(检索、增强、生成),返回回答结果。参考代码中后端部分包含三个核心文件:ai.controller.ts(控制器)、ai.service.ts(服务)、相关DTO(数据传输对象),下面重点解读控制器和服务,DTO部分简单说明。

4.2.1 数据传输对象(DTO)说明

DTO(Data Transfer Object,数据传输对象)的核心作用是"规范请求和响应的数据格式",避免无效数据传入后端,同时明确数据类型,提升代码的可读性和可维护性。参考代码中提到的DTO如下:

  • ChatDto:用于聊天接口(/ai/chat)的请求参数,包含messages数组(聊天消息列表),每个消息包含role(角色:user/assistant/system)和content(消息内容)。
  • SearchDto:用于搜索接口(/ai/search)的请求参数,包含keyword(搜索关键词)。

DTO的实现通常使用Zod或class-validator进行数据校验,例如ChatDto的实现如下(参考):

typescript 复制代码
// src/ai/dto/chat.dto.ts
import { z } from 'zod';
import { createZodDto } from '@anatine/zod-nestjs';

// 定义消息的Schema,校验消息格式
const MessageSchema = z.object({
    role: z.enum(['user', 'assistant', 'system']), // 角色只能是这三个值
    content: z.string().min(1, '消息内容不能为空'), // 消息内容不能为空
});

// 定义ChatDto的Schema,包含messages数组
const ChatDtoSchema = z.object({
    messages: z.array(MessageSchema), // messages是消息数组
});

// 创建DTO类,供控制器使用
export class ChatDto extends createZodDto(ChatDtoSchema) {};

补充说明:DTO并非RAG功能的核心,但它是后端开发中规范数据格式、避免错误的重要手段,在实际开发中必不可少。本次代码中,RAG接口(/ai/rag)的请求参数较为简单(仅question),因此没有单独定义DTO,而是直接在控制器中解构获取。

4.2.2 控制器(ai.controller.ts)

控制器的核心作用是"接收前端HTTP请求,解析参数,调用服务方法,返回结果",相当于后端的"入口",不处理具体的业务逻辑,只负责"转发请求"和"封装响应"。

typescript 复制代码
import {
    Controller,
    Post,
    Get,
    Body,
    Res,
    Query,
} from '@nestjs/common'
import { AiService } from './ai.service'
import { ChatDto } from './dto/chat.dto'
import { SearchDto } from './dto/search.dto'

// 定义控制器的路由前缀:/ai,所有该控制器下的接口都以/ai开头
@Controller('ai')
export class AiController {
    // 依赖注入:将AiService注入到控制器中,通过this.aiService调用服务方法
    constructor(private readonly aiService: AiService) {}

    // 聊天接口:POST请求,路径为/ai/chat
    @Post('chat')
    async chat(@Body() chatDto: ChatDto, @Res() res) {
        // 开启流式响应:用于聊天场景,实现"边生成边返回"的效果
        res.setHeader('Content-Type', 'text/event-stream');
        res.setHeader('Cache-Control', 'no-cache'); // 禁止缓存,确保每次请求都是新的结果
        res.setHeader('Connection', 'keep-alive'); // 保持连接,确保流式响应正常

        try {
            // 调用AiService的chat方法,传入消息和回调函数(用于接收流式token)
            await this.aiService.chat(chatDto.messages, (token: string) => {
                // 向前端写入流式数据,格式为"0:token字符串\n"(前端需对应解析)
                res.write(`0:${JSON.stringify(token)}\n`);
            })
            // 响应结束
            res.end();
        } catch (error) {
            // 异常处理:打印错误日志,返回500状态码
            console.error(error)
            res.status(500).end();
        }
    }

    // 搜索接口:GET请求,路径为/ai/search
    @Get('search')
    async search(@Query() dto: SearchDto) {
        // 从DTO中解构出keyword(搜索关键词)
        const { keyword } = dto;
        // 解码关键词:处理URL编码的关键词(如中文关键词会被URL编码)
        let decoded = decodeURIComponent(keyword);
        // 调用AiService的search方法,返回搜索结果
        return this.aiService.search(decoded);
    }

    // 头像生成接口:GET请求,路径为/ai/avatar
    @Get('avatar')
    async avatar(@Query('name') name: string) {
        // 调用AiService的avatar方法,传入姓名,返回头像URL
        return this.aiService.avatar(name);
    }

    // RAG核心接口:POST请求,路径为/ai/rag
    @Post('rag')
    async rag(@Body() { question }: { question: string }) { // 从请求体中解构出question
        // 调用AiService的rag方法,传入question,获取回答
        const answer = await this.aiService.rag(question);
        // 打印回答到控制台(用于调试)
        console.log(answer, '------------');
        // 封装响应结果,返回给前端(code:0表示成功)
        return {
            code: 0,
            answer
        }
    }   
}

逐行解读:

  1. 导入依赖:导入NestJS的核心装饰器(Controller、Post、Get等)、AiService(核心服务)、ChatDto和SearchDto(数据传输对象)。

  2. 定义控制器:@Controller('ai')装饰器表示该控制器的路由前缀为/ai,即所有该控制器下的接口都以/ai开头(如/ai/chat、/ai/rag)。

  3. 依赖注入:在构造函数中注入AiService,通过private readonly aiService: AiService定义,这样控制器内部就可以通过this.aiService调用AiService中的所有方法。NestJS的依赖注入机制能够实现组件的解耦,便于后续的扩展和测试。

  4. 聊天接口(@Post('chat'))

    • @Post('chat'):表示该接口是POST请求,路径为/ai/chat,接收前端发送的聊天消息。
    • @Body() chatDto: ChatDto:通过@Body()装饰器获取请求体中的数据,并使用ChatDto进行校验和类型转换,确保请求数据格式正确。
    • @Res() res:获取Express的响应对象(Response),用于实现流式响应(默认情况下,NestJS会自动封装响应,此处手动获取res是为了开启流式响应)。
    • 设置响应头:开启流式响应需要设置三个核心响应头:
      • Content-Type: text/event-stream:表示响应类型为事件流,支持流式传输。
      • Cache-Control: no-cache:禁止浏览器缓存响应结果,确保每次请求都能获取到最新的生成结果。
      • Connection: keep-alive:保持HTTP连接,确保流式数据能够持续发送到前端,避免连接中断。
    • 调用AiService的chat方法:传入chatDto.messages(聊天消息)和一个回调函数,回调函数用于接收大模型流式生成的token(每个token是回答的一部分),并通过res.write()向前端写入流式数据。
    • 异常处理:try-catch捕获chat方法执行过程中的错误,打印错误日志,返回500状态码(服务器内部错误),确保后端服务不会因为异常而崩溃。
  5. 搜索接口(@Get('search'))

    • @Get('search'):表示该接口是GET请求,路径为/ai/search,用于根据关键词搜索知识库中的相关文档标题。
    • @Query() dto: SearchDto:通过@Query()装饰器获取URL查询参数,并使用SearchDto进行校验和类型转换,获取keyword(搜索关键词)。
    • 解码关键词:decodeURIComponent(keyword)用于解码URL编码的关键词,例如中文关键词"RAG"在URL中会被编码为"%E5%8A%A8%E7%94%9F%E5%AD%97%E7%AC%A6",解码后才能得到原始关键词,避免搜索错误。
    • 调用AiService的search方法:传入解码后的关键词,返回搜索结果(相关文档标题列表),NestJS会自动将返回结果封装为JSON格式,返回给前端。
  6. 头像生成接口(@Get('avatar'))

    • @Get('avatar'):表示该接口是GET请求,路径为/ai/avatar,用于根据姓名生成个性化头像(非RAG核心功能,属于大模型应用扩展)。
    • @Query('name') name: string:通过@Query('name')获取URL查询参数中的name(姓名),无需单独定义DTO,因为参数简单,直接获取即可。
    • 调用AiService的avatar方法:传入姓名name,获取大模型生成的头像URL,NestJS自动封装为JSON格式返回给前端,供前端展示头像。
  7. RAG核心接口(@Post('rag'))

    • @Post('rag'):表示该接口是POST请求,路径为/ai/rag,是RAG功能的核心接口,接收前端发送的用户问题,返回生成的回答。
    • @Body()装饰器用于获取前端发送的请求体数据,此处通过对象解构语法直接提取出question参数,并显式指定其类型为string,严格遵循TypeScript强类型开发规范,避免因参数类型不明确导致的调用错误。
    • 调用AiService的rag方法,将用户问题question作为参数传入,等待方法执行完成并返回生成的回答。
    • console.log(answer, '------------')用于将生成的回答打印到控制台,属于开发阶段的调试手段,方便开发者排查"回答生成异常""接口调用失败"等问题,上线时可注释或删除该调试代码。
    • 最后,将回答封装成{code: 0, answer}的格式返回给前端,其中code: 0是自定义的"请求成功"状态码,前端可根据该状态码判断请求是否正常,若code不为0则可提示用户"请求失败,请重试",这种统一的响应格式便于前后端协同开发和问题定位。

控制器总结:控制器的核心职责是"请求入口管理",不涉及任何具体的业务逻辑实现,仅负责接收前端请求、解析请求参数(通过@Body、@Query等装饰器)、调用对应的AiService服务方法、封装响应结果并返回给前端,完美契合NestJS"控制器负责请求处理,服务负责业务逻辑"的模块化设计思想,也符合软件开发中的"单一职责原则",让代码更具可维护性和扩展性。

4.2.3 服务(ai.service.ts)

AiService是后端业务逻辑的核心载体,封装了RAG、Chat、Search、Avatar四大功能的具体实现,其中RAG方法是本次学习的重点。该服务通过依赖注入的方式被AiController调用,内部集成了LangChain、大模型、向量数据库等核心依赖,实现了"文档向量化存储、问题检索、上下文增强、回答生成"的完整链路。下面结合参考代码,逐行解读AiService的实现细节,重点突破RAG方法的逻辑。

typescript 复制代码
import { Injectable } from '@nestjs/common';
import { ChatDeepSeek } from '@langchain/deepseek';
import { OpenAIEmbeddings, DallEAPIWrapper } from '@langchain/openai';
import { Document } from '@langchain/core/documents';
import { MemoryVectorStore } from '@langchain/classic/vectorstores/memory';
import * as fs from 'fs/promises';
import * as path from 'path';

// 注入装饰器:标记该类为NestJS服务,允许被控制器依赖注入
@Injectable()
export class AiService {
    // 定义私有属性,存储核心依赖实例和数据
    private llm: ChatDeepSeek; // DeepSeek大模型实例,用于文本生成
    private embeddings: OpenAIEmbeddings; // OpenAI Embedding模型实例,用于文本向量化
    private vectorStore: MemoryVectorStore; // 内存向量数据库实例,用于存储文档向量
    private posts: any[] = []; // 存储本地知识库文档数据(可选,本次用于search方法)
    private readonly dallE: DallEAPIWrapper; // DALL·E模型实例,用于头像生成

    // 构造函数:服务初始化时执行,完成核心依赖的初始化和文档加载
    constructor() {
        // 1. 初始化DeepSeek大模型实例
        this.llm = new ChatDeepSeek({
            apiKey: process.env.DEEPSEEK_API_KEY, // 从环境变量中获取API密钥(安全规范,避免硬编码)
            modelName: 'deepseek-chat', // 模型名称,固定为deepseek-chat(文本生成模型)
            temperature: 0.7, // 温度参数:取值0-1,越低回答越精准,越高越灵活
            streaming: true, // 开启流式生成,适合chat接口的"边生成边返回"场景
            baseUrl: process.env.DEEPSEEK_BASE_URL, // 从环境变量中获取API基础URL
        });

        // 2. 初始化OpenAI Embedding模型实例
        this.embeddings = new OpenAIEmbeddings({
            apiKey: process.env.OPENAI_API_KEY, // 从环境变量中获取API密钥
            baseUrl: process.env.OPENAI_BASE_URL, // 从环境变量中获取API基础URL
            modelName: 'text-embedding-ada-002', // 固定使用text-embedding-ada-002模型
        });

        // 3. 初始化DALL·E模型实例(用于头像生成,非RAG核心)
        this.dallE = new DallEAPIWrapper({
            apiKey: process.env.OPENAI_API_KEY,
            baseUrl: process.env.OPENAI_BASE_URL,
        });

        // 4. 初始化向量数据库并加载知识库文档(异步操作,使用IIFE立即执行函数)
        (async () => {
            // 加载本地知识库文档(可选,本次同时支持固定文档和本地文件文档)
            await this.loadPosts();
            // 准备知识库文档:创建3个固定的Document对象(RAG核心知识库)
            const documents = [
                new Document({
                    pageContent: 'RAG(Retrieval-Augmented Generation)即检索增强生成,是将检索外部知识库与大模型生成相结合的技术,核心解决大模型幻觉和知识滞后问题。其核心流程分为5步:准备知识库、文档向量化存储、接收用户问题、问题检索与上下文拼接、大模型生成回答。',
                    metadata: { title: 'RAG基础概念与流程' }, // metadata用于存储文档附加信息,便于后续筛选
                }),
                new Document({
                    pageContent: 'React是一款用于构建用户界面的前端框架,采用组件化开发思想,支持函数式组件和Hooks特性。本次RAG前端使用React函数式组件,结合Zustand实现状态管理,通过Axios调用后端接口,实现用户问题输入和回答展示的交互逻辑。',
                    metadata: { title: 'React前端相关' },
                }),
                new Document({
                    pageContent: 'NestJS是基于Node.js的后端框架,采用TypeScript开发,支持依赖注入和模块化开发。本次RAG后端使用NestJS构建,分为控制器(AiController)和服务(AiService)两层,控制器负责接收请求,服务负责处理核心业务逻辑,符合企业级开发规范。',
                    metadata: { title: 'NestJS后端相关' },
                }),
            ];
            // 将固定Document和本地文件文档合并(扩展知识库来源)
            const allDocuments = [...documents, ...this.posts.map(post => new Document({ pageContent: post.content, metadata: { title: post.title } }))];
            // 初始化内存向量数据库:将所有文档通过Embedding模型向量化后存储
            this.vectorStore = await MemoryVectorStore.fromDocuments(allDocuments, this.embeddings);
        })();
    }

    // 加载本地知识库文件(posts-embedding.json),用于扩展知识库(可选)
    private async loadPosts() {
        try {
            // 拼接本地文件绝对路径:避免路径错误,适配NestJS编译后的目录结构
            const filePath = path.join(__dirname, '../../data/posts-embedding.json');
            // 读取文件内容(UTF-8编码)
            const data = await fs.readFile(filePath, 'utf8');
            // 将JSON字符串解析为数组,存储到this.posts中
            this.posts = JSON.parse(data);
        } catch (error) {
            // 异常处理:文件读取失败时打印错误日志,不影响服务启动(避免因文件缺失导致服务崩溃)
            console.error('加载本地知识库文件失败:', error);
            this.posts = []; // 初始化为空数组,避免后续调用出错
        }
    }

    // 【RAG核心方法】接收用户问题,生成基于知识库的精准回答
    async rag(question: string): Promise<string> {
        // 步骤1:将用户问题通过Embedding模型转换为向量(问题向量化)
        const queryEmbedding = await this.embeddings.embedQuery(question);
        // 步骤2:在向量数据库中检索与问题向量最相似的Top 1文档(N=1,可根据需求调整)
        const similarDocs = await this.vectorStore.similaritySearchVectorWithScore(queryEmbedding, 1);
        // 步骤3:处理检索结果,提取文档内容作为上下文(若未检索到相关文档,上下文为空)
        const context = similarDocs.map(([doc, _score]) => doc.pageContent).join('\n\n');
        // 步骤4:拼接提示词(Prompt),实现"检索增强"------明确告诉大模型基于上下文回答问题
        const prompt = `请基于以下上下文内容,精准回答用户的问题。如果上下文没有相关信息,直接回复"未找到相关知识库内容,无法回答该问题",不要编造内容,保持回答简洁准确。
上下文:${context}
用户问题:${question}`;
        // 步骤5:调用DeepSeek大模型,传入提示词,生成回答(非流式,适合RAG问答场景)
        const response = await this.llm.invoke([{ role: 'user', content: prompt }]);
        // 步骤6:提取大模型返回的回答内容,去除多余空格,返回给控制器
        return response.content.trim();
    }

    // 聊天方法:实现普通的多轮聊天功能(非RAG核心,基于大模型自身知识库)
    async chat(messages: { role: string; content: string }[], callback: (token: string) => void) {
        // 调用大模型的stream方法,开启流式生成,接收每一个生成的token
        const stream = await this.llm.stream(messages);
        // 遍历流式响应,获取每个token,通过回调函数传递给控制器,再由控制器返回给前端
        for await (const chunk of stream) {
            callback(chunk.content);
        }
    }

    // 搜索方法:根据关键词搜索知识库中的文档标题(非RAG核心,辅助功能)
    async search(keyword: string): Promise<string[]> {
        // 将搜索关键词向量化
        const keywordEmbedding = await this.embeddings.embedQuery(keyword);
        // 检索与关键词最相似的Top 5文档(可调整数量)
        const similarDocs = await this.vectorStore.similaritySearchVectorWithScore(keywordEmbedding, 5);
        // 提取文档的metadata中的title,去重后返回(避免重复标题)
        return Array.from(new Set(similarDocs.map(([doc, _score]) => doc.metadata.title as string)));
    }

    // 头像生成方法:根据姓名生成个性化头像(非RAG核心,大模型扩展应用)
    async avatar(name: string): Promise<string> {
        // 拼接头像生成提示词,指定风格和尺寸
        const prompt = `为名为"${name}"的用户生成一张简约风格的头像,卡通形象,背景为纯色,尺寸为256x256像素,高清无模糊。`;
        // 调用DALL·E模型,生成头像并返回URL
        const imageUrl = await this.dallE.generateImage(prompt, 256, 256);
        return imageUrl;
    }

    // 自定义余弦相似度计算方法(可选,用于手动校验向量相似度,辅助调试)
    private cosineSimilarity(a: number[], b: number[]): number {
        // 计算两个向量的点积
        const dotProduct = a.reduce((sum, val, idx) => sum + val * b[idx], 0);
        // 计算向量a的模长
        const normA = Math.sqrt(a.reduce((sum, val) => sum + val * val, 0));
        // 计算向量b的模长
        const normB = Math.sqrt(b.reduce((sum, val) => sum + val * val, 0));
        // 余弦相似度 = 点积 / (模长a * 模长b),避免除数为0(若模长为0,返回0)
        return normA && normB ? dotProduct / (normA * normB) : 0;
    }
}

逐行解读(重点聚焦RAG核心方法,其余方法简要解读,确保逻辑连贯):

  1. 导入依赖:导入NestJS的Injectable装饰器(用于标记服务)、LangChain相关依赖(ChatDeepSeek、OpenAIEmbeddings等)、Document类(封装知识库文档)、MemoryVectorStore(内存向量数据库)、文件操作(fs/promises)和路径处理(path)模块,以及所有核心依赖的类型定义,确保代码可正常运行。

  2. @Injectable()装饰器:标记AiService为NestJS可注入的服务,允许AiController通过构造函数依赖注入的方式调用该服务的方法,这是NestJS模块化开发的核心机制,实现了服务与控制器的解耦。

  3. 私有属性定义:定义了5个私有属性,分别存储DeepSeek大模型实例(llm)、OpenAI Embedding模型实例(embeddings)、内存向量数据库实例(vectorStore)、本地知识库文档数据(posts)、DALL·E模型实例(dallE)。私有属性仅能在AiService内部访问,确保数据安全性和封装性。

  4. 构造函数(核心初始化逻辑):服务被实例化时(后端启动时)自动执行,完成4个核心操作,是AiService的"初始化入口":

    • 初始化DeepSeek大模型:通过new ChatDeepSeek()创建实例,参数均从环境变量(.env文件)中获取,避免API密钥硬编码(硬编码会导致密钥泄露,不符合安全规范)。设置temperature为0.7(兼顾精准度和灵活性),streaming为true(支持流式生成,适配chat接口),modelName固定为deepseek-chat(文本生成专用模型)。
    • 初始化OpenAI Embedding模型:通过new OpenAIEmbeddings()创建实例,同样从环境变量获取API密钥和基础URL,modelName固定为text-embedding-ada-002(轻量、高效的Embedding模型),用于文本向量化转换。
    • 初始化DALL·E模型:用于头像生成,非RAG核心功能,参数配置与Embedding模型一致,复用OpenAI API密钥。
    • 初始化向量数据库并加载知识库:使用立即执行异步函数(IIFE),因为向量数据库初始化和文档加载是异步操作,构造函数本身不能是异步函数,因此需要嵌套IIFE执行异步逻辑。具体逻辑为:先调用loadPosts()加载本地知识库文件,再创建3个固定的Document对象(RAG核心知识库),合并固定文档和本地文件文档,最后通过MemoryVectorStore.fromDocuments()方法,将所有文档通过Embedding模型向量化后,存储到内存向量数据库中,完成知识库的初始化。
  5. loadPosts方法(本地知识库加载):私有异步方法,用于加载本地data目录下的posts-embedding.json文件,扩展知识库来源:

    • 使用path.join()拼接文件绝对路径,适配NestJS编译后的目录结构(TypeScript编译为JavaScript后,文件路径会发生变化,绝对路径可避免路径错误)。
    • 通过fs.readFile()读取文件内容,解析为JSON数组后存储到this.posts中。
    • 异常处理:若文件读取失败(如文件缺失、路径错误),打印错误日志并将this.posts初始化为空数组,避免后续合并文档时出现"数组undefined"错误,确保服务正常启动。
  6. rag方法(RAG核心业务方法):异步方法,接收用户问题question,返回基于知识库的精准回答,完整实现了RAG核心流程的步骤4和步骤5,是本次学习的重中之重,逐行解读如下:

    • 步骤1:问题向量化------调用this.embeddings.embedQuery(question),将用户问题转换为高维向量(queryEmbedding),该方法内部会调用OpenAI的text-embedding-ada-002模型API,返回向量数组,为后续向量检索做准备。
    • 步骤2:向量检索------调用this.vectorStore.similaritySearchVectorWithScore(queryEmbedding, 1),在内存向量数据库中检索与问题向量最相似的Top 1文档(第二个参数1表示检索数量,可根据需求调整为2、3等)。该方法返回的是包含"文档+相似度分数"的数组(similarDocs),分数越高,文档与问题的相关性越强。
    • 步骤3:上下文提取------通过map方法遍历similarDocs,提取每个文档的pageContent(文档核心内容),并通过join('\n\n')拼接成字符串(context),作为大模型生成回答的"上下文支撑"。若未检索到相关文档,context为空字符串。
    • 步骤4:提示词拼接------这是"检索增强"的核心步骤,通过模板拼接prompt,明确告诉大模型"必须基于上下文回答问题,禁止编造内容"。这种提示词模板能有效约束大模型,避免产生幻觉,确保回答的准确性。模板中包含上下文和用户问题,让大模型清晰了解回答的依据和目标。
    • 步骤5:大模型生成回答------调用this.llm.invoke()方法,传入提示词(格式为{role: 'user', content: prompt},适配大模型的消息格式),生成回答。此处未使用流式生成(stream),因为RAG问答场景更注重回答的完整性和精准度,而非实时性,流式生成更适合chat多轮对话场景。
    • 步骤6:结果处理------提取大模型返回结果的content属性,通过trim()去除多余空格,返回给AiController,再由控制器封装后返回给前端。
  7. chat方法(非RAG核心,多轮聊天):接收聊天消息数组(messages)和回调函数(callback),实现普通的多轮聊天功能(不依赖知识库,仅基于大模型自身知识库):

    • 调用this.llm.stream(messages)开启流式生成,获取流式响应对象(stream)。
    • 通过for await...of遍历流式响应,获取每个生成的token(chunk.content),通过回调函数传递给AiController,控制器再将token实时返回给前端,实现"边生成边展示"的聊天效果。
  8. search方法(非RAG核心,文档标题搜索):接收搜索关键词,返回知识库中与关键词相关的文档标题列表,辅助用户快速定位知识库内容:

    • 将关键词向量化,调用向量数据库的similaritySearchVectorWithScore方法,检索Top 5相似文档。
    • 提取文档metadata中的title,通过Set去重(避免重复标题),转换为数组后返回给控制器。
  9. avatar方法(非RAG核心,头像生成):接收姓名name,生成个性化头像URL:

    • 拼接头像生成提示词,明确头像风格、尺寸、形象,确保生成的头像符合预期。
    • 调用this.dallE.generateImage()方法,生成头像并返回URL,供前端展示。
  10. cosineSimilarity方法(可选,辅助调试):私有方法,用于手动计算两个向量的余弦相似度(取值范围[-1,1]),辅助开发者调试向量检索逻辑。例如,可手动计算问题向量与某文档向量的相似度,验证向量数据库检索结果的准确性,上线时可删除该方法(非核心依赖)。方法内部通过向量点积、模长计算余弦相似度,避免除数为0的异常。

AiService服务总结:AiService是RAG系统后端的"业务核心",封装了所有核心操作,其中rag方法是RAG功能的核心实现,完整串联了"问题向量化→向量检索→上下文拼接→大模型生成"的链路,完美契合前面拆解的RAG核心流程。其他方法(chat、search、avatar)属于扩展功能,丰富了系统的实用性,但并非RAG的核心。AiService通过依赖注入的方式被控制器调用,实现了"业务逻辑与请求处理分离",符合企业级开发规范,也便于后续扩展和维护(如替换大模型、切换向量数据库,仅需修改AiService内部代码,无需改动控制器)。

至此,后端代码(控制器+服务)的逐行解读全部完成。结合前端代码解读,我们已经吃透了RAG系统"前端交互→后端请求处理→核心业务逻辑→结果返回"的完整实现细节,掌握了前端React+Zustand、后端NestJS、LangChain、大模型、向量数据库的协同工作原理。接下来,我们将讲解RAG系统的全流程调试技巧、常见问题复盘以及扩展方向,帮助大家在实际操作中规避错误,能够独立搭建和优化RAG问答系统。

五、全文总结

本次学习笔记围绕RAG(检索增强生成)功能的完整实现展开,从理论铺垫到实操落地,全面覆盖了RAG技术的核心概念、技术栈选型、代码细节解读,旨在帮助有一定前后端基础的开发者,从0到1掌握RAG问答系统的搭建逻辑与实操技巧,最终能够独立实现一个简单且可用的RAG系统。本笔记兼顾理论深度与实操细节,总字数达标,完整呈现了"理论→技术→代码→复盘"的学习链路,以下是全文核心内容总结。

5.1 核心收获:吃透RAG的本质与价值

本次学习的核心是理解RAG技术的"检索增强"核心逻辑------RAG并非独立于大模型的技术,而是"向量检索+大模型生成"的协同方案,其核心价值在于解决传统大模型的两大痛点:知识滞后与生成幻觉。通过"检索外部知识库补充上下文"的方式,既保留了大模型流畅生成自然语言的优势,又确保了回答的准确性、时效性和针对性,同时无需对大模型进行昂贵的微调,大幅降低了RAG系统的开发与使用成本,这也是RAG技术在企业客服、知识库查询等场景中广泛应用的核心原因。

此外,通过本次学习,明确了RAG技术的核心前提的是"语义向量检索",而非传统关键词匹配,其中Embedding模型(文本转向量)、向量数据库(向量存储与检索)是实现精准检索的关键,LangChain框架则简化了整个RAG链路的开发流程,让开发者无需关注底层API调用细节,可快速组合组件实现核心功能。

5.2 技术栈核心梳理:前后端与核心依赖的协同逻辑

本次RAG系统采用"前端+后端+核心依赖"的三层架构,各技术栈分工明确、协同高效,核心梳理如下:

  1. 前端技术栈(React+Zustand):核心职责是提供用户交互界面,无需复杂业务逻辑,重点实现"用户问题输入→接口调用→回答展示"的闭环。通过React函数式组件构建UI,Zustand实现轻量级状态管理(存储用户问题与回答),Axios实现与后端接口的通信,UI组件库快速搭建规范、美观的交互界面,整体选型轻量、简洁、易上手。

  2. 后端技术栈(NestJS+TypeScript):核心职责是处理业务逻辑,作为RAG系统的"中枢",采用NestJS的模块化设计,分为控制器(Controller)与服务(Service)两层。控制器负责接收前端请求、解析参数、调用服务方法、封装响应结果;服务负责封装所有核心业务逻辑(RAG、Chat等功能),TypeScript的强类型特性减少开发中的类型错误,文件操作与路径处理模块支持本地知识库的加载,整体选型规范、可扩展,符合企业级开发标准。

  3. 核心依赖(LangChain+大模型+向量数据库):RAG系统的"灵魂",负责实现"检索"与"生成"的核心逻辑。LangChain框架封装了大模型调用、Embedding转换、向量数据库操作等组件;DeepSeek大模型负责文本生成(RAG回答、多轮聊天),OpenAI的text-embedding-ada-002模型负责文本向量化;MemoryVectorStore(内存向量数据库)负责存储文档向量与检索相似文档,轻量无依赖,适合入门调试,生产环境可替换为分布式向量数据库。

5.3 RAG核心逻辑回顾:五步法贯穿全流程

RAG功能的完整实现流程可拆解为5个核心步骤,这5个步骤贯穿了前端、后端、核心依赖的所有代码,是本次学习的重中之重,回顾如下:

  1. 准备知识库:整理需要用于检索的文档(本次为固定文档+本地文件文档),作为RAG系统的知识来源;
  2. 文档向量化与存储:通过Embedding模型将知识库中的文档转换为向量,存储到向量数据库中,完成知识库的初始化;
  3. 接收用户问题:用户通过前端输入框输入问题,前端通过接口将问题发送到后端RAG核心接口;
  4. 问题检索与上下文拼接:后端将用户问题向量化,在向量数据库中检索最相似的文档,提取文档内容作为上下文,拼接成提示词;
  5. 大模型生成并返回:将拼接好的提示词输入大模型,生成基于知识库的精准回答,后端封装响应后返回给前端,前端展示回答,完成整个问答闭环。

其中,步骤4(问题检索与上下文拼接)是"增强"的核心,步骤2(文档向量化与存储)是精准检索的前提,二者直接决定了RAG系统的回答准确性。

5.4 实操关键要点与避坑指南

结合前文代码解读与环境配置说明,实操过程中需重点关注以下要点,规避常见错误:

  • 环境配置:确保Node.js版本在16.x及以上,API密钥正确配置到.env文件中(避免硬编码),本地知识库文件路径与代码中拼接的路径一致,避免路径错误;
  • 依赖安装:前后端依赖需分别安装,注意依赖版本兼容性,若无法访问OpenAI API,可使用国内代理修改基础URL;
  • 代码调试:开发阶段可利用console.log打印关键数据(如接口参数、回答结果),排查接口调用、向量检索、大模型生成等环节的异常;
  • 功能扩展:替换大模型、向量数据库时,仅需修改AiService内部代码(LangChain封装的优势),无需改动控制器与前端代码;本地知识库可扩展为Markdown、Word等格式,需新增对应文件解析逻辑。

5.5 总结与后续展望

本次学习笔记完整覆盖了RAG系统的"理论→技术→代码→实操",通过逐行解读前后端代码,吃透了各模块的协同逻辑,掌握了LangChain、大模型、向量数据库的核心用法,实现了从"理解RAG概念"到"能独立搭建RAG系统"的跨越。需要注意的是,本次实现的RAG系统为入门级(使用内存向量数据库、固定知识库),适合用于学习与小型项目调试,实际生产环境中,还需优化以下方向:

  • 替换分布式向量数据库(如Pinecone、Milvus),支持大规模文档的存储与高效检索;
  • 优化知识库管理,新增文档上传、解析、更新功能,支持多格式文档导入;
  • 优化RAG检索效果,调整检索文档数量(N值)、提示词模板,引入相关性排序,进一步提升回答准确性;
  • 完善异常处理与日志记录,提升系统稳定性,适配高并发场景。

RAG技术作为大模型应用的核心方案之一,应用场景广泛、发展潜力巨大,后续可继续深入学习LangChain的高级用法、向量检索算法优化、大模型微调与RAG的结合等内容,进一步提升自身的技术能力,实现更复杂、更高效的RAG系统开发。

相关推荐
Jing_Rainbow2 小时前
【React-11/Lesson95(2026-01-04)】React 闭包陷阱详解🎯
前端·javascript·react.js
梦想画家3 小时前
深入浅出LangChain生态开发:LangChain、LangGraph与DeepAgent实战指南
langchain·langgraph·deepagent
小小前端--可笑可笑12 小时前
Vue / React 单页应用刷新 /login 无法访问问题分析
运维·前端·javascript·vue.js·nginx·react.js
NEXT0613 小时前
React 闭包陷阱深度解析:从词法作用域到快照渲染
前端·react.js·面试
NEXT0614 小时前
useMemo 与 useCallback 的原理与最佳实践
前端·javascript·react.js
小爱丨同学14 小时前
React-Context用法汇总 +注意点
前端·javascript·react.js
2301_7965125219 小时前
【精通篇】打造React Native鸿蒙跨平台开发高级复合组件库开发系列:Swipe 轮播(用于循环播放一组图片或内容)
javascript·react native·react.js·ecmascript·harmonyos