前端如何实现RAG?一文带你速通,使用RAG实现长期记忆

作为前端开发者,我们经常遇到一个痛点:AI 助手无法记住跨会话的历史记录 。本文将从前端的视角出发,深度解析 RAG(检索增强生成)的工作原理,并结合实际项目代码,详细剖析如何使用 LlamaIndex.js + DeepSeek + Qdrant 实现 AI 助手的永久记忆。

一、为什么我们需要 RAG?

当我调用 DeepSeek API 搭建 AI 对话平台时,很快发现一个致命缺陷:如果用户关闭网页再次进入,AI 会立刻"失忆"。解决这个问题,传统上有几种方案,但都有明显弊端:

方案 描述 弊端分析
1. 完整上下文传递 在系统提示词中加入全部历史对话。 高昂成本与限制:大量上下文极易超出 LLM 的 Token 上限,导致巨额消耗,且长文本会稀释 AI 的注意力。
2. 对话摘要记录 每次对话后,生成一个摘要并不断修改。 信息丢失与准确性:摘要有最大长度限制,且 AI 自动生成的摘要难以保证准确性,少量文字无法存储海量信息。
3. 模型微调(Fine-tuning) 使用对话记录对模型进行微调。 高门槛与高成本:对前端开发者而言,实现难度、资金投入和时间成本过高。
4. RAG 检索 每次对话时,检索与当前问题最相关的历史记录作为上下文。 优异的长期记忆方案:通过向量化实现语义匹配,精准高效地注入相关记忆。

结论: RAG 是目前最经济高效、可控性最高的长期记忆实现方案。


二、什么是 RAG(检索增强生成)?

RAG 的全称是 Retrieval-Augmented Generation 。它不是一个新的模型,而是一种架构框架 ,用于提升大型语言模型(LLM)回答的准确性、时效性知识深度

RAG 专门解决 LLM 的两大核心限制:知识时效性 (模型知识截止于训练数据)和上下文窗口限制(短期记忆)。

核心工作原理

RAG 的核心思想是:在 LLM 生成回复之前,先去外部的、特定的知识库中找到相关的"参考资料",然后将这些资料作为证据提供给 LLM。

RAG 流程分为两大阶段:

1. 索引阶段 (Indexing) --- 知识的存储与向量化

这个阶段是预先完成的,目标是将您的私有数据(例如本项目中的历史对话记录)转换为 LLM 可以快速查找的格式。

  • 分块 (Chunking) :将长的对话记录或文档切分成较小的、语义完整的片段。
  • 嵌入 (Embedding) :使用嵌入模型(如 DeepSeek Embeddings)将每个文本块转换成数值向量
  • 存储 (Storage) :将这些向量及其对应的原始文本存储在向量数据库(如 Qdrant)中。

2. 运行时阶段 (Runtime) --- 记忆的检索与生成

这个阶段在每次用户提问时实时发生

  • 问题嵌入:将用户当前的问题转换为查询向量。
  • 检索 :系统在向量数据库中进行相似性搜索 ,快速找到与查询向量最相似的 Top K 个历史对话记录。
  • 提示词增强 (Augmentation) :将用户原始问题与检索到的相关记录一起打包,形成一个 "增强的提示词"(Augmented Prompt)
  • 生成 (Generation) :将增强提示词发送给 DeepSeek Chat API,模型根据上下文生成最终答案。

三、如何实现 RAG?(项目实战流程)

项目github连接------github.com/Objecteee/a...

我们可以看到发送给ai的提示词已经通过RAG检索到了相似的对话记录,我整理了一下格式如下------

