给 agent 用的记忆层:把已有的记忆汇总成一份可快速检索的本地索引,同时给没有记忆系统的 agent 提供一个。
记忆散落在各处 —— 有的 agent 自带记忆系统(各自的 Markdown、JSON、数据库),有的完全没有,关掉会话就忘光。
memory-agent 做两件事:
- 汇总 —— 把已有记忆读出来,建成一份本地 SQLite 索引。原文件不动,只读。
- 提供 —— 给没有记忆系统的 agent 一套写入 + 检索能力,通过 MCP 或 CLI 接入。
Markdown 是给人看的。但当记忆成百上千条时,模型靠逐个读文件既找不准也找不快 —— 索引解决的正是这一步。汇总与检索全程在本机完成,内容不出网。
| 承诺 | 实现方式 |
|---|---|
| 原文件只读 | 索引端只读取源文件,从不修改、从不删除。真相始终在你自己手里 |
| 真相源与索引分离 | 原格式(Markdown 等)是真相源,SQLite 只是可重建的索引。删库可 index --rebuild 重建 |
| 存储区独立于项目 | 记忆在 ~/.memory_agent(路径可任意指定),代码在仓库。项目更新或程序失误不威胁记忆;重新克隆项目后配好 config.json 即可继续访问 |
⚠️ 一条如实的例外:访问统计不可重建。cards(卡片内容 + 全文索引 + priority/ttl)全部能从 Markdown 重新解析出来, 所以删库重建是无损的。但card_stats表记录的读取次数与最后读取时间没有第二个来源 —— 它一旦丢失就是永久丢失。这也是index --rebuild会清空cards/cards_fts却特意保留card_stats的原因(判据不是「表名像不像索引」,而是 能否从真相源重新算出来)。删掉.db文件这种损坏下,统计确实会丢。 卡片内容本身不受影响。 | 零运行依赖、内容不出网 | 仅用 Python 标准库;不做任何 LLM 调用,蒸馏交给调用方 agent | | 面向 agent 而非人 | 输出以机器可读为先(--json),不做展示层 |
已实现:单源 Markdown 的采集 / 索引 / 检索、MCP 查询端、CLI、检索质量基准。
尚未实现:多源整合(当前只接受一个 vault)、Markdown 以外的格式适配。
agent 会话 ──采集端──> 记忆源(Markdown)──索引端──> SQLite ──查询端──> agent
真相源 · 只读 (可重建) (MCP)
本仓库实现采集端(mcore/capture.py)、索引端(mcore/importer.py)与
查询端(mcore/mcp_server.py),外加检索质量基准(bench/retrieval_quality.py)。
数据默认在 ~/.memory_agent,与代码仓库完全分离:
~/.memory_agent/
├── memory.db 索引库
├── config.json 本机配置(不进仓库)
├── bench_queries.json 检索基准真值(含本机卡片路径,不进仓库)
└── vault/ Markdown 卡片(默认位置)
解析优先级:CLI 参数 > 环境变量 > config.json > 默认值。
| CLI | 环境变量 | config.json | |
|---|---|---|---|
| 数据根 | — | MEMORY_AGENT_HOME |
— |
| 索引库 | --db |
MEMORY_AGENT_DB |
db |
| vault | --vault |
MEMORY_AGENT_VAULT |
vault |
需要 Python 3.10+,运行期不安装任何第三方依赖:
pip install .
memory --help开发时可安装测试与静态检查工具(只属于开发依赖,不进入运行路径):
pip install -e ".[dev]"
python -m pytest tests/ -q
python -m ruff check .即使不安装包,也保留从仓库直跑的路径:
python memory.py mcp
python tests/test_mcp.pypython memory.py paths # 当前生效路径
python memory.py index [--rebuild] # 同步 / 重建索引
python memory.py search <query> # 检索
python memory.py capture --title T --body B # 写入一张卡片
python memory.py update <id> --body B # 原地改一张卡(见「更新」一节)
python memory.py delete <id> # 软删到 .trash(见「删除」一节)
python memory.py supersede <id> --title T --body B # 写新卡并让旧卡失效(见「取代」一节)
python memory.py stats # 统计
python memory.py show <id> # 卡片全文(长卡默认分片,见下;失效卡会标注)
python memory.py mcp # 启动 MCP server所有命令支持 --json。退出码:0 成功,1 无结果 / 被拒绝,2 环境错误 / 参数不合法,
3 标题撞车被拒绝(--on-conflict reject)。
卡片会长(蒸馏出来的会话卡常有数千字符),所以取全文的默认带长度上限:
python memory.py show 8 # 默认最多 20000 字符
python memory.py show 8 --offset 20000 # 续读下一页
python memory.py show 8 --max-chars 500 # 自定义窗口
python memory.py show 8 --full # 不分片,返回完整正文返回值里有 offset / length / returned / has_more / next_offset,
文本末尾会写明「还有 N 字符未显示」以及续读要用的 next_offset。
截断永远显式:宁可多一行提示,也不静默砍掉后半段再当成全文返回 ——
那会让调用方以为卡片就这么短。MCP 的 memory_get 参数与语义完全一致
(offset / max_chars / full),两个入口共用 mcore/readtext.py 一份实现。
max_chars=0 等价于 --full。
search -n 的取值范围是 [1, 20],越界会被钳制 —— SQLite 的 LIMIT -1 表示无上限,
不钳制就会把整张表倒出来。
search 输出字段:id path title kind source score matched coverage snippet。
python memory.py capture --title "标题" --body "正文" [--kind knowledge] [--tags a,b]
# 正文也可从 stdin 读写入一张 Markdown 卡片到 vault,落盘后立刻索引本卡 —— 不需要再手动跑 index,
写完即可被 search 检索。frontmatter 与既有卡片格式一致,因此新旧卡片共存、互相可检索。
记忆的价值在于压缩。把整段会话原样倒进 vault 只会制造噪音,检索时反而更难找到重点。
所以 capture.py 不做任何 LLM 调用 —— 蒸馏由调用方完成:agent 本身就是 LLM,
让它先想清楚「什么值得记、怎么写以后才看得懂」,再交给这里落盘。
这样做的收益:零额外成本、零 API key、内容不出网。
| 情况 | 结果 |
|---|---|
| 新卡片 | 写入并索引本卡,返回 created + indexed: true |
| 标题与正文都相同 | 跳过,返回 unchanged(幂等) |
| 正文 < 20 字符 | 拒绝,返回 rejected |
| 标题相同、正文不同 | 不覆盖已有卡;默认另存为 -2,并回传 conflict: true + existing_path + collision_paths |
--on-conflict reject 下的标题撞车 |
不写盘,返回 conflict,退出码 3 |
| 正文/标题含疑似凭据 | 仍然写入,但回传 secrets_found + warnings(默认只告警) |
--reject-secrets 下含疑似凭据 |
不写盘,返回 rejected,退出码 1 |
| 索引失败 | 卡片仍在磁盘(真相源优先),返回 indexed: false + warning |
python memory.py update 12 --body "新的正文" # 只改正文
python memory.py update 12 --title "新标题" --tags a,b # 只改给定的字段
python memory.py update 12 --priority 5 --ttl 30d它与「取代」(supersede)的分工 —— 选错会让历史静默消失:
| 场景 | 用哪个 | 为什么 |
|---|---|---|
| 事实写错了(错别字、漏了参数、路径写错) | update |
没有「当时是对的」这回事,历史没有价值 |
| 事实变了(服务迁址、端口改了、价格变了) | supersede |
「当时是多少」以后还要能回答 |
行为约定(每条都有对应测试):
- 只改传进来的字段,其余 frontmatter 行、未知字段、正文一字不动;
updated刷新,created不动;- 路径不变(改标题也不改文件名)。
rel_path是取代关系与读取统计的锚点, 移动文件会让它们对不上 —— 代价是文件名可能与标题不一致,这一点会回传在note里; - 给定值与现值相同时不写盘,返回
changed: []。不做假动作,也不谎称改过; - 拒绝改
--kind:kind决定卡片所在目录,改它等于移动文件。这是故意的不支持 —— 报错并提示「先supersede出新类型的新卡,再delete旧卡」,而不是静默忽略; - 正文长度门槛与
capture同一道(< 20 字符拒绝)。两处门槛不同的后果是 「同一份内容换个入口就能进来」,而两个入口都返回成功; indexed三态:true已重新索引 /false写盘成功但索引失败(见warning)/null没有字段变化、未写盘。不用false兼表「没变化」,否则调用方会把 「什么都没改」错读成「索引坏了」;- 不从 stdin 读正文:
--body不给就是「不动正文」。否则「没打算改正文」 会变成「把管道内容当成新正文写进去」。
python memory.py supersede 12 --title "ECS 部署地址(新)" --body "已迁到 10.0.0.9,端口 9090"
python memory.py search ECS --as-of 2026-03-01 # 回溯:那天当时有效的是什么
python memory.py search ECS --include-invalid # 连已失效的旧卡一起看
python memory.py show 12 # 旧卡全文照常可读,并标注已被取代它和「更新」的分工见上一节。取代是成对操作 —— 新卡出现与旧卡失效必须同时发生, 所以它做成一个命令,而不是「capture 时加个参数」:两步的话,中间失败会留下 「两张卡都有效」的状态,而检索看不出异常。
磁盘上的形态:旧卡文件永远保留,只加两行标记(invalid_at + superseded_by),
新卡多一行反向指针 supersedes。关系写在 frontmatter(真相源)里,所以
index --rebuild 之后关系一模一样地回来 —— 这也是为什么关系里记的是相对路径
而不是 id:id 是 rowid,重建后会重排。
| 语义 | 行为 |
|---|---|
| 默认检索 | 不返回旧卡(只有新卡)。默认视图是「当前事实」的投影,不是「曾经成立过的事实」的堆叠 |
| 旧卡原文 | 照常可取(show <旧 id>、--include-invalid)。取代是「默认不返回」,不是「把内容藏起来」 |
show 旧卡 |
正文前多一行「⚠ 这张卡已于 … 失效,被 … 取代」,并给出回溯用的命令 |
--as-of <日期> |
只看那一天当时有效的卡:那天已存在(created <= 日期)且那天还没失效 |
| 链式取代 | 拒绝(旧卡已被取代过就报错)。否则「A→B、B→C」时 A 的指向取决于操作顺序,历史链会静默断掉 |
--as-of 的三条边界(都写进了测试):
- 只按「天」比较,
--as-of 2026-09-01就是「9 月 1 日那天」。按时刻比较的话, 当天 11:30 写下的卡会因「11:30 > 00:00」被判成当时还不存在 —— 反直觉; created缺失或读不出来的卡按「一直有效」处理 —— 解析失败不该让一张卡凭空消失;- 非法日期明确报错(退出码 2),不静默当成「没传日期」。日期是当字符串比的,
--as-of 昨天不会崩,而是安静地让「存在性过滤」失效 —— 调用方拿到一批看起来 正常的结果,却不知道日期条件根本没生效。这种假成功本项目不接受。
--kind 不给时沿用旧卡的类型:取代默认是「同一件事变了」,类型跟着变会让卡片
悄悄换目录,而目录是路径的一部分。要换类型就显式写。
python memory.py delete 12 # 软删到 .trash/<原相对路径>(可恢复)
python memory.py delete --restore 03-Knowledge/x.md # 移回原位
python memory.py delete --purge 03-Knowledge/x.md # 彻底删除(不可恢复)
python memory.py delete --purge --all # 清空回收站(不可恢复)默认软删:文件移到 <vault>/.trash/<原相对路径>(原目录结构保留,所以恢复不需要
任何额外记录),索引里摘掉这一行,于是检索立刻搜不到。index / index --rebuild
都不会把它捞回来(.trash 被排除出扫描)—— 否则卡片会换个路径复活,
而 delete 返回的却是「成功」。
三条不可越过的边界:
| 边界 | 为什么 |
|---|---|
软删不清 card_stats,--purge 才清 |
软删可恢复,而读取统计是全项目唯一不可重建的数据;真删之后它才变成读不出来的幽灵行 |
--purge 只对回收站里的内容生效 |
传一张活着的卡会被拒绝。这不是靠调用方自觉,而是它唯一能删的位置就是 .trash |
| 批量彻底删除 | 必须显式给 --all |
- 回收站不自动清理。攒到一定量时
delete会回传reminder、stats会显示 回收站张数与占用 —— 提醒归提醒,动手要人来; - 回收站里只有卡片,不放任何辅助文件。所以彻底删除只有两种,都由人显式发起:
按路径逐张(
--purge <路径>)或整体清空(--purge --all)。 没有「只清理超过 N 天的」这种筛选 —— 它的判据是「这张卡什么时候被删的」, 而文件的 mtime 在移动后仍是原卡片的写入时间,没有可靠来源; 为了它去 vault 里新增一个非 Markdown 文件,代价大于收益(已由项目主人决定不做); --restore时目标位置已有卡 → 默认拒绝覆盖(覆盖是不可逆的丢失)。 确认要顶替就加--force:占位的那张会先被移进回收站,所以强制恢复也不销毁任何内容;- 同一路径被删两次时(删掉 → 原地又出现一张同路径的卡 → 再删),第二张在回收站里
另存为
-2,最早那一份逐字节不变。恢复它按它在回收站里的实际路径来。
正文与标题都会扫一遍常见凭据形态:私钥头(-----BEGIN ... PRIVATE KEY-----)、
AWS Access Key ID、GitHub / Slack token、Google API Key、OpenAI 风格 key、JWT、
以及 password = xxx 这类明文赋值。
命中后默认照常写入,只在结果里回传 secrets_found 与 warnings。
需要更严时加 --reject-secrets(MCP 的 memory_capture 传 reject_secrets: true)。
为什么默认不拦:本库的正当用途就包含渗透测试记录,而这类记录里天然会出现密钥、 连接串、凭据 —— 硬拒会把项目本身的用途一起拒掉。这是刻意的取舍,不是漏做。
两条实现上的约束,都是有意为之:
- 告警不复述凭据。只报「命中了哪一类 + 位置区间」,不回显原文 —— 把密钥抄进告警里等于又写了一遍到返回值与日志里,反而扩大暴露面。
- 宁可漏报,不要误报。
~/.ssh/id_ed25519、密钥见 ~/.ssh/xxx、token: 见上一条记录这类引用式写法不会被标记;赋值式还要求值同时含字母与数字 (password: 已改成用密钥登录是说明文字,不是凭据)。误报多起来,告警会被所有人忽略, 那比没有告警更坏。
这是提醒,不是安全边界。真要严格管控凭据请用专门的扫描工具,不要让「顺手的正则」 承担安全职责。
为什么标题撞车要显式回传:同标题不同正文过去会静默多出一个 slug-2.md ——
调用方只看到「写好了」,不知道库里已经有两张同标题的卡,此后检索会同时命中两张,
而没有任何信息能判断该信哪张。现在冲突会出现在返回值里,并且永远不覆盖已有卡片。
想表达「事实变了」不要用 --on-conflict:修正既有事实用 update,
事实已变而旧值仍需留存用 supersede(见 ROADMAP 阶段 3)。
on_conflict 只回答「标题撞了怎么办」这一个问题。
索引只作用于刚写入的这一张卡,不触发全量同步(全量是 O(语料) 的)。
索引失败不回滚 Markdown —— 真相源优先,索引随时可用 index --rebuild 重建。
CLI 的 capture 与 MCP 的 memory_capture 行为一致:两个入口,同一种结果。
- 文件名由标题生成,中文原样保留(如
nginx-站点根目录位置.md) - 重名自动加序号后缀(
-2、-3),不覆盖已有卡片 - 原子写入(临时文件 + 改名),中途失败不会留下半截文件
kind决定归入哪个分类目录:
| kind | 目录 | kind | 目录 |
|---|---|---|---|
system |
00-System |
prompt |
05-Prompts |
project |
02-Projects |
business |
06-Business |
knowledge |
03-Knowledge |
tool |
07-Tools |
content |
04-Content |
mistake |
08-Mistakes |
python memory.py mcpstdio 传输,每行一条 JSON-RPC 2.0 消息。协议版本 2025-06-18 / 2025-03-26 /
2024-11-05(按请求协商,未知版本回落到 2024-11-05)。
stdout 是协议通道,日志一律走 stderr。 往 stdout 多写一个字符就会破坏握手。
| 工具 | 用途 |
|---|---|
memory_search |
检索记忆,返回摘要 + id。参数 query limit(上限 20)kind source as_of |
memory_get |
用 id 取卡片全文(长卡分片返回,见下;已失效的卡会显式标注) |
memory_capture |
写入一条知识并立即索引本卡。参数 title body kind tags |
memory_update |
原地改一张卡(记忆本身写错了用这个)。参数 id + 要改的字段 |
memory_supersede |
写新卡并让旧卡失效(事实变了用这个,旧值仍可回溯)。参数 old_id title body |
memory_delete |
软删一张卡到回收站(可恢复,不是销毁)。参数 id |
memory_stats |
库概览(总数、类型/来源分布、最近更新、回收站张数) |
memory_reindex |
手动补建索引(增量或 rebuild 全量)。正常写入已自动索引,此工具用于索引丢失或外部改动后补建 |
三个生命周期工具对应 CLI 的 update / supersede / delete,共用同一份库层实现 ——
两个入口各写一套的话,迟早在一个细节上分叉,而两边都返回成功。
工具描述是接口的一部分 —— LLM 靠它判断何时调用。所以这三个描述里写清了三件事:
什么时候该用哪个(写错了用 memory_update、变了用 memory_supersede)、
删除是软删可恢复、as_of 用来回答「当时是什么」。
{
"mcpServers": {
"memory-agent": {
"command": "python",
"args": ["/path/to/memory-agent/memory.py", "mcp"]
}
}
}python tests/test_mcp.py覆盖握手、版本协商、工具列表、五个工具调用、错误码(-32700 / -32601 / isError)、 采集端闭环(写入 → 幂等 → 索引 → 检索到,隔离在临时 vault 中运行), 以及 stdout 纯净性 —— 逐行校验输出全部是合法 JSON-RPC。
SQLite FTS5 的内置分词器都不能用于中文。 本机实测(SQLite 3.53.1):
| 查询词 | 字数 | trigram |
unicode61 |
bigram 预分词 |
|---|---|---|---|---|
| 私钥 | 2 | 0 | 0 | 1 |
| 阿里云 | 3 | 1 | 1 | 1 |
ecs-prod |
8 | 语法报错 | 语法报错 | 1 |
unicode61把连续中文当作单个 token(「登录使用私钥」是一个词),搜不到子串。trigram要求查询至少 3 字符,中文词多为 2 字,因此大量漏召回。
做法:入库前把中文按 2 字滑窗切分(阿里云 → 阿里 里云),英文整词小写化。
见 mcore/tokenize.py。
cards_fts 用的是 tokenize='unicode61',所以预分词的
结果会被 FTS5 再切一遍 —— - _ . / 都是分隔符。用 fts5vocab 查实际
词表可以确认:id_ed25519 存进去是 id + ed25519 两个 token,不是一整串。
所以「保留 _ . / - 使标识符保持完整」这个说法不成立 —— 它只是让查询侧把
整串转成相邻短语,因此整串仍能命中;但搜 id 同样会命中。
另注:FTS5 的 MATCH 语法中 - 会被解析为列名过滤,含连字符的词必须加引号。
tokenize.to_query_expr() 已统一处理。
匹配档位从精确到宽松,前一档零召回才降级 —— 保证精确匹配的既有行为不被 放宽匹配污染:
| 档位 | 含义 | matched |
|---|---|---|
| 1 | AND 精确:全部词整词命中 | all |
| 2 | AND 前缀:全部词前缀命中 | all-prefix |
| 3 | OR 精确:任一整词命中 | any |
| 4 | OR 前缀:任一前缀命中 | any-prefix |
为什么需要前缀档:FTS5 的 MATCH 是整词匹配,正文里写了 sqlite3 就搜不到
sqlite,写了 requests 就搜不到 request。代码类内容里这种后缀差异极常见。
前缀只对长度 ≥ 2 的词生效 —— 实测 "a"* 在 49 张卡的库里命中 45 张,单字前缀纯噪音。
注意前缀是单向的:搜 sqlite 能命中 sqlite3,反过来搜 sqlite3 命中不了
sqlite(* 只能匹配「以查询词为前缀的 term」)。
- BM25 排序。SQLite 的
bm25()返回负值,对外取负使「越大越相关」。 - 已知局限(都是固有特性,不是缺陷):
- 要求字面一致。
sqlite能靠前缀档命中sqlite3,但userById搜不到getUserById—— 片段在词中间,前缀够不着。 - 词汇鸿沟无解。查询说「本地装了什么模型」,卡片写「本机 Ollama 已装模型清单」, 字面零重叠,任何词法手段都救不回来。这是向量检索要解决的问题,见「扩展」。
- 要求字面一致。
曾给 OR 档加过「先按命中词数重排、再按 BM25」的逻辑,动机是怀疑 OR 档 BM25 失真。 基准实测证伪并发现它是负优化:唯一受影响的查询「本地装了什么模型」,目标卡从 第 4 名被推到第 9 名,封顶 P@5 由 66.7% 降到 55.6%,其余查询无变化。已移除。 不要再加回来,除非基准显示正收益。
python memory.py bench # 人类可读
python memory.py bench --json # 机器可读
python memory.py bench --save base.json # 存基线
python memory.py bench --baseline base.json # 对比;任一指标退化则退出码 1真值文件在数据目录(~/.memory_agent/bench_queries.json),不在仓库里 ——
它包含本机卡片路径。实现见 bench/retrieval_quality.py。
三条硬规定,每一条都对应一次踩过的坑:
- 真值按
rel_path记录,不按整数 id。 用 id 记过一次,索引重建后 rowid 重排, 真值全部错位(卡 12 从「本机 Ollama」变成「渗透测试复盘」),据此得出的结论是假的。 - 真值是一组「可接受卡」,不是一张「标准卡」。 只认一张会把「返回了另一个同样 正确的答案」误判为失败。
- 真值为空的查询不计入精确率。 语料里没有这个词,返回空才是正确行为。
指标用封顶精确率:分母取 min(K, 相关卡数)。「相关卡只有 2 张,取满 top5 也填不满」
是相关卡用完了,不是返回了噪音 —— 不封顶的话这个区别看不出来。
本机实测(49 张卡,19 条查询,2026-09-12):
| 组 | 查询类型 | 条数 | 首位可接受 | 封顶 P@5 | 噪音率@5 | 实际档位 |
|---|---|---|---|---|---|---|
| A | 关键词(sqlite / 阿里云 / 运维…) |
11 | 100% | 100% | 0% | 全部 all,从不降级 |
| B | 自然语言(「本地装了什么模型」…) | 4 | 50% | 66.7% | 33.3% | 全部 any |
结论:关键词查询零噪音,且根本走不到 OR 档;噪音只出现在自然语言查询里。
另有 4 条查询(sqlite3/tokenize/bm25/mcp)在语料里不存在,返回空是正确的。
检索层抽象为 mcore.search.Searcher 协议。加向量检索时新增一个实现即可,
CLI 与调用方无需改动;cards.embedding 列已预留。
若用云端 API 计算嵌入向量,卡片内容会发送给模型厂商。必须使用本地模型。 本库含服务器信息、凭据、渗透测试记录,内容不能出网。
什么时候才值得上向量(满足任一):
- 卡片数 > 1000,关键词召回的候选池开始失控;
- 反复出现「明明记过但搜不到」,且措辞差异属于同义/近义而非词形差异 (词形差异前缀档已经覆盖);
- 需要跨语言检索(中文查询找英文卡片)。
在那之前,四档降级已经覆盖绝大多数场景,而向量的成本是实打实的:
本地模型文件、ONNX runtime 或 sqlite-vec 依赖、每次 capture 都要算嵌入、
重建索引显著变慢。接口已就位,推迟的代价≈0。
FTS5 trigram 分词器(SQLite 3.34+ 内置,零依赖,看着像个便宜的中间档)。
本机实测否决,三条理由:
| 维度 | 实测结果 |
|---|---|
| 2 字中文词 | 全部 0 召回 —— 词云/分词/快照/运维/记忆 一个都搜不到(trigram 要求查询 ≥ 3 字符) |
| 自然语言查询 | 全部返回空,比现状更差 |
| 唯一优势 | 英文中段碎片(2551 命中 ed25519),但前缀档已覆盖 ed2551 → ed25519 这类常见情况 |
| 索引体积 | 0.88x bigram,没有优势 |
对一个中文优先的记忆库,第一条就是致命的。
同义词/别名表(纯词法,零依赖)。用人工编写的别名表扩展查询后重测, B 类 4 条查询仍然 0/4。词法路线到此为止 —— 而且那张表是「看过测试查询之后」 写的,属于对测试集过拟合,真实泛化只会更差。
-
工具描述里要求传实体名/标识符,不要传整句问句。 实测关键词查询零噪音、 首位 100% 正确,而自然语言查询噪音率 33% —— 消灭问句等于消灭噪音来源。 这与项目「蒸馏交给调用方」的原则同源。
-
让置信度显式化:
Hit带matched档位,调用方看到any/any-prefix就知道这批结果不可信,可改用关键词重试。放宽档还会带上查询词覆盖率(
coverage)与一句低置信提示,例如 「置信度偏低:这是放宽档(OR)结果,只命中了部分查询词(覆盖率 33%)」。 精确档(all/all-prefix)覆盖率恒为 1.0,因此不显示 —— 写出来只是噪音。⚠️ coverage只用于展示与提示,绝不参与排序。本项目曾给 OR 档加过 「按命中词数重排」,bench 实测证伪且是负优化(目标卡从第 4 名掉到第 9 名, 封顶 P@5 由 66.7% 降到 55.6%)。加字段 ≠ 可以用它排序,这两件事必须严格区分。tests/test_coverage.py里有直接证据:排序层的行序与检索结果顺序逐条相等。
Python 3.10+(已在 3.13、3.14 验证)。需 SQLite 支持 FTS5,Python 自带版本均满足。