基于Nestjs+Langchainjs构建企业级RAG知识库系统
2026/8/30 4:34:15 网站建设 项目流程

如果你正在做全栈开发,又刚接触 AI 应用,最近一定绕不开一个词:RAG。不管是大模型课程、知识库问答、企业私有数据检索,还是 Agent 类项目,RAG 几乎成了 AI 应用落地的默认方案。而“如何把 RAG 做成一个真正能上线的系统”,和“跑通一个 RAG demo”之间,隔着一条巨大的工程鸿沟。

我先说一个明确判断:用 Nestjs + Langchainjs 搭建企业级 RAG 系统,是目前 Node.js 全栈技术栈里兼顾学习价值和落地效率的一条路。它让前端、后端和 AI 工程能力统一在 TypeScript 生态里,不需要为 AI 功能单独维护一套 Python 服务,这在中小型团队里尤其有吸引力。

这篇文章不是简单介绍 RAG 概念,我会从一个企业级知识库系统的实际痛点出发,拆解 RAG 系统的完整架构,并带你把文档处理、向量化、检索、问答链路全部跑通。读完之后,你能回答这几个问题:RAG 系统到底由哪些模块组成?Nestjs 在 AI 项目里承担什么角色?Langchainjs 比直接调大模型 API 多解决了什么问题?以及,真正上线时最容易踩的坑在哪里。

1. 企业级 RAG 系统为什么难落地

很多人第一次接触 RAG,是在 LangChain 文档或某段教程里,几十行代码就完成了“上传文档 -> 提问 -> 得到答案”。但一旦想把它做成公司内部的知识库系统,立刻会发现 demo 离可用还差得很远。

第一层问题是数据工程。真实业务里的文档不是干净的纯文本,而是 PDF、Word、Markdown、HTML、Excel 混合在一起。有的文档有扫描件,有的是表格,有的标题层级混乱。把这些文档统一解析成能被模型理解的文本,本身就是一项工作。更麻烦的是切块策略:切小了语义不完整,切大了检索噪声高,不同文档类型要设计不同的切分逻辑。

第二层问题是检索质量。很多人以为 RAG 就是把文本塞进向量数据库,然后做相似度检索。实际生产环境中,查询词和文档措辞往往不一致,比如用户问“最近一次版本发布了哪些功能”,文档里写的是“v2.3 上线公告”,单纯靠向量相似度可能匹配不到。还要处理权限过滤、元数据过滤、索引更新、问答历史等多重因素。

第三层问题是工程化。模型调用超时怎么办?大文件上传要不要异步处理?向量库数据怎么备份?用户输入怎么防注入?日志和链路追踪怎么做?这些问题和传统后端工程没有本质区别,但在 AI 项目里往往被忽略。

所以 RAG 系统的真正难点,不是“怎么调用大模型”,而是“怎么把 AI 能力嵌入到一套稳定的后端工程里”。这也是我为什么推荐 Nestjs —— 它本身就是一个强调架构的 Node.js 框架,模块化、依赖注入、守卫、管道这些工程能力,放在 AI 项目里同样适用。

2. 为什么选 Nestjs + Langchainjs 这个组合

先给结论:Nestjs 解决工程架构问题,Langchainjs 解决 AI 集成问题,两者都是 TypeScript,可以在同一套代码里完成全栈 AI 应用

2.1 Nestjs 在 AI 项目中的角色

Nestjs 是 Node.js 生态里最接近企业级 Java/Spring 风格的框架。它提供模块化组织、依赖注入、装饰器、守卫、拦截器、管道等能力,非常适合由多人协作、需要长期迭代的中大型项目。

在 RAG 系统里,Nestjs 负责的东西包括:

  • 提供 HTTP 接口,接收文档上传和问答请求。
  • 统一管理配置、日志、鉴权、接口校验。
  • 在模块内封装向量数据库、大模型客户端、文档解析等基础设施。
  • 处理异步任务,比如文档解析和向量化是耗时操作,不应阻塞主流程。

