用本地混合检索补足大模型长期记忆
文章讨论长上下文容量与有效信息召回之间的差距,并区分参数记忆和工作记忆。提出结合 Python、SQLite FTS5、sqlite-vec、RRF 排序融合及提示词边界布局,构建离线优先的持久记忆架构。
AI 深度解读
上下文能装下 128K、甚至超过 2M tokens,模型就能记住其中的每个细节吗?原文的回答是否定的。它提出一套本地记忆方案:用 SQLite 保存历史,通过关键词与向量检索找回相关片段,再结合摘要组织下一轮输入,减少对超长上下文的依赖。
对做 AI 编程助手、多轮问答的开发者,这套思路有参考价值。但文章把工程建议和理论断言混在了一起,关于 KV cache、注意力复杂度以及「保证召回」的说法,需要拆开看。
能放进上下文,不等于需要时能找出来
原文首先区分了两件事:上下文容量决定一次能输入多少内容,有效工作记忆决定模型能否从这些内容中找到并利用信息。
一个编程助手可以接收整段讨论记录,却仍然漏掉中间提过的函数约束。容量解决的是「装得下」,并不直接保证「用得对」。

文章用 Lost in the Middle(LiM)解释这个落差:一些长上下文检索任务呈现 U 形表现,开头和末尾的信息更容易被利用,中间的信息更容易被遗漏。
作者进一步引入三种位置影响:开头的 Primacy Tail 为 O(log N),末尾的 Recency Delta 为 O(1),中间的 Factorial Dead Zone 为 O(1 / (H-1)!)。其中 N 表示序列长度,H 表示网络深度,并以 H = 32、H = 80 说明阶乘衰减。
这些是原文转述的理论结论。提供的材料没有展开证明条件,不能据此认定所有模型的中间内容都会失效,更不能把示意曲线当成实际召回率。
另外,原文称 KV cache 的计算和显存占用都随长度按 O(N²) 增长,这里混淆了不同开销。**固定模型结构下,常规 KV cache 的存储量通常随序列长度线性增长。**标准全注意力处理完整序列时的二次方计算开销,不能直接套到缓存存储上。「显存抖动造成中间遗忘」也没有在文中得到实验支持。
长期记忆该存在哪里,检索又该怎么选
原文把模型已有的记忆分为两类:参数记忆是训练进权重的知识;工作记忆是当前请求携带的上下文。前者更新成本高,后者可以即时修改,却不能承担无限增长的历史记录。
因此,作者提出第三层:外部持久化存储。历史保存在数据库里,每次只取与当前问题有关的部分。

