150行代码搞定私有知识库!Node.js + LangChain 打造最小化 RAG 系统全流程

摘要:检索增强生成(RAG)是当前大模型应用落地的核心范式。本文将通过一个温馨的"光光和东东"友情故事案例,手把手带你使用 LangChain 框架,从向量数据库构建、语义检索到 Prompt 工程,完整实现一个基于本地内存的 RAG 问答系统。无需复杂的环境配置,只需几行代码,让你的 AI 拥有"长期记忆"。


前言:为什么我们需要 RAG?

在 2026 年的今天,大语言模型(LLM)已经无处不在。然而,直接使用原生模型往往面临两个痛点:

  1. 幻觉问题:模型可能会一本正经地胡说八道,编造不存在的事实。
  2. 知识滞后与私有化缺失:模型训练数据截止于过去,它不知道你的公司内部文档,也不知道你刚刚上传的"光光和东东"的故事。

RAG(Retrieval-Augmented Generation,检索增强生成) 正是为了解决这些问题而生。它的核心逻辑非常简单:先检索,后生成。当用户提问时,系统先去知识库中查找相关片段,将这些片段作为"上下文"连同问题一起喂给大模型,让模型基于事实回答问题。

今天,我们将通过一段关于"光光和东东"的温馨故事,利用 LangChain 框架和 MemoryVectorStore(内存向量库),构建一个零门槛的 RAG 应用。


一、核心概念拆解:RAG 是如何工作的?

在动手写代码之前,我们需要理解 RAG 流水线的三个关键步骤,这也对应了我们即将编写的代码逻辑:

  1. 索引(Indexing) :将非结构化数据(如我们的故事文本)切分,并通过 Embedding 模型转化为向量(Vector),存入向量数据库。

    • 比喻:把故事书撕成小纸条,给每张纸条打上独特的"数字指纹",然后归档到档案室。
  2. 检索(Retrieval) :将用户的问题也转化为向量,计算它与档案室中所有纸条指纹的相似度(通常用余弦相似度),找出最相关的 Top-K 个片段。

    • 比喻:用户问"他们擅长什么?",系统拿着这个问题的"指纹"去档案室比对,找出最匹配的三张纸条。
  3. 生成(Generation) :将检索到的片段组装成上下文(Context),构造 Prompt,发送给 LLM,让模型基于这些材料回答。

    • 比喻:把找到的三张纸条交给老师(LLM),说:"请根据这几张纸条的内容,回答用户的问题。"

二、环境准备与依赖安装

本教程基于 Node.js 环境,使用 LangChain JS 生态。你需要确保安装了 Node.js (建议 v18+)。

首先,初始化项目并安装核心依赖:

bash 复制代码
mkdir rag-test
cd rag-test
npm init -y
npm install @langchain/openai @langchain/core @langchain/classic dotenv

注:@langchain/classic 包含了经典的向量存储实现如 MemoryVectorStore,适合演示和轻量级应用。

接着,创建 .env 文件,配置你的 API 密钥(支持 OpenAI 或兼容接口如 Moonshot, DeepSeek 等):

ini 复制代码
OPENAI_API_KEY=sk-your-api-key
OPENAI_BASE_URL=https://api.openai.com/v1 # 或其他兼容地址
MODEL_NAME=gpt-4o-mini # 或你喜欢的模型
EMBEDDING_MODEL_NAME=text-embedding-3-small # 嵌入模型

三、实战编码:构建"友情故事"问答机器人

我们将创建一个 index.js 文件,完整复现 RAG 流程。

1. 初始化模型与嵌入组件

首先,我们需要引入必要的类,并实例化 LLM 和 Embeddings 模型。Embeddings 模型负责将文本转化为向量,是 RAG 的"眼睛"。

arduino 复制代码
import "dotenv/config";
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { Document } from "@langchain/core/documents";
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";