如果你只写一个脚本跑 RAG,Nestjs 看起来“重”。但如果要做一个有用户、有权限、有后台管理界面的企业级系统,Nestjs 的结构化优势就体现出来了。

2.2 Langchainjs 解决什么问题

Langchainjs 是 LangChain 的 TypeScript 版本,核心价值是提供一套抽象层,让开发者不必直接面对各家 API 的差异。

比如嵌入模型可能用 OpenAI、智谱、通义千问,向量存储可能用 Chroma、Pinecone、Milvus、PGVector,LLM 可能用 GPT-4、Claude、Qwen。如果全部手写,每换一家就要改一遍代码。而 Langchainjs 把这些封装成统一接口,代码里只面对EmbeddingsVectorStoreLLM抽象,切换厂商时改动很小。

Langchainjs 还提供了一组常用的 AI 组件:文档加载器(DocumentLoader)、文本切分器(TextSplitter)、向量存储器(VectorStore)、检索器(Retriever)、以及可组合的链(Chain)或RunnableSequence。这些组件互相配合,基本覆盖了一个 RAG 系统的主链路。

2.3 与 FastAPI + LangChain Python 方案的对比

维度Nestjs + LangchainjsFastAPI + LangChain Python
语言栈前后端统一 TypeScriptAI 后端单独 Python
学习成本一个语言栈搞定需要维护 JS/Python 两套
AI 生态组件在快速完善中生态更丰富、社区更大
工程化能力模块化、依赖注入强依赖开发者自觉
混合团队纯前端/Node 后端团队友好Python AI 团队更熟悉

如果你的团队本来就是 Node.js 技术栈,引入 Langchainjs 比重新养一支 Python 服务团队成本低得多。这不是说 Python 方案不好,而是不同的团队约束下,Node.js 全栈方案往往是更务实的选择。

3. RAG 系统架构与核心模块拆解

在写代码之前,先把 RAG 系统的整体架构看清楚。一个可上线的 RAG 知识库系统,一般分为五个核心模块。

3.1 文档接入与解析层

这一层解决“文档怎么进来”的问题。用户上传 PDF、Word、Markdown 文件后,系统需要完成:

  • 文件格式校验,限制大小和类型。
  • 解析文件内容,提取正文和基础元数据。
  • 大文件做异步处理,避免阻塞接口。

在 Langchainjs 里,这一步可以用文档加载器完成。文本文件用TextLoader,PDF 用PDFLoader,Markdown 有专门的MarkdownTextSplitter辅助处理。

3.2 切分与清洗层

切分是 RAG 系统里最容易忽视却又影响最大的一步。切分就是把长文档切成若干块(chunk),每一块会被向量化后存入向量数据库。

切分策略通常有三种:

  • 固定长度切分:按 token 数或字符数切开,简单但容易切断语义。
  • 递归字符切分:按段落、句子、标点逐级切分,Langchainjs 有现成的RecursiveCharacterTextSplitter,这是默认推荐。
  • 语义切分:根据 embedding 相似度或文档结构识别语义边界,效果更好但成本更高。

切分后的文本还要做清洗,比如去掉无意义的换行符、空行、Logo 文案、页眉页脚。

3.3 向量化与存储层

文本切分完成后,需要调用嵌入模型生成向量,然后写入向量数据库。向量数据库的作用是存储向量,并提供近似检索能力。

常见选择:

向量数据库优点适合场景
Chroma轻量,本地即可运行个人项目、学习演示
Milvus分布式,性能强生产环境、大规模数据
PGVector复用 PostgreSQL团队已有 PG 基础设施
Pinecone全托管,免运维不想维护基础设施

这里需要注意:向量数据库一旦选型,后续替换成本不小,所以应该根据团队实际情况做决定。

3.4 检索层

检索层决定“找什么”。用户提问后,系统会把问题向量化,然后在向量库中执行相似度检索。但一个合格的企业级系统不能只做向量检索,还需要:

  • 支持关键词检索与向量检索的混合模式。
  • 支持元数据过滤,比如只检索某个部门、某个项目的文档。
  • 支持权限过滤,保证用户只能看到自己有权访问的资料。