swift 复制代码
{

  "dataType": "ChatHistoryContext",

  "sourceJsonType": "debug",

  "systemInstruction": "你是一个智能助手。请基于以下提供的背景信息(Context)来回答用户的问题。这些背景信息可能包含相关的知识文档或过去的历史对话记录。如果背景信息与问题无关,请忽略它。",

  "contextType": "历史对话记录 (Historical Conversation Records)",

  "parsedConversations": [

    {

      "id": 1,

      "role": "User",

      "content": "我们刚刚在玩什么游戏"

    },

    {

      "id": 1,

      "role": "Assistant",

      "content": "根据我们的对话记录,我们并没有在玩具体的游戏哦~ \n你之前连续发送了几次"你好"和"1",我都在热情回应你的招呼呢!😊 \n\n如果你想玩点什么,我可以陪你玩猜谜、成语接龙、脑筋急转弯,或者你提议其他小游戏也可以~ 来试试吗?"

    },

    {

      "id": 2,

      "role": "User",

      "content": "你好,我们玩过互相说1的游戏么"

    },

    {

      "id": 2,

      "role": "Assistant",

      "content": "是的,我们之前玩过这个游戏!😊 \n你让我在你输入"1"时只回复"1",不加其他内容。 \n需要再玩一次吗?"

    },

    {

      "id": 3,

      "role": "User",

      "content": "你好啊"

    },

    {

      "id": 3,

      "role": "Assistant",

      "content": "你好啊!😊 今天看起来特别有活力呢~ \n有什么想聊的,或者需要我帮忙的吗?"

    },

    {

      "id": 4,

      "role": "User",

      "content": "你好"

    },

    {

      "id": 4,

      "role": "Assistant",

      "content": "你好!😊 很高兴见到你!有什么我可以帮助你的吗?"

    },

    {

      "id": 5,

      "role": "User",

      "content": "你好"

    },

    {

      "id": 5,

      "role": "Assistant",

      "content": "你好!😊 很高兴见到你!有什么我可以帮助你的吗?"

    }

  ],

  "summary": "这段数据是 RAG 系统用于增强 LLM 上下文的调试信息。它包含了 AI 的系统指令和五组结构化的用户/助手历史对话记录。"

}

要构建 RAG 系统,无论技术栈如何,都必须遵循以下三个核心阶段。我们将结合项目源码(rag/LlamaIndex)来具体分析实现细节。

项目概览:这套 RAG 在做什么

这套系统的目标不是"把最近 N 条对话塞给模型",而是:

  • 长期记忆 :把每一轮对话(User+Assistant)写入向量库,之后通过语义检索召回相关片段作为上下文。
  • 短期记忆 :从 SQLite 取最近 5 条(用于"继续""上面那个"这类指代保持连贯)。
  • 生成 :将"系统指令 + RAG 召回上下文"组成 systemPrompt,再加上短期历史与当前问题,调用 DeepSeek Chat 生成回答。

关键入口在:

  • 后端 RAG 服务:rag/LlamaIndex/backend/services/ragService.js
  • 后端对话接口:rag/LlamaIndex/backend/server.js
  • 前端调试输出:rag/LlamaIndex/frontend/src/App.jsx

RAG 实现剖析:第一阶段(知识库构建)

1. 数据摄取(Loaders)

目前的知识库来源是 backend/data/ 目录下的文件,启动时加载:

csharp 复制代码
const documents = await new SimpleDirectoryReader().loadData({
  directoryPath: path.join(__dirname, "../data"),
});

位置:rag/LlamaIndex/backend/services/ragService.js:56-59

这意味着:只要往 backend/data/ 放入新文档(如 .md),重建索引或首次初始化时就会被纳入向量库。


2. 切片逻辑(Chunking)------当前实现与重要说明

当前实现是什么?

当前没有显式写切片器 (Splitter),而是依赖 LlamaIndex 在 VectorStoreIndex.fromDocuments(...) 内部的默认切片策略(通常是句子/段落优先的 splitter + 默认 chunkSize/overlap)。

真实触发点:

ini 复制代码
index = await VectorStoreIndex.fromDocuments(documents, {
  storageContext,
});

位置:rag/LlamaIndex/backend/services/ragService.js:105-108

这意味着什么?
  • 切片确实发生了,但参数目前由 LlamaIndex 默认值决定。

  • 现在系统的"切片质量"主要取决于:

    • 文档本身的结构(是否有清晰标题/段落)
    • LlamaIndex 默认 splitter 策略