// 1. 初始化大语言模型
const model = new ChatOpenAI({
    modelName: process.env.MODEL_NAME,
    apiKey: process.env.OPENAI_API_KEY,
    configuration: {
        baseURL: process.env.OPENAI_BASE_URL
    },
    temperature: 0, // RAG 场景下,温度设为 0 以保证回答稳定性
});

// 2. 初始化嵌入模型 (用于向量化)
const embeddings = new OpenAIEmbeddings({
    apiKey: process.env.OPENAI_API_KEY,
    model: process.env.EMBEDDING_MODEL_NAME,
    configuration: {
        baseURL: process.env.OPENAI_BASE_URL
    },
});

2. 构建知识库:Document 与 Metadata

RAG 的强大之处在于不仅能检索内容,还能利用元数据(Metadata)进行过滤或增强。我们将"光光和东东"的故事拆分为 7 个片段,每个片段都带有丰富的元数据。

go 复制代码
// 3. 构建文档知识库
const documents = [
    new Document({
        pageContent: `光光是一个活泼开朗的小男孩,他有一双明亮的大眼睛,总是带着灿烂的笑容。光光最喜欢的事情就是和朋友们一起玩耍,他特别擅长踢足球,每次在球场上奔跑时,就像一道阳光一样充满活力。`,
        metadata: { chapter: 1, character: "光光", type: "角色介绍", mood: "活泼" },
    }),
    new Document({
        pageContent: `东东是光光最好的朋友,他是一个安静而聪明的男孩。东东喜欢读书和画画,他的画总是充满了想象力。虽然性格不同,但东东和光光从幼儿园就认识了,他们一起度过了无数个快乐的时光。`,
        metadata: { chapter: 2, character: "东东", type: "角色介绍", mood: "温馨" },
    }),
    // ... (此处省略中间章节,实际代码包含全部 7 个章节,涵盖相识、练习、比赛、结局及尾声)
    new Document({
        pageContent: `多年后,光光成为了一名职业足球运动员,而东东成为了一名优秀的插画师。虽然他们走上了不同的道路,但他们的友谊从未改变。东东为光光设计了球衣上的图案,光光在每场比赛后都会给东东打电话分享喜悦。他们证明了,真正的友情可以跨越时间和距离,永远闪闪发光。`,
        metadata: { chapter: 7, character: "光光和东东", type: "尾声", mood: "温馨" },
    }),
];

专家提示 :在实际生产中,文档切分(Chunking)策略至关重要。过长的片段会稀释关键信息,过短则可能丢失上下文。本例中我们手动切分,实际项目中可使用 RecursiveCharacterTextSplitter 自动处理。

3. 向量化与存储:MemoryVectorStore

接下来是魔法发生的时刻。我们将文档传入 MemoryVectorStore。这一步会自动调用 embeddings 接口,将所有 pageContent 转化为高维向量,并存储在内存中。

csharp 复制代码
// 4. 创建内存向量数据库
// fromDocuments 方法会自动遍历数组,对每个 document.pageContent 进行向量化并存储
const vectorStore = await MemoryVectorStore.fromDocuments(documents, embeddings);

MemoryVectorStore 非常适合开发测试、Demo 演示或小规模数据场景,因为它无需部署额外的数据库(如 Pinecone, Milvus),启动即用,进程结束数据即失。

4. 核心检索:Retriever 与相似度计算

有了向量库,我们需要一个"检索器"。LangChain 的 asRetriever 方法封装了复杂的向量搜索逻辑。

javascript 复制代码
// 5. 构建检索器 (Retriever)
// k: 3 表示每次检索返回最相关的 3 个文档片段
const retriever = vectorStore.asRetriever({ k: 3 });

const questions = ["光光和东东各自擅长什么?"];

