作为前端开发者,我们经常遇到一个痛点: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:落在本地 JSONVECTOR_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 条" :是把对话写入向量库,后续靠相似检索召回,见
insertChatLog与retrieveContext。 - 可观测性加强 :系统提示词会同时在后端终端打印(
server.js:105-109/183-187)并在浏览器 Console 打印(App.jsx:70-77/129-137)。
总结
OK,就写到这儿了。