Langchainjs 的检索器抽象层可以自由组合这些逻辑。

3.5 问答生成层

检索到相关文档片段后,需要将用户问题与文档片段一起提交给大模型,由模型生成最终答案。这里的关键参数是 Prompt 模板设计,以及是否允许模型在信息不足时明确说“不知道”。模型幻觉问题也要在这一层控制。

4. 环境准备与项目初始化

本文的示例会以本地可运行的轻量方案为主:使用 SQLite 或内存方式简化依赖,向量库使用 Chroma(支持本地持久化),大模型使用 OpenAI 兼容接口。你只需要准备以下环境。

4.1 环境要求

  • Node.js 18 或以上版本。
  • npm / pnpm 任一包管理器。
  • Nestjs CLI(可选,也可以手动初始化)。
  • 本地 Docker(如果希望通过 Docker 启动 Chroma)。

我不会把版本号写死,因为依赖库更新较快。安装时建议以latest稳定版本为准,重点演示通用思路。

4.2 创建 Nestjs 项目

# 全局安装 Nest CLI npm install -g @nestjs/cli # 创建一个新项目 nest new ai-knowledge-base # 进入项目目录 cd ai-knowledge-base

执行安装时选择包管理器,推荐 pnpm,因为它对依赖的隔离更好,安装速度也快。

4.3 安装 Langchainjs 相关依赖

npm install langchain @langchain/core @langchain/openai @langchain/community npm install chromadb uuid

这里简单说明几个包的分工:

  • langchain:Langchainjs 主包,提供链、工具函数、文本切分器。
  • @langchain/core:核心抽象层,包含RunnableEmbeddingsVectorStore等基础接口。
  • @langchain/openai:OpenAI 兼容模型的封装,支持 embeddings 和 chat。
  • @langchain/community:社区维护的集成,包括各种加载器和向量数据库适配器。
  • chromadb:Chroma 的 JavaScript 客户端。

4.4 配置环境变量

在项目根目录创建.env文件:

OPENAI_API_KEY=your_api_key_here OPENAI_BASE_URL=https://api.openai.com/v1 EMBEDDING_MODEL=text-embedding-3-small LLM_MODEL=gpt-4o-mini CHROMA_URL=http://localhost:8000

如果你使用的是国内大模型厂商的 OpenAI 兼容接口,把OPENAI_BASE_URL换成厂商地址即可。EMBEDDING_MODELLLM_MODEL换成厂商支持的模型名。

4.5 启动 Chroma 向量数据库

最简单的方式是使用 Docker 启动:

docker run -p 8000:8000 chromadb/chroma

启动后,Chroma 默认监听http://localhost:8000。如果没有 Docker,也可以在 Python 环境运行 Chroma 命令行版本,但 Docker 是更省事的方案。

5. 核心代码实现:模块划分与文档入库流程

现在开始写代码。我的思路是先把 Nestjs 的模块结构定好,再逐步实现“文档入库”和“问答检索”两条链路。

5.1 项目目录结构

src/ ├── app.module.ts ├── main.ts ├── rag/ │ ├── rag.module.ts │ ├── rag.controller.ts │ ├── rag.service.ts │ ├── config/ │ │ └── langchain.config.ts │ └── dto/ │ ├── ingest.dto.ts │ └── query.dto.ts

rag模块负责整个 RAG 能力,控制器暴露 HTTP 接口,服务层实现业务逻辑。

5.2 Langchain 配置模块:统一管理模型与向量库

在企业项目中,配置需要集中管理,不能散落在各个 service 里。我建了一个langchain.config.ts,负责初始化嵌入模型和共享 Token 计算器。

// src/rag/config/langchain.config.ts import { OpenAIEmbeddings } from "@langchain/openai"; import { config } from "dotenv"; config(); export const getEmbeddings = () => { return new OpenAIEmbeddings({ model: process.env.EMBEDDING_MODEL || "text-embedding-3-small", apiKey: process.env.OPENAI_API_KEY, configuration: { baseURL: process.env.OPENAI_BASE_URL, }, }); };

