基于向量检索与本地LLM的AI小说编辑器:实现长上下文记忆的工程实践
1. 项目缘起:当AI写作遇上“健忘症”
作为一个常年混迹于各种写作工具和代码编辑器之间的老鸟,我最近被一个痛点折磨得够呛:市面上那些所谓的AI辅助写作工具,聪明是聪明,但它们的“记性”实在太差了。你写了几千字的小说,想让AI帮你润色一下第三章里某个角色的对话,让它符合第一章里埋下的性格伏笔。结果呢?你不得不把前面几章的内容一股脑儿复制粘贴进提示词里,或者指望AI能自己从上下文中“猜”出来。这就像你请了个才华横溢但患有严重短期失忆的编辑,每改一句话,你都得把整本书的梗概再给他讲一遍,效率低得令人抓狂。
这正是我动手折腾这个项目的核心驱动力。我想要一个真正能“记住前文”的AI小说编辑器,不是那种简单粗暴的聊天记录式记忆,而是能理解故事脉络、角色关系、世界观设定的深度记忆。它应该像一个真正的写作伙伴(WorkBuddy),静静地待在编辑器侧边栏,通读你的整个文稿,在你需要的时候,基于完整的上下文给出精准的建议、续写或修改。
我选择围绕“WorkBuddy”这个概念来构建,是因为它精准地描述了我想要的协作关系——不是一个高高在上的“AI大师”,而是一个踏实、可靠、懂你项目的伙伴。这个伙伴需要深度集成到写作环境中,拥有持续学习项目上下文的能力。经过一番调研和折腾,我最终用Node.js生态里的一些强力工具,把这个想法变成了现实。下面,我就把这套方案的思路、踩过的坑和最终实现细节,毫无保留地分享出来。
2. 核心设计:如何让AI拥有“长时记忆”
实现一个能记住前文的编辑器,听起来像是要造一个通用人工智能,但其实我们可以把问题拆解成几个工程上可实现的模块。关键在于,我们不需要AI“理解”一切,而是需要一套机制,能高效地存储、检索和注入相关的上下文信息给AI模型。
2.1 架构总览:从文档到提示词的智能管道
整个系统的核心工作流可以概括为:监听文档变化 -> 智能切片与向量化 -> 存储到向量数据库 -> 用户提问时进行相关性检索 -> 构建包含上下文的提示词 -> 调用AI模型获取结果。
这听起来是一长串步骤,但每一个环节都有成熟的开源方案可供选择。我的设计目标是轻量、快速、可离线(至少核心流程可离线),毕竟写作是个需要专注的过程,网络延迟和API费用都是干扰项。
为什么选择向量检索这条路?早期我尝试过最简单的方法:每次都把整个文档作为上下文传给AI。这对于短篇尚可,一旦字数上万,不仅会急剧增加API调用成本(按Token计费),还可能触及模型本身的上下文长度限制(比如GPT-4 Turbo的128K听起来很长,但装下一部百万字的小说也够呛)。更糟糕的是,过长的无关上下文会干扰AI的判断,导致其输出偏离当前焦点,这就是所谓的“中间丢失”现象。
向量检索的核心思想是“按需索取”。我们将文档切分成一个个有意义的片段(比如按段落或场景),把这些片段转换成数学向量(这个过程叫“嵌入”),存储起来。当用户针对某个位置提问时,系统将问题也转换成向量,然后在向量数据库里快速找出与问题向量最相似的几个文档片段。最后,只把这些最相关的片段,连同当前正在编辑的段落,一起送给AI。这极大地提高了效率、降低了成本,并提升了回答的相关性。
2.2 技术栈选型:为什么是它们?
选型直接决定了项目的可行性和开发体验。下面是我权衡后的选择:
运行时:Node.js这是整个项目的基础。选择Node.js,首先是因为我需要一个能快速构建跨平台桌面应用的环境。其次,整个AI处理流程涉及大量的异步操作(文件I/O、网络请求、计算密集型嵌入),Node.js的事件驱动、非阻塞I/O模型非常适合。最后,其庞大的npm生态几乎提供了我所需的一切工具,从向量数据库客户端到各种AI模型的SDK。
编辑器核心:CodeMirror 或 Monaco Editor这是一个关键选择。我需要一个功能强大、可扩展性极强的代码编辑器组件来作为文字处理的核心。
CodeMirror轻量、高度可定制,是许多现代编辑器的基石(如VSCode的早期版本)。Monaco Editor则是VSCode编辑器的直接开源版本,功能极其强大,开箱即用,但体积也更大。对于小说编辑器,CodeMirror通常足够,且更容易集成和定制样式。我最终选择了CodeMirror,因为它能让我从更底层控制编辑器的行为,比如实现语法高亮(针对小说,可以高亮角色名、地点等)、自定义侧边栏等。向量数据库:LanceDB 或 Chroma向量数据库是“记忆”的仓库。我需要一个能够快速进行相似性搜索的数据库。
Chroma是一个流行的开源向量数据库,易于使用,有很好的JavaScript/TypeScript支持。LanceDB是另一个新兴选择,它基于Apache Arrow列式内存格式,性能非常出色,尤其适合嵌入到桌面应用中,因为它可以以单文件形式存在,无需运行单独的数据库服务。考虑到项目的离线友好性和部署简便性,我选择了LanceDB。它就像一个本地的、专门为向量优化过的“智能笔记本”。嵌入模型:本地 vs. 云端这是另一个需要权衡的点。嵌入模型负责将文本转换成向量。
- 云端API(如OpenAI的
text-embedding-3-small):优点是质量高、稳定、省心。缺点是会产生持续的费用,且必须联网。 - 本地模型(如通过
TensorFlow.js或Transformers.js运行的all-MiniLM-L6-v2):优点是完全离线、零成本、隐私性好。缺点是需要一定的客户端算力(但现代电脑完全能胜任),且模型体积和初始化需要时间。 为了追求极致的离线体验和隐私保护,我决定挑战一下本地嵌入。我选择了Transformers.js库,它可以直接在浏览器或Node.js环境中运行经过优化的Hugging Face模型。all-MiniLM-L6-v2是一个在通用语义相似度任务上表现很好的轻量级模型,生成的向量维度是384,在精度和速度之间取得了很好的平衡。
- 云端API(如OpenAI的
大语言模型(LLM)接口:OpenAI API 兼容层对于最终生成文本的LLM,我选择了兼容OpenAI API的方案。这给了我最大的灵活性:在开发调试时,我可以使用OpenAI的GPT系列(需要联网);而在追求离线时,我可以无缝切换到本地部署的、同样提供OpenAI兼容API的开源模型,如
Ollama管理的Llama 3、Qwen或DeepSeek等。通过一个统一的API客户端(比如openainpm包),我只需要切换baseURL和apiKey,就能在不同模型间切换,代码无需改动。
注意:本地运行LLM对硬件有一定要求(主要是内存和显存)。对于小说创作这种需要较长上下文和一定逻辑性的任务,建议至少准备16GB内存,并考虑使用量化后的模型(如4-bit或5-bit量化)来降低资源消耗。
Ollama极大地简化了本地模型的下载和管理,是入门首选。
2.3 整体工作流设计
确定了技术栈,整个应用的工作流就清晰了:
- 初始化与加载:用户打开一个小说文档(或新建)。编辑器组件加载文本。
- 后台索引:系统在后台自动将整个文档进行分块(例如,按“## 章节标题”或空行分割),调用本地嵌入模型为每一块文本生成向量,并存入本地的LanceDB表中。这个过程在首次打开文件或文件有重大修改后触发。
- 交互与检索:用户在编辑器中选中一段文字,或光标停留在某处,然后通过侧边栏的WorkBuddy面板输入问题(如:“帮我把这段对话改得更紧张些”或“根据前文,这个角色此时应该是什么心情?”)。
- 智能上下文构建:系统将用户的问题和光标附近的文本合并,生成一个查询向量。用这个向量在LanceDB中进行相似性搜索,找出前文中最相关的3-5个文本块。
- 提示词工程:系统组装一个结构化的提示词(Prompt):
你是一个专业的小说编辑助手。请根据以下提供的小说上下文,回答用户的问题或完成指令。 【相关前文上下文】 (这里插入从向量数据库检索到的相关文本块) 【当前编辑段落】 (这里插入用户选中或光标所在的段落) 【用户指令】 (用户输入的问题或要求) 请基于以上信息进行回应。 - 调用与呈现:将组装好的提示词发送给配置好的LLM(本地或云端)。将返回的流式结果实时显示在WorkBuddy面板中,用户可以一键采纳或修改后插入编辑器。
这个设计使得AI的每次回应都牢牢地扎根于你已创作的故事土壤中,避免了天马行空的偏离。
3. 关键实现细节与踩坑实录
把设计图变成代码,每一步都有需要注意的细节。这里我分享几个最关键的实现环节和遇到的典型问题。
3.1 文档分块(Chunking)的艺术
分块是向量检索效果的基础。分得太细(比如每句话一块),会丢失上下文信息;分得太大(比如整章一块),检索精度会下降,且依然可能包含无关信息。
我采用的策略是“重叠式语义分块”:
- 首先,按明显的结构分割,如“## 第一章”这样的Markdown标题或连续的两个换行符。这保证了基本的场景或章节完整性。
- 对于每个大块,如果长度超过预设值(例如400个字符),再按句子边界(。!?等)进行进一步分割。
- 关键技巧:重叠。在分割时,让相邻的两个块有少量重叠(比如50-100个字符)。这能有效防止一个完整的语义单元(如一段重要的描述性对话)被硬生生割裂在两个块边界,导致检索时只找到一半,上下文丢失。
// 一个简化的分块函数示例 function chunkText(text, chunkSize = 400, overlap = 50) { const chunks = []; // 首先按双换行符分大段 const segments = text.split(/\n\s*\n/); for (const segment of segments) { if (segment.length <= chunkSize) { chunks.push(segment); } else { // 按句子分割 const sentences = segment.split(/(?<=[。!?])/); let currentChunk = ''; for (let i = 0; i < sentences.length; i++) { // 如果加上下一句就超长,且当前块不为空,则保存当前块 if ((currentChunk + sentences[i]).length > chunkSize && currentChunk) { chunks.push(currentChunk); // 重叠处理:回溯一部分句子作为下一个块的开始 currentChunk = currentChunk.slice(-overlap) + sentences[i]; } else { currentChunk += sentences[i]; } } if (currentChunk) chunks.push(currentChunk); } } return chunks; }踩坑一:标点符号与语言模型。最初我用的句子分割正则表达式比较简单,对中文标点支持不好,导致很多段落没有被正确分割。后来改用了更健壮的分词库(如nodejieba)辅助判断句子边界,但考虑到轻量性,最终优化了正则表达式/(?<=[。!?\.\?!])/来兼顾中英文。
3.2 本地嵌入模型集成
在浏览器或Node.js中运行Transformer模型,听起来很复杂,但Transformers.js让它变得简单。主要步骤是:
- 安装与引入:
npm install @xenova/transformers - 加载模型:指定模型名称,库会自动从Hugging Face Hub下载并缓存模型文件。
- 生成嵌入:将文本块传递给模型,得到浮点数数组(向量)。
import { pipeline } from '@xenova/transformers'; // 注意:首次运行会下载模型,需要一定时间 const extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2'); async function generateEmbedding(text) { const output = await extractor(text, { pooling: 'mean', normalize: true }); // output.data 是一个Float32Array,即我们的向量 return Array.from(output.data); }踩坑二:模型加载与性能。模型文件有几十MB,首次加载需要时间。在桌面应用中,我选择在应用启动时异步加载模型,并显示加载进度。另外,嵌入计算是CPU密集型任务,对于超长文档,索引过程可能会暂时阻塞UI。解决方案是将索引任务放入Web Worker(浏览器环境)或worker_threads(Node.js环境)中,避免界面卡顿。实测下来,在M1 Mac上处理一个10万字的小说,生成所有向量大概需要2-3分钟,后续增量更新就很快了。
3.3 向量数据库LanceDB的集成
LanceDB的使用非常直观。在Node.js环境中,可以将其视为一个本地的、基于文件的数据库。
const lancedb = require('@lancedb/lancedb'); const { connect } = lancedb; async function setupVectorDB(dbPath) { const db = await connect(dbPath); let table; try { table = await db.openTable('novel_chunks'); console.log('已存在表,直接打开'); } catch { // 表不存在,创建它 const schema = { id: new lancedb.Field('id', new lancedb.Int32()), text: new lancedb.Field('text', new lancedb.Utf8()), vector: new lancedb.Field('vector', new lancedb.FixedSizeList(384, new lancedb.Float32())), // 384维向量 startPos: new lancedb.Field('startPos', new lancedb.Int32()), // 在原文中的起始位置,用于快速定位 }; table = await db.createTable('novel_chunks', schema); console.log('创建新表'); } return { db, table }; } // 插入数据 async function addChunkToTable(table, chunk, embedding, startPos) { const data = { id: Date.now(), // 简单生成ID text: chunk, vector: embedding, startPos: startPos }; await table.add([data]); } // 搜索 async function searchSimilarChunks(table, queryEmbedding, limit = 5) { const results = await table .search(queryEmbedding) .limit(limit) .execute(); return results.map(r => ({ text: r.text, score: r._distance, startPos: r.startPos })); }踩坑三:向量维度对齐。嵌入模型all-MiniLM-L6-v2输出384维向量,在定义LanceDB表结构时,FixedSizeList的大小必须严格指定为384。如果用了其他维度的模型(如text-embedding-3-small是1536维),这里必须同步修改,否则会报错。
3.4 编辑器与WorkBuddy面板的交互
这是用户体验的核心。我使用CodeMirror作为编辑器,并在其DOM容器旁边创建一个绝对定位的侧边栏div作为WorkBuddy面板。
关键交互逻辑:
- 上下文感知:监听编辑器的光标位置(
cursorActivity事件)和选中文本变化。当用户没有明确选中文本时,默认以光标所在段落(通过查找最近的换行符确定边界)作为“当前编辑段落”。 - 流式输出:调用LLM API时,使用流式响应(
stream: true)。这样,AI的回复可以像打字一样逐字显示在面板中,体验更佳。对于本地Ollama,同样支持流式输出。 - 一键应用:在WorkBuddy面板的AI回复区域,提供一个“插入到光标处”或“替换选中文本”的按钮。点击后,将AI生成的内容插入编辑器相应位置。
// 伪代码,展示思路 const editor = new CodeMirror(document.getElementById('editor'), { /* 配置 */ }); const workbuddyPanel = document.getElementById('workbuddy-panel'); const askButton = document.getElementById('ask-button'); const questionInput = document.getElementById('question-input'); askButton.addEventListener('click', async () => { const question = questionInput.value; const cursor = editor.getCursor(); const currentLine = editor.getLine(cursor.line); // 更智能地获取当前段落(向前向后查找空行) const currentParagraph = getParagraphAroundCursor(editor, cursor); // 1. 为 (问题 + 当前段落) 生成查询向量 const queryVector = await generateEmbedding(question + '\n' + currentParagraph); // 2. 从向量数据库搜索 const relevantChunks = await searchSimilarChunks(table, queryVector); // 3. 构建提示词 const prompt = buildPrompt(relevantChunks, currentParagraph, question); // 4. 调用LLM(流式) const response = await fetch('/api/chat', { // 本地或远程代理接口 method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: [{ role: 'user', content: prompt }] }), }); const reader = response.body.getReader(); const decoder = new TextDecoder(); let fullReply = ''; workbuddyPanel.innerHTML = ''; // 清空面板 while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value); // 假设chunk是SSE格式或简单文本 fullReply += chunk; workbuddyPanel.innerText = fullReply; // 实时更新显示 } // 5. 显示“应用”按钮 showApplyButton(fullReply, editor); });4. 部署、优化与扩展思考
一个可用的原型出来后,下一步是让它更健壮、更实用。
4.1 本地化部署与打包
为了让其他写作者也能用上,我需要把它打包成一个真正的桌面应用。Electron或Tauri是自然的选择。
- Electron:更成熟,生态丰富,但打包体积较大。
- Tauri:使用Rust编写核心,打包体积极小,性能更好,安全性更高,是当前的新兴热门选择。
我选择了Tauri,因为它能更好地与我的Rust后端(如果需要)集成,并且最终生成的安装包可以小到几MB(加上本地模型文件除外)。Tauri应用的前端部分就是一个Web应用(我的编辑器界面),后端Rust逻辑可以处理更复杂的文件操作或本地模型调度。
打包注意事项:需要将Transformers.js的模型文件(.bin和.json等)一并打包进应用资源,或者提供首次启动时下载的机制。LanceDB的数据库文件则直接存储在用户的应用数据目录下。
4.2 性能优化点
- 增量更新索引:每次用户保存文档时,全量重新索引是低效的。可以实现一个简单的差异分析:比较新旧文档,只对新增或修改的段落进行重新嵌入和更新数据库操作,删除已移除段落对应的向量。
- 嵌入模型缓存:对已嵌入的文本块进行哈希(如MD5),将哈希值和向量一起存储。下次遇到相同文本时,直接使用缓存,避免重复计算。
- 向量索引加速:LanceDB内部支持多种索引(如IVF_PQ),对于非常大的文档库(比如系列小说全集),创建索引可以大幅提升检索速度。可以在后台空闲时或文档数量达到阈值后自动创建索引。
- LLM上下文管理:即使经过检索,有时相关的上下文块加起来还是可能很长。需要设计一个“精炼”层,当总Token数接近模型上限时,自动对检索到的上下文进行摘要或进一步筛选,确保提示词不会超长。
4.3 功能扩展方向
这个基础框架的潜力很大:
- 角色知识库:单独维护一个“角色设定”的向量库。当AI处理涉及特定角色的内容时,同时检索小说正文和角色设定库,使AI对角色的把握更精准。
- 风格模仿:让用户提供几段范文,提取其风格特征(通过嵌入或专门的分析),在生成时要求AI模仿此风格。
- 情节一致性检查:定期自动扫描全文,让AI基于向量检索找出可能存在矛盾的时间线、人物特征或事件描述,并给出提示。
- 多模态扩展:结合本地图像生成模型(如Stable Diffusion),根据小说片段自动生成场景概念图或角色立绘,为创作提供视觉灵感。
5. 常见问题与排查指南
在实际开发和试用中,我遇到了一些具有代表性的问题,这里整理出来供参考。
| 问题现象 | 可能原因 | 排查与解决思路 |
|---|---|---|
| 向量检索结果完全不相关 | 1. 嵌入模型未正确加载或运行。 2. 文本分块不合理,破坏了语义。 3. 向量数据库搜索函数使用错误。 | 1. 检查嵌入模型pipeline是否成功创建,尝试对一句简单文本生成嵌入,看输出是否为固定长度的浮点数数组。 2. 检查分块函数,打印出前几个块的内容,看是否符合预期。调整分块大小和重叠度。 3. 确认 search函数调用正确,查询向量维度与数据库存储维度一致。 |
| AI回复似乎未使用前文 | 1. 检索到的相关片段未正确拼接到提示词中。 2. 提示词(Prompt)模板设计不佳,AI忽略了上下文。 | 1. 在发送请求前,将组装好的完整提示词打印到控制台,检查【相关前文上下文】部分是否包含有效内容。 2. 强化提示词指令。例如,在开头明确强调“你必须严格依据以下上下文回答,如果上下文未提供相关信息,请直接说明无法回答”。可以尝试在上下文中加入明显的标记,如 === 上下文开始 ===。 |
| 应用运行缓慢,界面卡顿 | 1. 在主线程进行大量同步计算(如嵌入生成)。 2. 向量数据库操作阻塞了事件循环。 3. 文档过大,索引耗时过长。 | 1.必须将嵌入计算、数据库索引等重型任务放入Web Worker或独立进程。 2. 确保所有数据库操作(尤其是初始全量插入)是异步的。 3. 实现进度提示,并考虑将全量索引改为在后台空闲时进行。对于超长文档,可以提示用户先索引部分章节。 |
| 本地LLM(Ollama)回复质量差 | 1. 模型选择不当,能力不足。 2. 提示词未针对本地模型优化。 3. 上下文长度超出模型处理能力。 | 1. 尝试更强大的模型,如llama3:8b、qwen2:7b等。7B参数以上的模型在理解长指令和复杂上下文上表现更好。2. 本地模型可能对指令的遵循能力不如GPT-4。需要编写更直接、更结构化的提示词,避免过于复杂或含蓄的表述。 3. 检查并严格控制送入模型的总Token数。使用 transformers的Tokenizer预先计算Token数量。 |
| LanceDB表无法打开或写入失败 | 1. 数据库文件被占用(如另一个进程正在使用)。 2. 表结构(Schema)与现有数据不匹配。 3. 文件路径权限问题。 | 1. 确保应用是单实例运行,或实现了正确的数据库连接池/锁机制。 2. 如果修改了表结构(如向量维度),可能需要删除旧的数据库文件重新创建。 3. 检查应用是否有对目标目录的读写权限。在打包应用中,应使用 app.getPath('userData')这类API来获取合适的可写目录。 |
最后一点心得:这个项目的核心价值不在于用了多炫酷的模型,而在于通过工程化的思路,将“记忆”这个抽象需求,拆解成了可落地的数据流水线。它证明了,即使不依赖庞大的云端服务和复杂的算法,利用开源工具和清晰的架构,我们也能在本地打造出智能、实用的创作工具。最大的成就感,来自于当它真正“读懂”了你前文埋下的伏笔,并给出一个严丝合缝的续写建议时,那种与机器协同创作的奇妙感觉。