for (const question of questions) {
    console.log("=".repeat(80));
    console.log(`问题:${question}`);
    console.log("=".repeat(80));
    
    // 6. 执行检索
    // 内部流程:Question -> Embedding -> Cosine Similarity Search -> Top K Docs
    const retrievedDocs = await retriever.invoke(question);
    
    // 7. 获取详细评分 (可选,用于调试)
    // similaritySearchWithScore 返回 [Document, score] 元组
    // 注意:LangChain 中某些实现的 score 是距离 (Distance),越小越相似;
    // 而余弦相似度 (Cosine Similarity) 越大越相似。
    // OpenAI Embeddings + MemoryVectorStore 默认返回的是欧氏距离或余弦距离,需根据具体实现转换。
    const scoreResults = await vectorStore.similaritySearchWithScore(question, 3);
    
    console.log("\n[检索到的文档及相似度分析]");
    retrievedDocs.forEach((doc, i) => {
        const scoreResult = scoreResults.find(
            ([scoredDoc]) => scoredDoc.pageContent === doc.pageContent
        );
        
        // 这里的 score 通常是距离 (Distance)。
        // 如果是余弦距离 (Cosine Distance),范围 0-2,0 表示完全相同。
        // 相似度 (Similarity) = 1 - Distance (针对归一化后的余弦距离)
        const distance = scoreResult ? scoreResult[1] : null;
        const similarity = distance !== null ? (1 - distance).toFixed(2) : "N/A";
        
        console.log(`\n文档 ${i+1} | 估算相似度:${similarity}`);
        console.log(`内容摘要:${doc.pageContent.substring(0, 50)}...`);
        console.log(`元数据:${JSON.stringify(doc.metadata)}`);
    });
    
    // ... 后续构建 Prompt 和调用模型
}

深度解析:相似度分数 很多新手会对 score 感到困惑。在向量数据库中,我们计算的是"距离"。

  • 余弦相似度 (Cosine Similarity) :范围 [-1, 1],1 表示完全相同。
  • 余弦距离 (Cosine Distance) :范围 [0, 2],0 表示完全相同。公式通常为 <math xmlns="http://www.w3.org/1998/Math/MathML"> 1 − Similarity 1 - \text{Similarity} </math>1−Similarity。 代码中 (1 - score).toFixed(2) 的目的就是将"距离"反转回人类直觉的"相似度",数值越接近 1,代表文档与问题越相关。

5. 提示词工程:组装 Context 与生成回答

检索只是手段,回答才是目的。我们需要将检索到的碎片信息拼凑成一段连贯的上下文,并设计一个优秀的 Prompt。

javascript 复制代码
    // 8. 构建上下文 (Context)
    const context = retrievedDocs
        .map((doc, i) => `[片段${i+1}]\n ${doc.pageContent}`)
        .join("\n\n----\n\n");

    // 9. 构造 Prompt (提示词工程)
    // 关键点:明确角色、限定依据、处理未知情况
    const prompt = `
    你是一个讲友情故事的老师,语气温暖生动,充满鼓励。
    
    请严格基于以下【故事片段】来回答问题。
    如果【故事片段】中没有提及相关信息,请诚实地回答:"这个故事里没有提到这个细节哦",不要编造内容。

    【故事片段】:
    ${context}

    【问题】:
    ${question}

    【老师的回答】:
    `;

    console.log("\n[AI 回答生成中...]");
    
    // 10. 调用模型生成最终回答
    const response = await model.invoke(prompt);
    console.log(response.content);
    console.log("\n");
}

Prompt 设计心法

  1. 角色设定讲友情故事的老师,定调了回答的风格(温暖、生动)。
  2. 边界约束严格基于以下故事片段,这是防止幻觉的关键指令。
  3. 兜底策略如果没有提及...,教会模型在知识盲区如何优雅地拒绝,而不是胡编乱造。

四、运行结果与分析