方案使用 Python、SQLite FTS5 和 sqlite-vec:
- FTS5 通过 BM25 做词项检索,适合寻找
handleSubmit、ERR_CONNECTION_REFUSED这类明确符号。 - sqlite-vec 使用
vec0虚拟表保存归一化的 384 维向量,通过 KNN 寻找语义接近的内容。 - 两路结果使用倒数排名融合(Reciprocal Rank Fusion,RRF),避免直接比较不同体系的原始分数。
RRF 只关心排名:某条记录在一路结果中排名 r,就获得 1 / (60 + r) 的贡献,多路贡献相加。原文使用平滑常数 k = 60,两路各取 20 条,默认返回 top_k = 5。
下面是原文的检索实现,用来展示两路排名如何合并:
import sqlite3
import numpy as np
def hybrid_rrf_search(db_path: str, query_text: str, query_vector: list[float], top_k: int = 5) -> list[tuple[int, float]]:
conn = sqlite3.connect(db_path)
cur = conn.cursor()
# Path 1: Lexical Search via FTS5 (BM25)
cur.execute("""
SELECT content_rowid FROM conversations_fts
WHERE conversations_fts MATCH ?
ORDER BY rank LIMIT 20
""", (query_text,))
fts_ranks = {row[0]: rank + 1 for rank, row in enumerate(cur.fetchall())}
# Path 2: Semantic Search via sqlite-vec
query_blob = np.array(query_vector, dtype=np.float32).tobytes()
cur.execute("""
SELECT rowid FROM conversations_vec
WHERE embedding MATCH ?
ORDER BY distance LIMIT 20
""", (query_blob,))
vec_ranks = {row[0]: rank + 1 for rank, row in enumerate(cur.fetchall())}
# Step 3: Reciprocal Rank Fusion
all_ids = set(fts_ranks.keys()).union(set(vec_ranks.keys()))
rrf_scores = {}
k = 60
for doc_id in all_ids:
score_fts = 1.0 / (k + fts_ranks[doc_id]) if doc_id in fts_ranks else 0.0
score_vec = 1.0 / (k + vec_ranks[doc_id]) if doc_id in vec_ranks else 0.0
rrf_scores[doc_id] = score_fts + score_vec
sorted_results = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
return sorted_results[:top_k]
需要先检查 SELECT content_rowid 这一行。按原文给出的建表语句,content_rowid='id' 是 FTS5 的配置项,并没有创建一个名为 content_rowid 的查询列;这里应核对使用 rowid。
它也不是完整的可运行程序:没有加载 sqlite-vec 扩展,没有生成或写入向量,也没有证明两张索引表的 rowid 与原始对话 id 一致。原文只展示 FTS5 的插入、删除触发器,更新同步和向量同步仍需补齐。查询文本直接交给 MATCH,还要处理其查询语法。
384 维只是存储约定。作者没有说明使用哪个 embedding 模型,也没有给出 sqlite-vec 版本,具体 KNN 查询写法仍需验证。
摘要控制长度,位置安排提高利用机会
检索负责找回过去,摘要负责维持当前任务状态,两者解决的问题不同。
原文将对话拆成当前上下文 C_active、较旧历史 C_history 和近期缓冲 C_recent。当总 tokens 超过阈值 T_max,后台摘要器把旧历史压缩成 S_t,提取关键实体、未决事项和当前目标。原始记录继续保存在 SQLite,近期对话保留在输入里。
作者称这样可以维持 O(1) 的上下文占用。这个说法有前提:摘要、近期缓冲和检索片段都必须有固定预算。只触发摘要,却允许摘要不断变长,并不能让输入保持有界。
最后一步是安排位置:系统指令和排名靠前的检索记忆放在前部,S_t、近期对话与当前问题放在尾部。
这里的「中间留空」应理解为删去低价值内容。只要输入仍然很长,它就仍然存在中间位置,不能靠一个提示词模板消除位置偏差。
**这套方案更有价值的部分,是控制输入的信息密度。**至于文中声称位置安排可以确保「最大梯度影响」,推理时并不执行训练式梯度更新,这种措辞不足以证明回答会更准确。
我的判断
我认可「保存原文、按需检索、维护摘要」这个方向。相比不断追加聊天记录,它让开发者有机会检查模型究竟拿到了哪些依据,也适合围绕代码符号和历史决策做检索。
但文章没有提供团队实测过程,也没有召回率、延迟或成本对照。所谓「可预测召回」「最低延迟」属于方案主张,不能当作已验证收益。
对于短对话,直接保留近期上下文可能更省事;对于主要靠函数名、错误码定位的问题,单用 FTS5 也应先作为比较基线。混合检索是否值得增加 embedding、索引同步和摘要维护成本,要靠自己的任务验证。
「离线、零依赖」同样说得过满。示例已经依赖 NumPy、sqlite-vec,还需要 embedding 模型和摘要模型。只有这些环节都在本地运行,才具备完整离线条件;数据库存在本机,不代表数据不会被发送给远端模型。
原文也没有处理记忆过时、摘要漏掉限制条件、检索记录夹带指令等问题。历史记忆应该作为带来源的参考材料,不能自动获得系统指令的优先级。
能拿来做什么
- 给多轮 AI 编程工具记录历史决策时,同时保存原文和简短摘要。摘要负责快速恢复任务,原文负责核对细节。
- 先用函数名、错误码、历史需求各准备一组查询,比较 FTS5、向量检索和 RRF 的结果,再决定是否引入完整混合链路。
- 为摘要、近期对话和检索片段分别设置 token 预算,并记录每轮最终输入的内容,便于排查遗漏发生在检索还是生成阶段。
- 接入现有前端或 Node 应用前,先补齐示例中的扩展加载、向量写入和索引同步,验证更新、删除记录后两路检索仍然一致。