这里把嵌入模型封装成工厂函数,避免每次调用都新建实例。注意configuration.baseURL用于兼容 OpenAI 兼容接口,如果你的服务商不需要,可以省略。

5.3 RAG Service:实现文档解析、切分、向量化

rag.service.ts是核心逻辑所在。下面这段代码实现了从上传文件到写入向量数据库的完整流程。

// src/rag/rag.service.ts import { Injectable } from "@nestjs/common"; import { RecursiveCharacterTextSplitter } from "langchain/text_splitter"; import { Chroma } from "@langchain/community/vectorstores/chroma"; import { getEmbeddings } from "./config/langchain.config"; import { v4 as uuidv4 } from "uuid"; @Injectable() export class RagService { private collectionName = "knowledge_base"; async ingestDocument(file: Express.Multer.File) { // 1. 读取文件内容,这里以 txt / md 为例 const content = file.buffer.toString("utf-8"); // 2. 切分文档 const splitter = new RecursiveCharacterTextSplitter({ chunkSize: 500, chunkOverlap: 50, }); const chunks = await splitter.splitText(content); // 3. 构造文档列表 const documents = chunks.map((text, index) => ({ pageContent: text, metadata: { id: uuidv4(), source: file.originalname, chunkIndex: index, }, })); // 4. 写入向量数据库 const vectorStore = await Chroma.fromDocuments( documents, getEmbeddings(), { collectionName: this.collectionName, url: process.env.CHROMA_URL, } ); return { success: true, totalChunks: documents.length, source: file.originalname, }; } }

这段代码有几点需要说明。

切分参数chunkSize: 500表示每块大约 500 个字符,chunkOverlap: 50表示相邻块重叠 50 个字符。重叠的目的是防止语义被切断。实际项目中需要根据文档类型和模型上下文窗口调整这两个参数。

元数据:每块文档都携带sourcechunkIndex,检索时可以据此追溯来源。生产环境还会加上部门、权限等级、上传时间等元数据。

Chroma.fromDocuments:这个方法会先把文档切块写入集合,再计算向量。如果集合不存在会自动创建。

5.4 Ingest DTO:定义接口入参

在 Nestjs 中,DTO 用来定义接口参数结构,并配合验证管道使用。

// src/rag/dto/ingest.dto.ts import { IsNotEmpty } from "class-validator"; export class IngestDto { @IsNotEmpty({ message: "文件不能为空" }) file: Express.Multer.File; @IsNotEmpty({ message: "知识库名称不能为空" }) collectionName: string; }

这里我把collectionName放进了 DTO,方便后续支持多个知识库。不过为了方便演示,上面的 service 写法是写死了集合名称,实际项目需要按 DTO 传入。

5.5 RAG Controller:暴露上传接口

控制器负责接收 HTTP 请求,调用 service。

// src/rag/rag.controller.ts import { Controller, Post, UploadedFile, UseInterceptors, Body, } from "@nestjs/common"; import { FileInterceptor } from "@nestjs/platform-express"; import { RagService } from "./rag.service"; @Controller("rag") export class RagController { constructor(private readonly ragService: RagService) {} @Post("ingest") @UseInterceptors(FileInterceptor("file")) async ingest( @UploadedFile() file: Express.Multer.File, @Body("collectionName") collectionName: string ) { if (!file) { return { success: false, message: "请上传文件" }; } return this.ragService.ingestDocument(file, collectionName); } }

FileInterceptor是 Nestjs 提供的文件上传拦截器,它会自动解析multipart/form-data请求里的file字段,注入到@UploadedFile()参数中。

6. 检索与问答链路实现

入库只是第一步,用户真正关心的是问答能力。这一节实现完整的检索问答链路。

6.1 检索器封装

rag.service.ts中增加一个方法,用于从用户问题中检索相关文档片段。

// 在 RagService 中新增方法 async getRetriever() { const vectorStore = await Chroma.fromExistingCollection( getEmbeddings(), { collectionName: this.collectionName, url: process.env.CHROMA_URL, } ); return vectorStore.asRetriever(4); }