切片优化方案(建议按阶段演进)
  • 结构化切片 (推荐优先级最高):按标题(如 Markdown 的 #/##)、段落分隔符、代码块边界切,减少"跨主题混片"。
  • Overlap 增大:如果经常出现"关键句刚好切断",增加 overlap 可以减少信息断裂。
  • 语义切片:对长文档先做主题边界识别,再切 chunk,减少噪声召回。
  • 专门为对话记忆设计切片 :现在是一轮对话写成一个 Document(见长期记忆部分),这类数据天然短而密,通常不需要再细切;但如果一轮回答很长,可考虑把一轮对话拆为多段并共享 metadata(如同一 conversationId)。

3. 向量化(Embedding)与存储(Vector Store)

向量化模型(Embedding Model)

这里不是 DeepSeek Embedding API,而是本地 HuggingFace Embedding:

arduino 复制代码
Settings.embedModel = new HuggingFaceEmbedding({
  modelType: "Xenova/all-MiniLM-L6-v2",
  quantized: false
});

位置:rag/LlamaIndex/backend/services/ragService.js:40-43

同时为网络环境设置了镜像(用于模型下载):

ini 复制代码
env.remoteHost = "https://hf-mirror.com";
env.remoteTemplate = "{model}/resolve/{revision}/{file}";

位置:rag/LlamaIndex/backend/services/ragService.js:7-8

存储模式:Local / Qdrant

通过环境变量切换:

  • VECTOR_STORE_TYPE=local:落在本地 JSON
  • VECTOR_STORE_TYPE=qdrant:落在 Qdrant(失败会 fallback 到 local)

逻辑位置:rag/LlamaIndex/backend/services/ragService.js:63-81

Local 模式下的持久化目录是:

  • rag/LlamaIndex/backend/storage/
如何查看"向量化存储内容"

在 Local 模式下,最直接可读的文件是:

  • backend/storage/doc_store.json:原始文本(切片/对话记忆)内容
  • backend/storage/index_store.json:索引结构信息
  • backend/storage/vector_store_default.json(或类似):向量本体(大量数值,不太适合人读,但可以确认存在与规模)

写入持久化发生在长期记忆写入时(见后文),位置:rag/LlamaIndex/backend/services/ragService.js:159-190


RAG 实现剖析:第二阶段(运行时检索)

当用户每次提问时,会执行一次向量召回。

1. 召回逻辑:TopK 相似检索

ini 复制代码
const retriever = index.asRetriever({ similarityTopK: 5 });
const nodes = await retriever.retrieve(queryText);
return nodes.map(node => node.node.text).join("\n\n");

位置:rag/LlamaIndex/backend/services/ragService.js:135-141

这里的关键点:

  • TopK=5:每次最多取 5 条"最相近片段"
  • 返回的是纯文本拼接,作为后续 System Prompt 的"背景信息"

2. 召回的数据来源:知识库 + 历史对话

因为把"历史对话"也写进了同一个向量索引(见长期记忆写入),所以检索结果可能混合:

  • backend/data/ 里的知识文档切片
  • 用户历史对话(User:...\nAssistant:...

这也是为什么你在浏览器 Console 看到的 systemPrompt 背景信息里会出现很多轮对话文本。


RAG 实现剖析:第三阶段(生成回复)

1. 增强提示词(System Prompt)的构造规则

非流式接口 /api/chat 的 System Prompt 组装:

ini 复制代码
let systemPrompt = "你是一个智能助手。";
if (context) {
  systemPrompt += `请基于以下提供的背景信息(Context)来回答用户的问题。这些背景信息可能包含相关的知识文档或过去的历史对话记录。如果背景信息与问题无关,请忽略它。\n背景信息:\n${context}`;
}
const systemMessage = { role: 'system', content: systemPrompt };

位置:rag/LlamaIndex/backend/server.js:100-112

组成结论(非常关键)

  • systemPrompt = "基础系统指令" + "RAG 检索出来的 context(可为空)"
  • systemPrompt 不包含 SQLite 的最近 5 条对话,它们是作为单独 message 插入的(下一段)

2. 最终发送给模型的消息列表组成

ini 复制代码
const dbHistory = chatHistoryService.getHistory(sessionId, 5);

const finalMessages = [
  systemMessage,
  ...dbHistory,
  { role: 'user', content: lastMsg.content }
];

位置:rag/LlamaIndex/backend/server.js:90-118

所以项目中"喂给模型的上下文"来自两条管线:

  • 长期记忆(RAG) :进 systemPrompt
  • 短期记忆(SQLite 最近 5 条) :以 messages 数组形式送入模型(更接近"对话上下文")

3. 将系统提示词暴露给浏览器 Console

非流式:直接回传给前端
php 复制代码
res.json({
  role: 'assistant',
  content: responseContent,
  systemPrompt: systemPrompt
});

位置:rag/LlamaIndex/backend/server.js:136-142

前端打印:

javascript 复制代码
console.group("🔍 [DeepSeek Chat] RAG Debug Info");
console.log("User Prompt:", ...);
console.log("System Prompt (with RAG Context):", response.data.systemPrompt);
console.groupEnd();

位置:rag/LlamaIndex/frontend/src/App.jsx:60-85

流式:在 SSE 开头先发一条 debug 包
typescript 复制代码
res.write(`data: ${JSON.stringify({ type: 'debug', systemPrompt })}\n\n`);

位置:rag/LlamaIndex/backend/server.js:198-213

前端解析:

ini 复制代码
if (parsed.type === 'debug' && parsed.systemPrompt) {
  console.group("🔍 [DeepSeek Chat Stream] RAG Debug Info");
  console.log("User Prompt:", ...);
  console.log("System Prompt (with RAG Context):", parsed.systemPrompt);
  console.groupEnd();
  continue;
}

位置:rag/LlamaIndex/frontend/src/App.jsx:127-137

这实现了要的:在浏览器控制台完整观察"系统提示词 + 用户提示词"。


长期记忆(Long-term Memory)在您项目里是怎么实现的?

1. 写入规则:每轮对话结束就写入向量库

/api/chat 拿到模型回答后,会调用:

csharp 复制代码
await ragService.insertChatLog(lastMsg.content, responseContent);

位置:rag/LlamaIndex/backend/server.js:133-135

2. 写入内容格式:User + Assistant 合并成一个 Document

ini 复制代码
const text = `User: ${userMsg}\nAssistant: ${assistantMsg}`;
const doc = new Document({ text, metadata: { type: 'conversation', timestamp: ... }});
await index.insert(doc);

位置:rag/LlamaIndex/backend/services/ragService.js:148-158

3. 持久化规则:Local 模式写到 storage/*.json

插入后会持久化(Local):

scss 复制代码
await storageContext.docStore.persist(...doc_store.json)
await storageContext.indexStore.persist(...index_store.json)
for (...) await store.persist(...vector_store_${key}.json)

位置:rag/LlamaIndex/backend/services/ragService.js:159-190


与"标准三阶段 RAG"相比:项目有哪些关键差异?

  • Embedding 不是 DeepSeek Embedding API :是本地 HuggingFaceEmbedding(更稳定、成本低,但模型选择需要自己评估准确率),见 ragService.js:40-43
  • Prompt 不仅有 RAG Context :额外注入了 SQLite 最近 5 条短期历史(作为 messages),见 server.js:90-118
  • 长期记忆不是"拉历史记录 N 条" :是把对话写入向量库,后续靠相似检索召回,见 insertChatLogretrieveContext
  • 可观测性加强 :系统提示词会同时在后端终端打印(server.js:105-109/183-187)并在浏览器 Console 打印(App.jsx:70-77/129-137)。

总结

OK,就写到这儿了。

相关推荐
Luna-player2 小时前
在前端中,<a> 标签的 href=“javascript:;“ 这个是什么意思
开发语言·前端·javascript
lionliu05192 小时前
js的扩展运算符的理解
前端·javascript·vue.js
小草cys3 小时前
项目7-七彩天气app任务7.4.2“关于”弹窗
开发语言·前端·javascript
奇舞精选3 小时前
GELab-Zero 技术解析:当豆包联手中兴,开源界如何守住端侧 AI 的“最后防线”?
前端·aigc
奇舞精选3 小时前
Vercel AI SDK:构建现代 Web AI 应用指南
前端·aigc
神仙别闹3 小时前
基于C语言实现B树存储的图书管理系统
c语言·前端·b树
玄魂4 小时前
如何查看、生成 github 开源项目star 图表
前端·开源·echarts
前端一小卒4 小时前
一个看似“送分”的需求为何翻车?——前端状态机实战指南
前端·javascript·面试
syt_10134 小时前
Object.defineProperty和Proxy实现拦截的区别
开发语言·前端·javascript