当我们运行 node index.js 并提出问题 "光光和东东各自擅长什么?" 时,系统内部发生了如下化学反应:

  1. 向量化:问题被转化为向量。
  2. 匹配:系统发现 Chapter 1(光光擅长足球)、Chapter 2(东东喜欢画画)、Chapter 7(职业结局)的向量与问题向量距离最近。
  3. 组装 :这三个片段被填入 Prompt 的 context 区域。
  4. 生成:LLM 阅读上下文后,输出了类似以下的回答:

"亲爱的孩子,在这个温暖的故事里,光光是一个像阳光一样充满活力的男孩,他特别擅长踢足球,甚至在多年后成为了一名职业足球运动员呢!

而他的好朋友东东,虽然性格安静,但他非常聪明,擅长画画和读书,他的画作充满了想象力,后来也成为一名优秀的插画师,连光光的球衣图案都是他设计的哦。

他们一个在球场奔跑,一个在画板前创作,互相支持,真是一对完美的搭档!"

结果分析

  • 准确性:模型准确提取了"足球"和"画画"这两个核心点,没有混淆。
  • 跨段落推理:模型结合了第一章的"擅长"和第七章的"职业",给出了更完整的回答,体现了 RAG 整合多片段信息的能力。
  • 风格一致:回答符合"温暖老师"的人设。

五、进阶思考:从 Demo 到生产

虽然本教程使用了 MemoryVectorStore 快速上手,但在真实的生产环境中,我们还需要考虑更多:

  1. 持久化存储 :内存向量库重启即失。生产环境应选用 Pinecone , Milvus , WeaviatePostgreSQL (pgvector) 。LangChain 对这些数据库都有完善的集成。
  2. 混合检索(Hybrid Search) :单纯的向量检索(语义匹配)有时不如关键词匹配(BM25)精准。最佳实践是结合两者,既懂语义又懂关键词。
  3. 重排序(Re-ranking) :检索出的 Top-50 个文档,可以用一个更强大的 Cross-Encoder 模型进行精细重排序,取 Top-5 给 LLM,能显著提升回答质量。
  4. 元数据过滤 :利用代码中的 metadata(如 chapter, character),可以在检索前先过滤数据。例如:"只查找'光光'相关的片段",这在大规模知识库中能大幅降低噪声。

结语

通过这不到 200 行的代码,我们成功构建了一个具备"记忆"和"推理"能力的 RAG 应用。从向量化文档,到语义检索,再到基于上下文的生成,RAG 的核心链路其实并不神秘。

"光光和东东"的故事告诉我们,真正的朋友是互相帮助、共同成长的。同样,大模型与知识库也是这样的关系:大模型提供强大的推理与语言能力,知识库提供精准的事实与数据,两者结合(RAG),才能创造出真正可靠、有价值的 AI 应用。

希望这篇教程能成为你 RAG 之旅的起点。快去尝试替换成你自己的文档,让 AI 读懂你的世界吧!

相关推荐
猿猿长成记2 小时前
AI专栏 | AI大法则之思维链、自洽性、思维树
人工智能
用户5191495848452 小时前
CrushFTP 条件竞争认证绕过漏洞利用工具 (CVE-2025-54309)
人工智能·aigc
一拳不是超人2 小时前
AI时代,35岁程序员焦虑终结:经验从负债变资产
人工智能·程序员
SimonKing2 小时前
觅得又一款轻量级数据库管理工具:GoNavi
java·后端·程序员
一次旅行2 小时前
npm-error code 128问题解决方法
node.js
IT_陈寒2 小时前
Vite快得离谱?揭秘它比Webpack快10倍的5个核心原理
前端·人工智能·后端
风象南3 小时前
OpenClaw 登顶 GitHub Star 榜首:一个程序员 13 年后的"重新点火"故事
人工智能·后端
TF男孩13 小时前
重新认识Markdown:它不仅是排版工具,更是写Prompt的最佳结构
人工智能
牛奶13 小时前
系统的弃子与倦怠的灵魂:在液态社会中审视“修正主义”与职场异化
程序员·团队管理·午夜话题