asRetriever(4)表示每次召回 4 个最相关的文本块。召回数量需要根据模型上下文长度和文档切块大小调整。

6.2 问答链实现

Langchainjs 里,可以通过RunnableSequence把检索器和模型串联起来。

// src/rag/rag.service.ts 追加 import { ChatOpenAI } from "@langchain/openai"; import { PromptTemplate } from "@langchain/core/prompts"; import { RunnableSequence } from "@langchain/core/runnables"; import { StringOutputParser } from "@langchain/core/output_parsers"; async query(question: string) { const retriever = await this.getRetriever(); const llm = new ChatOpenAI({ model: process.env.LLM_MODEL || "gpt-4o-mini", apiKey: process.env.OPENAI_API_KEY, temperature: 0.2, configuration: { baseURL: process.env.OPENAI_BASE_URL, }, }); const prompt = PromptTemplate.fromTemplate(` 你是一个企业内部知识库助手。请根据以下资料回答用户问题。 如果资料中没有相关信息,请明确回答"资料库中未找到相关信息",不要编造。 相关资料: {context} 用户问题: {question} `); const chain = RunnableSequence.from([ { question: (input: { question: string }) => input.question, context: async (input: { question: string }) => { const docs = await retriever.invoke(input.question); return docs.map((doc) => doc.pageContent).join("\n\n"); }, }, prompt, llm, new StringOutputParser(), ]); const answer = await chain.invoke({ question }); return { answer, question, }; }

这段代码的核心是RunnableSequence。它定义了执行顺序:先取用户问题,通过检索器获取相关资料,然后填入 Prompt 模板,交给模型生成回答。

Prompt 里专门加了一句“没有信息时明确回答”,这是减少幻觉的重要手段。幻觉是指模型在没有依据的情况下编造答案,在知识库场景里非常危险。

6.3 完整 Controller

rag.controller.ts增加一个查询接口。

// src/rag/rag.controller.ts 追加 @Post("query") async query(@Body() body: { question: string }) { if (!body.question) { return { success: false, message: "问题不能为空" }; } return this.ragService.query(body.question); }

此时整个最小闭环已经完成:上传文档 -> 切分向量化 -> 检索召回 -> 模型生成答案。

7. 运行验证与效果测试

代码写完后,需要完整跑一遍流程验证结果。

7.1 启动项目

# 确保 Chroma 已在运行 docker ps | grep chroma # 启动 Nestjs npm run start:dev

启动成功的标志是终端输出 Nestjs 的启动日志,提示应用监听在3000端口。

7.2 准备测试文档

在项目根目录创建test.md

# 公司差旅报销制度 1. 员工出差前需在 OA 系统提交出差申请。 2. 出差费用包括交通费、住宿费、餐饮补贴。 3. 住宿费标准为一线城市每天 500 元,其他城市每天 350 元。 4. 报销需要在出差结束后 14 天内提交。 5. 所有报销单据需附发票原件。

这是一个典型的内部知识库文档,用于测试检索效果。

7.3 调用文档入库接口

curl -X POST http://localhost:3000/rag/ingest \ -F "file=@test.md" \ -F "collectionName=knowledge_base"

预期返回:

{ "success": true, "totalChunks": 2, "source": "test.md" }

如果返回totalChunks: 0,说明文件内容为空或切分参数导致没有产出块,检查文件编码和内容。

7.4 调用问答接口

curl -X POST http://localhost:3000/rag/query \ -H "Content-Type: application/json" \ -d '{"question":"一线城市的住宿费标准是多少?"}'

预期输出中应包含“500 元”以及相关上下文。判断成功的标准是:回答内容来自文档,而不是模型凭空生成;同时回答中含有文档里的事实细节。

7.5 失败排查顺序

如果接口调用失败,按以下顺序排查:

  1. 看 Nestjs 控制台日志,确认请求是否到达 Controller。
  2. 确认 Chroma 容器是否正常,容器日志是否有报错。
  3. 确认.env里的 API Key 和 Base URL 是否正确。
  4. 确认嵌入模型名称是否在服务商支持列表中。

