如果你正在做全栈开发,又刚接触 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 把这些封装成统一接口,代码里只面对Embeddings、VectorStore、LLM抽象,切换厂商时改动很小。
Langchainjs 还提供了一组常用的 AI 组件:文档加载器(DocumentLoader)、文本切分器(TextSplitter)、向量存储器(VectorStore)、检索器(Retriever)、以及可组合的链(Chain)或RunnableSequence。这些组件互相配合,基本覆盖了一个 RAG 系统的主链路。
2.3 与 FastAPI + LangChain Python 方案的对比
| 维度 | Nestjs + Langchainjs | FastAPI + LangChain Python |
|---|---|---|
| 语言栈 | 前后端统一 TypeScript | AI 后端单独 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:核心抽象层,包含Runnable、Embeddings、VectorStore等基础接口。@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_MODEL和LLM_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.tsrag模块负责整个 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 个字符。重叠的目的是防止语义被切断。实际项目中需要根据文档类型和模型上下文窗口调整这两个参数。
元数据:每块文档都携带source和chunkIndex,检索时可以据此追溯来源。生产环境还会加上部门、权限等级、上传时间等元数据。
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 失败排查顺序
如果接口调用失败,按以下顺序排查:
- 看 Nestjs 控制台日志,确认请求是否到达 Controller。
- 确认 Chroma 容器是否正常,容器日志是否有报错。
- 确认
.env里的 API Key 和 Base URL 是否正确。 - 确认嵌入模型名称是否在服务商支持列表中。
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 的入门门槛不高,但真正做好,比拼的是数据工程和系统工程能力,而这正是全栈工程师的强项。