不用向量数据库也能实现AI长期记忆,SQLite+FTS5轻量方案

发布时间:2026/9/1 11:50:26
不用向量数据库也能实现AI长期记忆,SQLite+FTS5轻量方案 最近在做一个 AI 助手类的项目最让我头疼的从来不是模型本身不够聪明而是模型“不记事”。用户上一轮刚说过“我家狗叫旺财”下一轮再问“我的狗叫什么”它就开始一本正经地胡说八道。于是我去查社区里的主流方案发现几乎所有讨论都指向同一件事上向量数据库。RAG 要向量数据库Agent 要向量数据库长期记忆也要向量数据库。Chromadb、Milvus、PGVector、Qdrant 轮番登场看起来不上一个向量数据库这个 AI 应用都不好意思出门。但你真正把项目跑起来就会发现向量数据库不是银弹。它要维护 embedding 模型要调切片长度要操心距离阈值要处理重复记忆膨胀还要在“语义相似”和“事实准确”之间反复纠结。更关键的是长期记忆这件事的本质其实不是“相似度召回”而是“记忆生命周期管理”。搞懂这一点之后我反而找到了一条更轻的路径不用向量数据库也能实现一套可用的 AI 长期记忆方案。这篇文章把我的实践思路和可落地代码完整拆出来底座就是三个开源组件SQLite、SQLite FTS5 和 Python。没有额外中间件没有向量索引也能跑通一个完整的记忆闭环。如果你正在做中小型 AI Agent、个人助手、内网工具或者想快速验证“长期记忆到底有没有用”这篇文章值得看完。1. 为什么“AI 长期记忆”总被约等于“向量数据库”先说一个直觉上的误区。当我们要给 AI 加记忆时第一反应是把历史对话切块向量化存进向量数据库下次按语义相似度把“相关的记忆”捞出来再拼进 Prompt。这个流程听起来无懈可击但问题是它把“长期记忆”和“RAG 知识库检索”混为一谈了。RAG 解决的是“外部知识”的问题它面对的是文档库、网页、PDF天然是非结构化文本适合用向量召回。但长期记忆不一样。长期记忆更多是“事实”和“状态”用户喜欢喝美式咖啡不加糖。用户的宠物狗叫旺财。用户当前正在处理订单 20240601状态是待支付。用户上次说下周要去上海出差。这些信息是精确的、结构化的、需要被覆盖更新的。你问“我家狗叫什么”这根本不是靠“语义相似”来查的而是要精确到“用户 user_1001 的宠物狗 name 旺财”。Embedding 召回非常擅长找“意思相近但表达不同”的内容但把它用在事实查询上反而会出现“最相似的向量不一定对应正确答案”的问题。另一个隐藏成本是工程复杂度。向量数据库通常会引入独立服务、网络调用、索引构建和超参调优。即便用 Chromadb 这种嵌入式方案你也得考虑 embedding 模型选型、向量维度、集合管理、持久化路径等等。对一个只想先把“记忆”功能跑起来的团队来说这层复杂度并不合理。所以我的判断是长期记忆的核心是“记忆管理”不是“向量检索”。向量数据库只是实现召回的一种手段而召回只是记忆管理的一环。你完全可以先用最朴素的组件把记忆的提取、存储、召回、更新、遗忘整个闭环跑通再在真正需要语义检索的地方引入向量能力。2. 长期记忆的本质一张会生长的状态表如果把长期记忆拆开来看它本质上是一个生命周期管理过程通常包括五个环节环节要解决的问题常见实现方式提取从对话中抽取出值得记住的信息LLM 结构化抽取、规则模板存储把记忆持久化避免进程重启丢失数据库、KV、文件召回在合适的时机把相关记忆取出精确匹配、全文检索、向量检索更新旧记忆被新事实覆盖或修正按业务主键覆盖、版本化遗忘低价值或过期的记忆被压缩清理时间衰减、LRU、LLM 总结把这张表摆出来你会发现问题很清晰向量数据库能覆盖的只有“召回”这一环中的“语义相似”部分。而真正让记忆系统好用的往往是提取准确度、更新策略和遗忘策略。这也是为什么很多项目上了向量数据库之后发现效果并没有想象中惊艳——他们需要的不是一个索引工具而是一个完整的记忆管理层。我们可以换个类比。向量数据库就像搜索引擎里的倒排索引它负责帮你快速定位“哪些文档和查询相关”。但一个业务系统里订单表、用户表、商品表才是真正的数据底座。你不会因为要用搜索引擎就把用户订单全部塞进倒排索引里。同样的道理AI 的长期记忆里大量内容是“用户偏好”“当前任务状态”“历史事实”这类结构化数据它们更需要的是像数据库一样精确读写而不是像搜索一样模糊召回。理解了这一点你就可以摆脱“记忆 向量数据库”的思维定式开始按真实业务需求来设计记忆层。3. 哪些场景真的需要向量数据库哪些其实不需要不是所有场景都该抛弃向量数据库。这里要做一个清醒的边界划分。场景是否需要向量数据库原因与替代方案记忆用户偏好、身份、事实信息通常不需要结构化字段 精确匹配足够更新和覆盖也更方便记忆当前对话状态、订单状态、任务进度不需要这是典型的 KV/状态存储用数据库或缓存即可小规模项目、快速原型、个人工具通常不需要全文检索 元数据过滤就能满足大部分记忆召回长期保存聊天原文事后按意思找“某句话”可能需要全文检索可以完成关键词级召回语义召回才需要向量大规模知识库、长文档、模糊语义查询更推荐向量数据库关键词断层、同义改写场景下向量召回优势明显多模态内容检索图片、音视频片段基本需要文本全文检索无法覆盖非语义特征判断标准其实很朴素如果你的记忆数据大多是“事实型”和“状态型”向量数据库帮不上大忙反而会增加系统复杂度如果你的记忆需求属于“内容检索型”即需要在大量非结构化文本里找到语义相关的内容那向量数据库是合适的。以我前面说的助手项目为例它需要记住的是用户偏好、家庭信息、任务进度这些属于典型的结构化事实。所以我不需要嵌入一个大向量库用 SQLite 存表就足够。而如果另一个项目的需求是“帮用户从一年前的聊天记录里翻出某次讨论的方案”那我就会考虑引入向量召回甚至混合使用多种索引。4. 轻量记忆层参考设计组件与核心流程所谓“开源方案”并不是指某个黑盒框架而是用几个成熟的开源组件搭出一套可维护的记忆层。4.1 组件选择SQLite零运维、单文件、支持事务适合做记忆的主存储。SQLite FTS5内置全文索引支持 BM25 排序适合做记忆的关键词召回。Python作为业务逻辑层负责记忆写入、召回、更新、遗忘策略编排。这套组合最大的价值是“不引入额外服务”。所有代码跑在业务进程内没有网络调用没有独立容器开发调试体验非常顺畅。数据量在几十万条以内时性能完全够用。4.2 记忆生命周期记忆系统的核心表设计如下CREATE TABLE memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, memory_type TEXT NOT NULL DEFAULT fact, content TEXT NOT NULL, content_token TEXT NOT NULL, importance REAL DEFAULT 0.5, created_at REAL NOT NULL, last_access REAL NOT NULL, access_count INTEGER DEFAULT 0, metadata TEXT DEFAULT {} );字段说明user_id记忆归属用户所有查询必须带上的隔离维度。memory_type记忆类型如 fact事实、preference偏好、topic话题、task任务。content记忆内容原文。content_token为中文检索做的分词预处理字段。importance记忆重要性由上层业务或 LLM 给出。last_access最近一次被召回的时间。access_count被召回次数。metadata扩展字段可放来源对话 ID、置信度等。这个表天然支持“更新覆盖”和“遗忘清理”。比如用户说“我不喜欢美式咖啡了”就可以把原来那条 preference 记录直接更新或标记过期而不是追加一条冲突记忆。4.3 综合评分策略召回时不能只靠关键词命中。一个合格的记忆系统需要把多个信号融合成一个分数文本匹配度FTS5 的 BM25 分数。重要性业务标记的 importance。新鲜度距离 last_access 的时间越近越容易被召回。使用频率access_count 越高说明这条记忆越常被用到。综合评分的含义是即使某条记忆和当前查询的关键词匹配不是最强但只要它足够重要、最近经常被用到仍然可以排到前面。这种机制很像人类记忆经常被提起的旧事比一次都没提过的新信息更容易想起来。5. 完整示例Python SQLite FTS5 实现记忆层下面直接给出一套可复制的实现。代码是完全可运行的不用魔改但要注意 FTS5 在不同 Python 环境下的支持情况。5.1 环境准备与 FTS5 检查建议环境Python 3.8SQLite 3.9Python 3.x 自带 sqlite3 模块通常满足操作系统无特殊要求首先检查你的环境中 SQLite 是否支持 FTS5# check_fts5.py import sqlite3 conn sqlite3.connect(:memory:) try: conn.execute(CREATE VIRTUAL TABLE _t USING fts5(content)) print(FTS5 OK) except sqlite3.OperationalError as e: print(FTS5 NOT SUPPORTED:, e)如果输出FTS5 OK就可以继续。如果报错说明当前 Python 打包的 SQLite 版本或编译选项不支持 FTS5需要更换 Python 环境或在系统层重新编译 SQLite。这一步虽然麻烦但必须在项目开始前确认否则后面建表会直接失败。5.2 建表与全文索引同步完整的初始化逻辑如下代码注释里解释了每段的作用# memory_store.py import json import re import sqlite3 import time DB_PATH memory.db def get_conn(db_pathDB_PATH): conn sqlite3.connect(db_path, timeout30) conn.row_factory sqlite3.Row try: conn.execute(PRAGMA journal_modeWAL;) except sqlite3.OperationalError: pass return conn def init_db(conn): # 主表保存记忆内容与元数据 conn.execute( CREATE TABLE IF NOT EXISTS memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, memory_type TEXT NOT NULL DEFAULT fact, content TEXT NOT NULL, content_token TEXT NOT NULL, importance REAL DEFAULT 0.5, created_at REAL NOT NULL, last_access REAL NOT NULL, access_count INTEGER DEFAULT 0, metadata TEXT DEFAULT {} ) ) # FTS5 全文索引表 conn.execute( CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5( content_token, contentmemories, content_rowidid ) ) # 插入触发器新记忆写入时自动同步到 FTS 索引 conn.execute( CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN INSERT INTO memories_fts(rowid, content_token) VALUES (new.id, new.content_token); END ) # 删除触发器删除记忆时同步删除索引 conn.execute( CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN INSERT INTO memories_fts(memories_fts, rowid, content_token) VALUES (delete, old.id, old.content_token); END ) # 更新触发器先删除旧索引再插入新索引 conn.execute( CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN INSERT INTO memories_fts(memories_fts, rowid, content_token) VALUES (delete, old.id, old.content_token); INSERT INTO memories_fts(rowid, content_token) VALUES (new.id, new.content_token); END ) conn.commit()这里最容易踩坑的是 FTS5 外部内容表的触发器遗漏。如果只建 FTS 表但不维护触发器后续所有写入都不会同步到索引里召回时可能什么都查不到。所以初始化时一定要确认三个触发器都创建成功。5.3 中文检索预处理SQLite FTS5 默认分词器是 unicode61对中文支持很差。它会把一长串连续汉字当成一个 token导致“用户喜欢喝美式咖啡”这样的句子无法用“美式”检索到。这里提供一个折中方案在写入和查询时统一做“单字 二元组”切分。def tokenize_cn(text: str) - str: 简单中文切分保留单字和二元组英文小写化。仅供检索演示。 tokens [] # 把连续中文和英文/数字块分开处理 for part in re.findall(r[\u4e00-\u9fff]|[a-zA-Z0-9_], text): if re.fullmatch(r[\u4e00-\u9fff], part): # 中文保留每个单字 tokens.extend(part) # 再补充相邻二元组 if len(part) 2: tokens.extend(part[i:i 2] for i in range(len(part) - 1)) else: tokens.append(part.lower()) return .join(tokens)比如用户喜欢喝美式咖啡会被转换成类似用 户 用 户 喜 欢 喜 欢 喝 美 式 咖 啡 ...的 token 序列。虽然会带来一部分索引膨胀但对于中小规模项目完全可控而且能同时支持单字和双字查询实用性很强。5.4 写入记忆def add_memory( conn, user_id: str, content: str, memory_type: str fact, importance: float 0.5, metadata: dict | None None, ) - int: now time.time() content_token tokenize_cn(content) cur conn.execute( INSERT INTO memories (user_id, memory_type, content, content_token, importance, created_at, last_access, access_count, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?) , ( user_id, memory_type, content, content_token, importance, now, now, json.dumps(metadata or {}, ensure_asciiFalse), )) conn.commit() return cur.lastrowid写入时就把content_token算好落库后触发器会自动把 token 同步进 FTS 索引。这个设计避免了查询时对整张表做全表扫描。5.5 召回与综合排序召回逻辑是记忆层的核心。先用 FTS5 完成粗召回再在内存里做综合评分排序def recall_memories( conn, user_id: str, query: str, top_k: int 5, decay_hours: float 24.0, ): query_token tokenize_cn(query) # 中文 n-gram 场景用 OR 连接避免所有 token 同时出现才能命中的问题 fts_query OR .join(query_token.split()) if not fts_query: return [] now time.time() # 先粗召回 top_k * 10 条候选 rows conn.execute( SELECT m.id, m.memory_type, m.content, m.importance, m.last_access, m.access_count, -bm25(memories_fts) AS match_score FROM memories_fts JOIN memories m ON m.id memories_fts.rowid WHERE memories_fts MATCH ? AND m.user_id ? LIMIT ? , (fts_query, user_id, top_k * 10)).fetchall() if not rows: return [] # 对 match_score 做 min-max 归一化 scores [r[match_score] for r in rows] min_s, max_s min(scores), max(scores) diff max_s - min_s if max_s min_s else 1.0 ranked [] for r in rows: norm_score (r[match_score] - min_s) / diff # 时间衰减分越久没被访问分数越低 recency_score 1.0 / (1.0 (now - r[last_access]) / (3600.0 * decay_hours)) # 访问频率分最多按 10 次截断 access_score min(r[access_count], 10) / 10.0 final_score ( 0.5 * norm_score 0.2 * r[importance] 0.2 * recency_score 0.1 * access_score ) ranked.append((final_score, r)) ranked.sort(keylambda x: x[0], reverseTrue) return ranked[:top_k]这里做了一个关键选择用OR连接所有查询 token。原因是中文单字 二元组切分后如果一个查询包含多个 token用默认的AND会要求文档同时包含所有 token召回率会很低。而在 FTS5 的外部内容表上执行MATCH需要保证索引与主表同步否则 join 出来的结果可能对不上。5.6 更新与遗忘记忆更新最常见的场景是“事实覆盖”。用户说“我不喜欢美式咖啡了”对应旧记忆应该被更新而不是追加一条矛盾记录def update_memory_content(conn, memory_id: int, new_content: str, importance: float | None None) - bool: row conn.execute(SELECT * FROM memories WHERE id ?, (memory_id,)).fetchone() if not row: return False new_token tokenize_cn(new_content) if importance is None: importance row[importance] conn.execute( UPDATE memories SET content ?, content_token ?, importance ? WHERE id ? , (new_content, new_token, importance, memory_id)) conn.commit() return True遗忘策略是记忆系统的“除草器”。如果只增不减记忆表迟早会被无价值内容塞满。一个朴素的规则是清理“低重要 长期未被访问”的旧记忆def forget_stale(conn, user_id: str, days: float 30.0, min_importance: float 0.3) - int: threshold time.time() - days * 86400 cur conn.execute( DELETE FROM memories WHERE user_id ? AND last_access ? AND importance ? , (user_id, threshold, min_importance)) conn.commit() return cur.rowcount更进阶的遗忘方式是用 LLM 把同一用户的历史记忆合并成一条 summary再把旧的候选记忆批量删除。这个逻辑可以根据你的项目按需接入核心思路是记忆不是为了无限积累而是为了在合适的时刻提供正确信息。5.7 运行演示与结果下面写一个最小演示验证整套流程def demo(): conn get_conn() init_db(conn) uid user_1001 add_memory( conn, uid, 用户喜欢喝美式咖啡不加糖, memory_typepreference, importance0.8, ) add_memory( conn, uid, 用户的宠物狗叫旺财, memory_typefact, importance0.9, ) add_memory( conn, uid, 用户最近在研究量子计算, memory_typetopic, importance0.5, ) print( 查询狗 ) for score, row in recall_memories(conn, uid, 狗): print(f{score:.4f} | {row[content]}) print( 查询咖啡 ) for score, row in recall_memories(conn, uid, 咖啡): print(f{score:.4f} | {row[content]}) if __name__ __main__: demo()运行命令python memory_store.py预期输出大致如下 查询狗 0.8732 | 用户的宠物狗叫旺财 0.5110 | 用户最近在研究量子计算 查询咖啡 0.9021 | 用户喜欢喝美式咖啡不加糖 0.2338 | 用户最近在研究量子计算注意具体分数会因 BM25 归一化和时间衰减而变化但排序结果应该符合预期查询“狗”时宠物狗记忆排第一查询“咖啡”时咖啡偏好排第一。如果出现“量子计算”排在前面说明你的业务场景里 importance 权重可能偏低或者候选集太小可以适当调大 text match 权重。6. 运行结果与效果验证上面这段代码跑通之后你可以把它当成一个最小的记忆层骨架。但我建议再补一组“验证测试”确认记忆系统不是只对固定几条数据有效。建议从三个维度验证召回排序是否符合业务直觉。构造 10 条记忆其中有 5 条围绕“咖啡”5 条围绕“编程”分别查询“咖啡”“Python”“旺财”观察召回结果是否把对应类型排在前面。更新时间是否生效。先写入“用户喜欢美式咖啡”再调用update_memory_content改成“用户喜欢拿铁”随后查询“咖啡”确认不会再返回旧内容。遗忘策略是否可见。设置min_importance0.9执行forget_stale确认低重要性且长期未访问的记忆被删除而高重要性记忆保留。这组验证比单个 demo 更有工程意义。长期记忆系统的价值只有在“数据略微混乱、存在冲突和过期内容”时才能体现出来。7. 常见问题与排查思路问题现象可能原因排查方式解决方案建表时报no such module: fts5当前 SQLite 版本不支持 FTS5运行 FTS5 检查脚本更换 Python 环境或重编译 SQLite中文查询搜不到结果FTS 默认分词器对中文不友好查看 content_token 字段是否已生成使用自定义单字 二元组切分查询时同样切分更新记忆后查询