前段时间突然奇想想做一个基于本地资料回答的客服聊天,做了个demo放在gitee,个人感觉RAG是较可能应用到企业项目的一种模式,同时也能自己耍耍,故用AI总结出了一下这篇文章(基于自己一步步完善这个RAG项目的demo的提问与回答总结而来) ,遇到的很多问题其实是一些依赖的引入报错,只能退而让ai手搓简易的版本使用,如下面的向量存储

一、 为什么我们需要 RAG?

大语言模型(LLM)很强大,但在企业级应用中存在三个致命缺陷:

  1. 幻觉:一本正经地胡说八道,无法容忍于专业场景。
  2. 知识滞后:训练数据截止后发生的事情,它完全不知道。
  3. 私域盲区:它懂全人类的常识,但不懂你公司内部的规章制度、业务文档。

最朴素的解决思路是把所有私域文档塞进 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 的进阶方向

当前的极简方案足以应对中小型知识库,若要走向生产环境,还需考虑:

  1. 智能分块:按 Markdown 标题、代码块逻辑切分,而非简单按字数。
  2. 多路召回 + Reranker:结合 BM25(关键字召回)和向量召回,再用交叉编码器重排序。
  3. Agent 融合:让大模型自主决定何时检索本地知识库,何时调用外部工具。
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