实现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系统开发。

相关推荐
风落无尘7 小时前
我用 LangChain 写了一个带“定速巡航”的向量化工具,发布到 PyPI 了!
人工智能·python·langchain
BU摆烂会噶8 小时前
【LangGraph】 流式处理入门
人工智能·python·langchain·人机交互
大模型真好玩8 小时前
LangChain DeepAgents 速通指南(八)—— DeepAgents流式输出详解
人工智能·langchain·agent
沪漂阿龙8 小时前
AI Agent爆火,但你真的懂LangChain吗?——大模型智能体开发完全指南
人工智能·langchain
庞轩px8 小时前
LangChain不是“套壳”——它解决了什么实际问题
langchain·大模型·agent·tool·ai应用开发
openKaka_8 小时前
为什么 React 18 之后使用 createRoot,而不是 ReactDOM.render
前端·javascript·react.js
qq_283720058 小时前
LangChain 动态模型中间件实战使用技巧
中间件·langchain·middleware·wrap_model_call
老王以为9 小时前
从源码到架构:React useActionState 深度剖析
前端·javascript·react.js
天蓝色的鱼鱼10 小时前
当AI开始替我写代码,我还要纠结选Vue还是React吗?
vue.js·react.js·ai编程
JaydenAI11 小时前
拆解LangChain执行引擎[博文汇总-17篇]
langchain·langgraph·pregel