8. 常见问题与排查思路

问题现象可能原因排查方式解决方案
上传文件后 totalChunks 为 0文件内容为空或切分后内容被过滤打印切分后的文本长度检查文件编码,调整 chunkSize
400 错误,提示 collection 已存在Chroma 集合重复创建查看 Chroma 日志或使用客户端列出集合删除旧集合或使用新的 collectionName
检索结果与问题不相关切分过大/过小,或嵌入模型质量不足查看召回的原始文档片段调整 chunkSize 和 chunkOverlap,更换嵌入模型
模型回答带有编造内容Prompt 未限制幻觉,或检索上下文为空查看传入模型的实际 context在 Prompt 中明示“无信息时不要编造”
接口超时大文件解析慢,或模型响应慢查看日志耗时统计大文件改异步任务,模型调用加超时配置
向量库连接失败Chroma 未启动或地址错误检查 docker 容器和端口确认 CHROMA_URL 配置,重启容器

在实际项目排查时,优先看日志。建议在关键步骤打日志,包括文档解析耗时、切分块数量、召回文档数量、模型响应耗时,这些指标能快速定位问题环节。

9. 从 Demo 走向企业级的最佳实践

把上面的最小系统放到真实业务里,还需要补齐几件事。

9.1 异步化文档处理

文档解析和向量化是耗时操作,几 MB 的文件可能耗时数秒甚至更长。如果用户在请求里等待同步处理,体验很差。生产环境推荐把文档处理放到异步队列里,比如 BullMQ + Redis,上传接口立即返回“处理中”,处理完成后通过回调或轮询通知状态。

9.2 检索策略升级

纯向量检索不是银弹。实际项目中推荐混合检索:同时使用向量检索和关键词检索(比如 BM25),再通过 RRF(Reciprocal Rank Fusion)合并结果。这样能兼顾语义匹配和关键词精确匹配。Langchainjs 生态里有EnsembleRetriever可以组合多个检索器,值得研究。

9.3 权限与安全边界

企业知识库必然有权限需求。最常见的做法是在元数据中带上权限标签,检索时根据当前用户身份生成过滤条件。这里要特别提醒:LLM 不能替代权限控制,你可以在 Prompt 里说“只回答有权限的内容”,但模型不一定严格遵守。必须在检索层就把无权访问的内容过滤掉,确保模型根本看不到。

用户输入也要做防护。虽然 RAG 场景的 Prompt 注入风险比纯 Agent 场景低,但如果用户输入被拼进 Prompt,理论上可能诱导模型输出预设内容。建议对输入长度做限制,并对危险指令模式做拦截。

9.4 可观测性与评估

AI 系统有一个特征:不确定性强。同一个问题,可能今天答对明天答错。所以生产系统一定要有日志和评估机制。

至少要做到:

  • 记录每次问答的完整链路:问题、检索到的文档块、Prompt、模型答案、耗时。
  • 定期人工抽检回答质量。
  • 建立评估数据集,对切块策略和检索方案的改动做回归验证。

9.5 成本控制与缓存

每次问答都调用大模型,成本会随用户量线性增长。可以考虑对常见问题做缓存,命中缓存就不再调用模型。还可以根据场景选择合适的模型,简单问答用迷你模型,复杂推理用大模型,而不是所有请求都走同一个模型。

10. 总结与下一步实践建议

这篇文章介绍了如何用 Nestjs + Langchainjs 从 0 到 1 搭建一套 RAG 知识库系统。核心链路是:文档解析 -> 文本切分 -> 向量化入库 -> 检索召回 -> LLM 生成回答。同时讨论了为什么这个技术组合值得关注,以及从 Demo 到企业级还需要补齐的工程能力。

现在最值得做的下一步,是把仓库克隆到本地,先用一个 Markdown 文档跑通全链路,感受一下“文档入库后能回答问题”是个什么体验。再慢慢加入异步处理、权限过滤和混合检索。RAG 的入门门槛不高,但真正做好,比拼的是数据工程和系统工程能力,而这正是全栈工程师的强项。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询