如果只让你保留一套 AI 长期记忆方案你会优先考虑向量数据库吗我不会。最近我在整理一套不依赖向量数据库也能做 AI 长期记忆的开源思路核心不是放弃语义检索而是把记忆拆成结构化存储加轻量全文检索。适合小规模 agent、个人项目、本地优先场景。先给结论当记忆条数在几万以内召回靠精确匹配和关键词命中就够用的时候一套 SQLite 加 FTS5 的方案可能比向量库更省心。这不是说向量数据库没用而是很多项目把问题想复杂了。长期记忆真正要解决的不是“无所不找”而是“该想起来的时候能想起来”。大部分业务记忆比如用户偏好、任务状态、历史决策语义相近但表达可能不同可只要你按结构存好用关键词和标签就能召回。向量检索只是其中一种手段不是唯一方案。这篇文章会按一套完整落地方案来拆先看长期记忆到底缺什么再对比不同存储方案接着给出 SQLite 和 PostgreSQL 两个可复现的轻量记忆实现最后说明什么时候才真的需要向量数据库以及常见问题怎么排查。如果你正在做 AI agent、聊天机器人或本地知识助手这篇文章可以直接作为设计参考。1. AI 长期记忆的本质不是“存进去”而是“用得上”1.1 长期记忆到底记什么长期记忆这个词很容易被理解成“把聊天记录保存下来”。真正的长期记忆不是日志而是从历史交互里提取出来的、能影响后续行为的信息。我一般会把需要记忆的内容分成几类用户偏好比如“这个用户喜欢简洁回答”“不喜欢表格”“习惯用中文项目命名”。事实档案比如“用户所在城市是上海”“负责的产品线有两条”“团队规模 20 人”。任务状态比如“上轮任务是生成周报大纲”“周二上午要跟进客户反馈”。历史决策比如“上次选型的时候排除了某个依赖因为维护不活跃”。这些信息有个共同点它们不是一段连续的文本而是可以被固定字段描述的事实。只要你用一张表、几个字段就能表达清楚就不一定非要向量化。1.2 记忆系统要打通两条链路任何记忆方案核心都只有两条链路写入和召回。写入决定记忆是否完整、是否去重、是否过期。召回决定遇到新输入时系统能不能让最相关的记忆出现在上下文中。向量数据库解决的是召回端的相似性搜索问题。它把文本转成向量再用距离计算找出语义相近的结果。听起来很美好但代价也很明确你要维护 embedding 服务、向量索引、维度配置、距离算法还要处理文本切块、索引更新、召回阈值调参。对一个日活几百、记忆条数几万的小项目来说这个复杂度往往是多余的。1.3 向量数据库在记忆系统里的真实位置我不反对向量检索甚至认为它是 RAG 架构里的重要组件。但放在长期记忆场景里它只解决一个问题当你的记忆无法用精确条件表达只能靠语义相似度去撞的时候向量检索才有不可替代的价值。举个例子用户说“上次那个跑批脚本有点慢”而历史记忆里存着“用户提到数据处理任务耗时较长”。这两句话字面不同语义相似。如果用关键词匹配可能漏掉用向量召回能关联上。但更多时候记忆的召回条件是非常明确的。比如“用户所在城市”“当前项目名”“上次选型结论”。这些字段用 SQL 查询就能精准定位根本不需要算相似度。所以我的判断标准很简单能结构化表达的记忆优先用结构化存储只有自由文本的语义联想才考虑向量化。2. 不用向量数据库的替代方案结构化记忆加轻量全文检索2.1 结构化记忆表怎么设计既然要用传统数据库第一件事是设计记忆表。这张表不能设计成一个大字段塞文本必须拆成多个可查询的列否则后面所有功能都会别扭。我常用的一张记忆表核心字段如下字段名类型说明id整数/文本记忆唯一标识memory_type文本记忆类型偏好、事实、任务、决策entity文本关联主体用户、项目、机器人等content文本记忆正文一段可读性描述keywords文本逗号分隔的检索关键词importance整数重要程度用于排序created_at时间写入时间updated_at时间最后更新时间expire_at时间过期时间空为不过期这个结构的好处是召回路径清晰。用户问“他喜欢什么风格”就查 memory_type 等于“偏好”的记录问“这个项目进度到哪了”就查 entity 等于项目名的记录。content 字段虽然要存文本但不代表要做向量化。它只是给人或 LLM 阅读用的原始描述召回靠的是 keywords 和结构化条件。2.2 全文检索为什么能覆盖大部分召回只用结构化字段还不够。因为用户提问经常不是精确的字段值而是自然语言描述。比如“上次说的跑批优化还有哪些没做”这句里的关键词可能只在 content 里出现不在 entity 里。这时需要全文检索。SQLite 内置 FTS5PostgreSQL 内置全文检索MySQL 也有全文索引。它们不需要额外部署向量服务就能提供关键词匹配、短语匹配、排序能力。对于几万条记忆的规模全文检索的性能完全够用。我实测过普通笔记本上 5 万条记忆FTS5 查询基本是毫秒级。更关键的是全文检索的行为更可控。它不搞“我觉得语义相近”这种黑盒命不命中完全由词项决定。这对调试很友好出现召回不准时你能快速定位是关键词缺了还是分词问题。2.3 标签和分类为什么重要纯全文检索也有缺点同义词问题。用户说“喜欢简明风格”记忆里写的是“偏好极简回答”。关键词不同匹配会失败。解决方案不是立即引入向量库而是先给记忆打标签。在 keywords 字段里手动或借助 LLM 提取“风格、简洁、极简、简明”等词。写入时多存几个同义关键词召回时就能覆盖。我实际项目里就是这么处理的。写入记忆前让 LLM 生成 3 到 5 个关键词和记忆正文一起存储。效果非常稳定召回率上来了而且没有额外组件没有 embedding 延迟。3. 实操基于 SQLite FTS5 的轻量记忆服务这一节给出一个可以直接复制的实现思路。不需要安装额外数据库服务Python 环境自带 SQLiteFTS5 是标准编译选项多数情况直接用。3.1 环境与前置条件操作系统Windows、macOS、Linux 均可。Python 3.8 以上。不需要安装第三方数据库。如果使用 Python直接 import sqlite3。验证 SQLite 是否支持 FTS5可以执行import sqlite3 conn sqlite3.connect(:memory:) conn.execute(CREATE VIRTUAL TABLE test_fts USING fts5(content)) print(FTS5 supported)这一句能跑通说明环境没问题。3.2 建记忆表和全文索引我的做法是建两张表一张结构化记忆表一张 FTS5 索引表。业务查询优先走结构化表自由文本检索走 FTS5 表。CREATE TABLE IF NOT EXISTS memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, memory_type TEXT NOT NULL, entity TEXT NOT NULL, content TEXT NOT NULL, keywords TEXT DEFAULT , importance INTEGER DEFAULT 1, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP, expire_at TEXT ); CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(memory_type); CREATE INDEX IF NOT EXISTS idx_memories_entity ON memories(entity); CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5( content, keywords, contentmemories, content_rowidid );这里用到了 FTS5 的 external content 模式。好处是数据主体在普通表里方便 SQL 管理FTS5 表只负责索引避免两份数据不同步。3.3 写入记忆写入记忆时要同时更新普通表和 FTS 索引。为了保持同步我用事务包裹。如果是新增记忆先插普通表拿到 id再写 FTS 表。import sqlite3 def add_memory(conn, memory_type, entity, content, keywords): conn.execute(BEGIN) try: cur conn.execute( INSERT INTO memories (memory_type, entity, content, keywords, importance) VALUES (?, ?, ?, ?, ?) , (memory_type, entity, content, keywords, 1) ) memory_id cur.lastrowid conn.execute( INSERT INTO memories_fts (rowid, content, keywords) VALUES (?, ?, ?) , (memory_id, content, keywords) ) conn.execute(COMMIT) return memory_id except Exception: conn.execute(ROLLBACK) raise这里有两个容易踩的坑。第一单条记忆内容不要无限长超过几千字会影响检索质量也会让后续给 LLM 的上下文变臃肿。第二keywords 写入前最好统一成小写并去重不然检索时大小写和重复词会干扰评分。3.4 召回记忆召回分两种场景。第一种是结构化条件明确。比如“查这个用户的所有偏好”直接查普通表SELECT content, importance, updated_at FROM memories WHERE entity ? AND memory_type preference ORDER BY importance DESC, updated_at DESC LIMIT ?;第二种是用户用自然语言提问。先提取关键词再用 FTS5 搜索def search_memories(conn, query_text, limit5): # 示例从一个自然语言句子中选出几个核心词 # 实际项目中可以用简单分词或 LLM 抽取 words extract_keywords(query_text) if not words: return [] match_expr OR .join(f{w} for w in words) sql SELECT m.id, m.memory_type, m.entity, m.content, bm25(memories_fts) AS score FROM memories_fts JOIN memories m ON m.id memories_fts.rowid WHERE memories_fts MATCH ? ORDER BY score LIMIT ? rows conn.execute(sql, (match_expr, limit)).fetchall() # 如果结果太少再退回到结构化字段查询 if len(rows) limit: type_rows conn.execute( SELECT id, memory_type, entity, content, 0 AS score FROM memories WHERE memory_type IN (preference, fact) ORDER BY updated_at DESC LIMIT ? , (limit - len(rows),) ).fetchall() rows.extend(type_rows) return rows关键点是bm25()排序它是 SQLite FTS5 自带的评分函数分数越低代表相关性越高所以用ORDER BY score升序。调用的时候还要注意一点关键词不足时不要强行搜索。比如用户只说了“情况怎么样”这样的查询词太泛FTS5 会返回大量无关结果。这时可以直接返回最近更新的记忆或者让 LLM 把问题转成更具体的检索条件。3.5 记忆清理和压缩长期记忆不能只写不删。时间一长重复记忆、过期状态、低价值内容会占据检索空间。我按三层来做过期清理定期删除 expire_at 非空且早于当前时间的记录。重复合并按 entity 加 memory_type 分组找出 content 相似度过高的记录保留 importance 更高的那条。记忆压缩当某条记忆积累太多更新记录时让 LLM 把旧内容压缩成一份新的摘要替换原记录。压缩操作要注意不能只压 contentkeywords 也要重新生成。否则检索词还是旧的新内容反而匹配不到。4. 进阶PostgreSQL 全文检索与 JSONB4.1 为什么从 SQLite 升级到 PostgreSQLSQLite 适合单机、单进程、小规模。一旦你的记忆系统要部署成独立服务或者多个 agent 实例同时写入SQLite 的写入锁和网络访问限制会变成瓶颈。PostgreSQL 是更稳的替代。它有原生全文检索还有 JSONB 这种半结构化字段灵活性更高。适用场景大概是有多个服务实例并发读写。需要通过 IP 和端口访问记忆库。记忆条数超过十万。需要更细粒度的用户权限控制。4.2 表结构和全文检索配置PostgreSQL 版本建议 12 以上因为对中文全文检索的配置更友好。安装后先启用扩展CREATE EXTENSION IF NOT EXISTS pg_trgm;pg_trgm 提供三元组相似度搜索对中文短文本召回很有帮助但它和 FTS5 不是一回事不能替代全文索引。建表CREATE TABLE memories ( id BIGSERIAL PRIMARY KEY, memory_type TEXT NOT NULL, entity TEXT NOT NULL, content TEXT NOT NULL, keywords TEXT[] DEFAULT {}, importance INTEGER DEFAULT 1, metadata JSONB DEFAULT {}, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now(), expire_at TIMESTAMPTZ ); CREATE INDEX idx_memories_type ON memories(memory_type); CREATE INDEX idx_memories_entity ON memories(entity); CREATE INDEX idx_memories_keywords ON memories USING gin(keywords); CREATE INDEX idx_memories_search ON memories USING gin(to_tsvector(simple, content));这里用to_tsvector(simple, content)建了表达式索引。对中文来说内置分词器不一定好用所以我会先把 keywords 设计成数组用 GIN 索引做元素匹配把 content 全文索引作为补充。4.3 检索逻辑怎么写PostgreSQL 的检索可以写得更灵活。一条 SQL 同时处理结构化条件、标签过滤、全文搜索SELECT id, memory_type, entity, content, ts_rank(to_tsvector(simple, content), plainto_tsquery(simple, ?)) AS rank FROM memories WHERE (entity ? OR ? ) AND (memory_type ? OR ? ) AND ( keywords ARRAY[?] OR to_tsvector(simple, content) plainto_tsquery(simple, ?) ) AND (expire_at IS NULL OR expire_at now()) ORDER BY importance DESC, rank DESC LIMIT ?;这个 SQL 看起来长逻辑很清晰。keywords ARRAY[?]表示数组有交集用来做标签匹配。plainto_tsquery处理自由文本。expire_at条件保证没查过期记忆。生产环境里我建议把这段 SQL 封装成函数参数用绑定变量避免 SQL 拼接。索引命中情况用EXPLAIN ANALYZE验证不要凭感觉。5. 到底什么时候才需要向量数据库5.1 从数据规模来判断我自己的经验是分档判断记忆规模推荐方案原因几千条以内文件或 SQLite写入简单查得快成本低几万到几十万条PostgreSQL 全文检索并发和扩展性更好检索可控百万条以上且以自然语言语义召回为主向量数据库关键词匹配已经不够必须靠语义相似度这个表不是绝对标准但可以作为选型起点。如果你的关键词匹配命中率已经在 80% 以上加向量库带来的收益会很小成本却很大。5.2 从召回质量来判断判断标准是你希望记忆系统“怎么被想起”。如果业务中用户提问和记忆内容高度同义改写比如“喜欢利落的回复”和“偏好简短答案”而且这种改写没有固定规律那关键词方案会漏向量方案更合适。如果提问通常包含明确的实体词比如项目名、用户名、功能名那结构化字段加关键词就足够。实体词是天然的锚点没必要向量化。5.3 折中过渡方案即使真需要语义召回也不是只能上完整向量数据库。可以先做一个轻量特征方案用语言模型把记忆内容生成几个同义改写短句存入 keywords。检索时把用户问题也做一次改写再匹配 keywords。用 PostgreSQL 的similarity()函数做模糊匹配作为兜底。这样能在不引入向量索引的情况下把语义召回提升一大截。我先跑通业务再把真正高价值的检索场景迁移到向量库。6. 常见问题和排查顺序6.1 召回结果不准先不要怀疑数据库。先检查写入时的 keywords 是否覆盖了核心实体词再检查查询词是否太泛。我遇到最多的情况是写入时只存了 content没有认真提取关键词查询时又只用整句匹配导致 FTS 命中率低。正确做法写入前让 LLM 生成 3 到 5 个有区分度的关键词查询前把用户问题做一次关键词抽取。不要用整段话直接去匹配。6.2 写入变慢如果 FTS5 external content 模式下写入明显变慢先检查是不是每次都触发了全表 rebuild。INSERT到 FTS 表时只插增量数据不要执行INSERT INTO memories_fts(memories_fts) VALUES(rebuild)。PostgreSQL 那边检查 GIN 索引是否有过大膨胀必要时REINDEX。6.3 记忆重复重复通常来自两个地方同一段对话触发多次写入或者多条记忆描述同一事实。写入侧加去重逻辑按 entity、memory_type、content 哈希做唯一约束更新时用 UPSERT。召回侧做合并先按实体分组再通过 LLM 汇总。不要指望数据库自动解决。6.4 排查顺序遇到记忆系统出问题我一般按下面顺序排查先复现现象确认是写不进去、查不到还是返回了错误内容。检查输入用户问题里的实体词是否在记忆 keywords 中。检查数据直接查数据库看记忆有没有真的写入updated_at 是否正确。检查检索先跑最简单的一条 SQL逐步加条件看到底哪一步把结果过滤掉了。检查上下文组装确认召回结果真的被拼进 LLM 上下文而不是模型压根没看到。大部分记忆问题最终都出在数据写入不干净和召回条件太严这两处和向量数据库没有关系。6.5 这套方案适合谁这套轻量方案适合个人知识助手、企业内部 agent、小团队的工具型应用。它们的数据量有限业务记忆边界清晰团队也没有专门维护中间件的人力。如果你的产品已经是大规模开放域对话用户记忆有大量长尾语义并且已经有向量数据库运维经验那直接上向量库完全合理。两者不冲突甚至可以共存。我更建议的做法是先结构化后向量化。把基础事实和偏好放传统数据库把语义模糊的长文本回忆放向量库两边配合而不是一上来就全量向量化。最后留一个个人观察很多项目换向量库之前连最简单的记忆表都没设计好。先花时间把 entity、memory_type、keywords 这三列做好比换任何存储引擎都更有价值。