基于 Node.js 与智谱 AI 的 RAG 工程实践
幻觉:一本正经地胡说八道,无法容忍于专业场景。知识滞后:训练数据截止后发生的事情,它完全不知道。私域盲区:它懂全人类的常识,但不懂你公司内部的规章制度、业务文档。最朴素的解决思路是把所有私域文档塞进 Prompt 让它读。但这受限于 LLM 的上下文窗口和高昂的 Token 成本。RAG(Retrieval-Augmented Generation,检索增强生成)应运而生。不让大模型翻阅整座图书馆
前段时间突然奇想想做一个基于本地资料回答的客服聊天,做了个demo放在gitee,个人感觉RAG是较可能应用到企业项目的一种模式,同时也能自己耍耍,故用AI总结出了一下这篇文章(基于自己一步步完善这个RAG项目的demo的提问与回答总结而来) ,遇到的很多问题其实是一些依赖的引入报错,只能退而让ai手搓简易的版本使用,如下面的向量存储
一、 为什么我们需要 RAG?
大语言模型(LLM)很强大,但在企业级应用中存在三个致命缺陷:
- 幻觉:一本正经地胡说八道,无法容忍于专业场景。
- 知识滞后:训练数据截止后发生的事情,它完全不知道。
- 私域盲区:它懂全人类的常识,但不懂你公司内部的规章制度、业务文档。
最朴素的解决思路是把所有私域文档塞进 Prompt 让它读。但这受限于 LLM 的上下文窗口和高昂的 Token 成本。
RAG(Retrieval-Augmented Generation,检索增强生成) 应运而生。它的核心哲学是:不让大模型翻阅整座图书馆,而是先帮它找出最相关的几页纸,让它只做这几页纸的阅读理解。
二、 核心技术栈与组件选型
在本次工程实践中,我们摒弃了沉重的 Python 体系,采用了纯 Node.js 方案,核心技术组件如下:
| 分类 | 技术/组件 | 作用说明 |
|---|---|---|
| 大语言模型 (LLM) | 智谱 AI (glm-4-flash) |
负责理解指令、基于检索上下文生成自然语言回答 |
| 向量化模型 | 智谱 AI (embedding-2) |
将文本转换为 1024 维的高维向量(语义 DNA) |
| 文本切分 | @langchain/textsplitters |
将长文本递归切分为固定长度的 Chunk,保留重叠度防语义截断 |
| 向量存储 | 纯 JS 自研 JSON 向量库 | 避开了 C++ 原生模块的编译坑,实现本地持久化与余弦相似度检索 |
| 后端框架 | Express.js |
提供 HTTP 接口,处理 SSE 流式响应 |
| 环境变量 | dotenv / zod |
安全管理 API Key,校验环境依赖 |
三、 RAG 全链路架构与核心代码
整个 RAG 系统分为两大阶段,数据流如下图所示:
[离线构建阶段] 文档 -> 切分 -> 调用智谱 Embedding API -> 向量数据存入本地 JSON 缓存
↑ (相似度计算)
[在线生成阶段] 用户提问 -> 调用智谱 Embedding API -> 问题向量 -------+----> 检索相关上下文
|
组装 Prompt (上下文 + 问题)
↓
调用智谱 GLM-4 API (流式) -> 返回给前端
1. 离线数据准备:构建本地 JSON 向量库
为了避免每次启动服务都重新计算向量消耗 Token,我们实现了一个基于 JSON 的极简持久化向量库。
const fs = require('fs');
const { RecursiveCharacterTextSplitter } = require("@langchain/textsplitters");
const { OpenAIEmbeddings } = require("@langchain/openai");
// 初始化智谱 Embedding 模型
const embeddings = new OpenAIEmbeddings({
openAIApiKey: process.env.ZHIPU_API_KEY,
modelName: "embedding-2",
configuration: { baseURL: "https://open.bigmodel.cn/api/paas/v4/" }
});
// 极简 JSON 向量库核心逻辑
class JsonVectorStore {
constructor(embeddings, filePath) {
this.embeddings = embeddings;
this.filePath = filePath;
this.data = []; // { content: string, embedding: number[] }
}
// 从文档创建并保存缓存
async saveFromDocuments(docs) {
const texts = docs.map(doc => doc.pageContent);
// 🔑 核心步骤:调用 API 批量生成向量
const vectors = await this.embeddings.embedDocuments(texts);
this.data = docs.map((doc, i) => ({ content: doc.pageContent, embedding: vectors[i] }));
// 持久化到本地硬盘
fs.writeFileSync(this.filePath, JSON.stringify(this.data), 'utf-8');
}
// 从本地缓存加载
load() {
if (fs.existsSync(this.filePath)) {
this.data = JSON.parse(fs.readFileSync(this.filePath, 'utf-8'));
return true;
}
return false;
}
// 语义检索:计算余弦相似度
async similaritySearch(query, k = 3) {
const queryVector = await this.embeddings.embedQuery(query); // 仅将问题向量化
const results = this.data.map(item => ({
content: item.content,
similarity: this.cosineSimilarity(queryVector, item.embedding)
}));
results.sort((a, b) => b.similarity - a.similarity);
return results.slice(0, k);
}
cosineSimilarity(vecA, vecB) { /* 余弦相似度数学公式... */ }
}
2. 在线生成:路由层的 Prompt 组装与流式输出
当用户发起请求时,后端的核心职责是:检索 -> 约束 Prompt -> 流式响应。
// routes/chatRoute.js
router.post('/langchain', async (req, res) => {
const { messages } = req.req.body;
const question = messages[messages.length - 1].content;
// 1. 本地检索(纯数学计算,毫秒级,不消耗大模型 Token)
const relatedDocs = await searchRelatedDocs(question, 2);
const contextText = relatedDocs.map(d => d.pageContent).join("\n---\n");
// 2. 构建 Prompt:严格防止幻觉(最后一道防线)
let finalSystemPrompt = process.env.SYSTEM_IDENTITY;
if (contextText) {
// ⚠️ 关键避坑:必须明确指示大模型“只依赖资料”,否则检索噪音会引发幻觉
finalSystemPrompt += `【参考信息】\n${contextText}\n\n请严格根据上面的【参考信息】回答用户问题。如果参考信息中没有包含所需内容,请直接回复“根据现有知识库无法回答”,严禁编造。`;
} else {
finalSystemPrompt += `请根据你的通用知识回答用户的问题。`;
}
// 3. 调用智谱 GLM 大模型进行流式生成
const stream = await streamChat([
{ role: "system", content: finalSystemPrompt },
{ role: "user", content: question }
]);
// 4. 通过 SSE 将流式数据推送给前端...
res.setHeader('Content-Type', 'text/event-stream');
for await (const chunk of stream) {
res.write(`data: ${JSON.stringify({ content: chunk.content })}\n\n`);
}
res.end();
});
四、 工程踩坑与深度认知 (面试高光时刻)
在真实工程落地中,跑通 Demo 只是第一步,以下三个深度认知决定了 RAG 系统的可用性:
1. 为什么非要算成向量?关键字匹配不行吗?
关键字匹配(如 SQL LIKE)基于字面重合,它不懂“失眠”和“睡不着”是一个意思。向量是语义层面的表示,在向量空间中,意思相近的文本距离天然相近。向量化,是把“语义匹配”降维成了“数学计算”,从而实现了毫秒级的语义检索。
2. 既然检索出的已经是文本,为什么还要写严格的 Prompt 约束?
向量检索存在误召回率。有时用户问“报销流程”,检索出的却是“开发流程”(因为都有“流程”)。
如果不限制大模型,它会顺着错误资料胡编乱造(幻觉放大)。严格的 Prompt 是守住准确性的最后一道防线。大模型在 RAG 中的角色不是发散创作,而是受限条件下的阅读理解。
3. 为什么放弃成熟的向量库(HNSWLib/Faiss),改用 JSON?
在 Node.js 环境下,hnswlib-node 和 faiss-node 都是 C++ 原生编译模块。在 Windows 环境下极易因缺少 Visual Studio Build Tools 或 Node.js 版本不匹配导致编译失败(ERR_PACKAGE_PATH_NOT_EXPORTED)。
对于中小型知识库,基于文件系统的 JSON 缓存 + 内存余弦计算,零依赖、无需编译、永不报错,是最稳健的起步方案。
五、 生产级 RAG 的进阶方向
当前的极简方案足以应对中小型知识库,若要走向生产环境,还需考虑:
- 智能分块:按 Markdown 标题、代码块逻辑切分,而非简单按字数。
- 多路召回 + Reranker:结合 BM25(关键字召回)和向量召回,再用交叉编码器重排序。
- Agent 融合:让大模型自主决定何时检索本地知识库,何时调用外部工具。
更多推荐



所有评论(0)