内容
<p align="center">
<img src="docs/banner.svg" alt="RE-call — 基于检索增强的自我记忆" width="900">
</p>
<p align="center">
<b>为人工智能代理的记忆提供值得信赖的检索。</b><br>
每次检索都带有置信度、来源和有效性 — 或者诚实的回答是 <i>"我不知道。"</i>
</p>
<p align="center">
<a href="https://github.com/GiulioDER/RE-call/actions/workflows/ci.yml"><img src="https://github.com/GiulioDER/RE-call/actions/workflows/ci.yml/badge.svg" alt="持续集成"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="许可证:MIT"></a>
<img src="https://img.shields.io/badge/python-3.11%2B-blue" alt="Python 3.11+">
<img src="https://img.shields.io/badge/PostgreSQL-16%2F17%20%C2%B7%20pgvector-336791" alt="PostgreSQL + pgvector">
<img src="https://img.shields.io/badge/tests-349%20·%20real%20pgvector-brightgreen" alt="349 测试">
</p>
<p align="center">
<a href="#see-it-in-one-screen">查看</a>
·
<a href="#what-is-actually-verified">验证内容</a>
·
<a href="#production-posture">生产环境准备</a>
·
<a href="#quickstart--2-minutes-no-api-key">快速开始</a>
·
<a href="#what-this-does-not-do">限制</a>
</p>
---
**大多数 RAG 返回最近的向量匹配结果。但这往往是错误的答案。**
长期运行的代理会积累记忆 — 决策、已关闭的实验、事件笔记 — 然后它会**重新审视已解决的决策**,**在记忆无法填补的空白中产生幻觉**,并**基于不再真实的事实进行构建**。问题在于:当你撤销决策时,*过时的* 记忆往往是 **整个结果中余弦值最高的匹配结果**。相似性搜索会自信地提供它。
RE-call 是一个检索引擎,它**诚实地表达自己对未知内容的态度**。它返回**判决 + 置信度 + 来源**,而不仅仅是相似性 — 降低被取代或过期的记忆优先级,并优先考虑明确的**放弃**,而不是自信的噪音。
## 在一个屏幕上查看
<p align="center">
<img src="docs/superseded-catch.svg" width="740" alt="recall demo: 过时的 rate-limit 记忆具有最高的余弦值(0.806),但被标记为已取代并降级到当前记忆之下;一个无法回答的查询返回明确的 ABSTAIN。">
</p>
<details>
<summary>同一运行,以文本形式显示</summary>
```text
$ python -m recall.cli demo
[ok] query='客户端每秒可以发送多少请求?'
ok conf=1.00 cos=0.784 rate_limits_v2.md '# API 速率限制(修订版)'
superseded conf=1.00 cos=0.806 rate_limits_v1.md → use rate_limits_v2 '# API 速率限制 … 限制为 100'
[ABSTAIN · gap] query='我们如何在火星上处理企鹅?'
reason: 没有命中超过校准的置信度阈值(可能的语料库空白)
```
</details>
看看余弦值。**过时** 的记忆得分 **更高(0.806)**,而当前的记忆得分较低 — 普通向量搜索会返回它,而代理会基于一个不再存在的限制进行构建。RE-call 标记它为 `superseded`,指向其继任者,并将*当前*记忆放在首位。当记忆真正没有答案时,它会如实地说。
## 验证内容
以下每个标题数字都是经过测量的,每个数字都有其限制。如果无法支持某个声明,则会撤回,而不是软化 — 撤回的声明也会被列出,因为没有它们的声明表是营销。
| 声明 | 测量 | 限制 |
|---|---|---|
| **Supersession beats similarity** | Superseded-trust rate **0.00**, 95% Wilson **[0.00, 0.02]**, n=250, against a baseline of **1.00** — plain search returns the stale memory *every time* on adversarially-worded queries | Generated corpus; the successor/abstain columns on it are **not** meaningful (below) |
| **Abstention is calibrated, not guessed** | On the real corpus: threshold **0.728 ± 0.042** over 4 index rebuilds, false-abstain **0.015**, gap false-confidence **0.000** | Needs ≥ ~20 labelled samples; below that the rule loses its outlier robustness |
| **Timestamps cannot replace declared supersession** | "Trust the newest relevant hit", steelmanned, still trusts the stale memory **83–100%** of the time | — |
| **Reranking rescues a weak embedder** | Hybrid + cross-encoder lifts MRR **0.63 → 1.00** offline | Situational: a strong embedder already saturates this corpus |
| **Fine-tuning pays only for a vocabulary gap** | **+0.00** on a rich corpus; **0.31 → 0.55** held-out MRR on opaque jargon → [study](docs/RAG_TRAINING_STUDY.md) | Measure your gap first |
| **Near-misses need a judge, not a threshold** | QNLI stage cuts near-miss false-confidence **1.00 → 0.60**, **0.80 → 0.50**, same judge across embedders → [study](docs/ENTAILMENT_SUPERSESSION_STUDY.md) | Judge-alone *degrades* far-gap detection — the two stack, neither replaces the other |
完整的方法论、每个嵌入器的表格和负面结果 → **[results/FINDINGS.md](results/FINDINGS.md)**。
设计原理和每个保护措施背后的推理 → **[docs/WRITEUP.md](docs/WRITEUP.md)**。
### 已撤回的声明
以前版本的此文件发布了以下内容。它们在重新测量后没有存活下来:
- **"FCR @calibrated 0.00"** — 阈值是根据相同的样本拟合和评分的。在可分离的数据上,这算术上等于 0.00。现在经过交叉验证,拟合规则被完全**替换**,因为它允许 **20.5%** 的无法回答的查询通过。
- **Coverage and abstention accuracy on generated corpora** — “无法回答”的查询是一个可回答的查询加上一个无意义的后缀,因此没有任何东西可以区分它们。重建为真正的非主题问题;*文档* 级别的退化仍然存在且被声明为未测量。
- **"6× 更快的增量重新索引"** — 低估了。 在 Linux 服务器上测量,它是 **33×**。
- **Real-corpus recall@5 of 0.945** — 使用文档 *标题* 作为查询,这是已知项检索。针对 110 个手工标记的问题,措辞方式为人员实际提问,命中@5 为 **0.33**。警告始终被打印;现在它有一个数字,它是系统最弱的测量部分。 → [FINDINGS §7](results/FINDINGS.md)
## 生产环境准备
"企业级" 不是一个单一的属性,因此这里是逐项列出的版本 — 在真正的宿主(PostgreSQL 17,pgvector 0.8.2,Python 3.12,作为 **非特权** 角色连接)上验证,而不仅仅是在笔记本电脑上。
| 属性 | 状态 | 证据 |
|---|---|---|
| **多租户** | ✅ `tenant_id` 在每一行和每个查询上,加上行级安全策略(`ENABLE` + `FORCE`) | 验证为 `NOSUPERUSER NOBYPASSRLS` 角色 — 超级用户绕过 RLS,因此作为超级用户测试会虚假通过 |
| **并发性** | ✅ 异步 MCP 工具 + `psycopg_pool`;服务器以前一次只接受 **一个** 请求 | FastMCP 等待异步工具并内联调用同步工具 — 没有线程卸载 |
| **超时 / 弹性** | ✅ `statement_timeout`, `connect_timeout`, 窄重试和重试 | 重试拒绝重新运行 `QueryCanceled`,否则会逃避触发的超时 |
| **安全态势** | ✅ 发布默认凭证时关闭;索引根限制在 3.11/3.12 上生存的符号链接 | `pathlib` 仅在 3.13 中获得 `recurse_symlinks` |
| **可观察性** | ✅ `logging` (文本/JSON),中止、判决和重新连接的计数器和延迟百分位数;通过 MCP `recall_stats` 工具显示 | 库永远不会附加处理程序 — 那是主机的任务 |
| **增量索引** | ✅ 内容哈希跳过、有界内存批处理写入、修剪磁盘删除的文件 | 5,100 个块 / 1,120 个文件:完整 **7.4 s**,不变重新索引 **0.22 s** |
| **规模特性** | ✅ 在 **50,600 个块** 处测量:recall@5 1.00 过滤和未过滤,搜索 p50/p95/p99 | 模板化文本;绝对检索质量是乐观的 |
| **真实语料库操作** | ✅ 794 篇手写备忘录 → 6,491 个块,p50 **78 ms** | 在此大小下工作;查看检索行以了解其效果 |
| **检索质量,真实问题** | ⚠️ **hit@5 0.33** [0.21, 0.47],n=46,在 110 个手工标记的问题上 | 标题作为查询得分 0.945 — 代理隐藏了三分之二的失败。未使用重新排序器测量 |
| **身份验证** | ❌ **未实现** — 标准输入 MCP 不携带传输身份 | [#9](https://github.com/GiulioDER/RE-call/issues/9) |
| **模式迁移** | ❌ 运行时 `CREATE TABLE IF NOT EXISTS`,无版本升级路径 | 租户前表格 *被* 原地迁移,带有测试 |
| **HA / 复制** | ❌ 超出范围 — 这是您 Postgres 上的库 | — |
## 工作原理
```mermaid
flowchart LR
Q([查询]) --> E[嵌入]
E --> D[密集 · pgvector 余弦]
Q --> S[稀疏 · Postgres 全文搜索]
D --> F[互惠排名融合]
S --> F
F --> R[交叉编码器重新排序]
R --> G{信任层}
G --> O([每个命中的判决 + 置信度 + 来源,或 ABSTAIN])
```
密集语义搜索和稀疏关键词搜索分别检索候选项;**互惠排名融合** 将它们合并,一个交叉编码器重新排序,**信任层** 在命中达到代理之前判断每个命中 — 取代、有效性窗口、校准置信度 — 。
## 快速开始 · 2 分钟,无需 API 密钥
```bash
docker compose up -d --wait # PostgreSQL + pgvector
pip install -e ".[fastembed]" # 本地嵌入,无需 API 密钥
python -m recall.cli demo # 索引语料库/并运行示例查询
```
## 使用
```bash
python -m recall.cli index ./notes # 索引一个 markdown 文件夹
python -m recall.cli search "我们决定了什么关于缓存?"
python -m recall.cli lint ./notes # 取代图健康检查(无需数据库)
python -m recall.cli lint ./notes --fix # 提出缺少的边(干运行)
python -m recall.cli check ./notes/new-memo.md --strict # 编写时门控,用于 pre-commit 钩子
```
```python
from recall.store import PgVectorStore
from recall.embeddings import FastEmbedEmbedder
from recall.trust import trusted_search
emb = FastEmbedEmbedder()
with PgVectorStore(DSN, dim=emb.dim, tenant="acme", pool_size=8) as store:
store.ensure_schema()
result = trusted_search(store, emb, "速率限制是多少?")
if result.abstained:
... # 说你不知道 — 不要从这些命中中回答
for hit in result.hits:
hit.verdict # ok | superseded | expired | not_yet_valid | low_confidence | …
hit.confidence # 校准;0.5 恰好位于放弃边界上
hit.validity.superseded_by
```
将 `RECALL_DSN` 指向任意 Postgres。
> **两个操作注意事项。** 测试套件 **DROPs 表**,因此它读取一个单独的 `RECALL_TEST_DSN`,而永远不会读取 `RECALL_DSN` — 导出您的真实 DSN 并运行 `pytest` 不能触及它。并且 MCP 服务器 **如果 `RECALL_DSN` 携带内置 `recall:recall` 凭证,则拒绝启动**,而针对非本地主机;设置真实密码,或 `RECALL_ALLOW_INSECURE_DSN=1` 以有意接受风险。
> **多租户。** 设置 `RECALL_TENANT` 或 `PgVectorStore(tenant=...)`。RLS 在数据库中强制执行相同的边界,因此忘记的 `WHERE` 返回空值而不是另一个租户的记忆。 ⚠️ **超级用户或 `BYPASSRLS` 角色绕过 RLS** — 包括此存储库的 `docker-compose.yml` 中的角色。以非特权角色连接,或第二个层是装饰;`store.check_rls_effective()` 告诉你是哪个,服务器在启动时发出警告。
## 与 Claude(MCP)一起使用
```json
{ "mcpServers": { "recall": {
"command": "python", "args": ["-m", "recall_mcp.server"],
"env": { "RECALL_DSN": "postgresql://...", "RECALL_TENANT": "acme" } } } }
```
三个工具:`recall_search`(判决 + 置信度 + 来源,或明确放弃),`recall_index`,`recall_stats`(大小,新鲜度和过程指标)。完整指南 → [docs/USING_WITH_CLAUDE.md](docs/USING_WITH_CLAUDE.md)。
## 它不具备的功能
明确说明,因为该库旨在防止的失败模式是自信的过度延伸。
- **无身份验证。** 任何可以访问 MCP 服务器的客户端都可以获得该租户的内存。 租户被强制执行;身份未建立。 → [#9](https://github.com/GiulioDER/RE-call/issues/9)
- **有效性由作者而非推断而来。** 在一个真实的 792 备忘录语料库中,**2** 备忘录声明了 `supersedes:` 而 **60** 仅在正文中描述了关闭。`recall lint --fix` 被构建来弥补这一差距,并在审查后可以安全地声明 **zero**:叙述 vs 声明,部分 vs 整体,增强 vs 替换对模式不可见,对作者来说很明显。 它作为 **审查辅助工具** 交付,`recall check` 将问题移到编写时。 → [#29](https://github.com/GiulioDER/RE-call/issues/29)
- **生成语料库上的继任者和放弃准确性未测量。** 每个合成文档都是同一个句子,具有不同的不透明令牌,因此这些列测量令牌区分度,而不是信任层。STR、延迟和规模数字不受影响。
- **间隙检测受嵌入器限制。** 如果嵌入器很弱,则没有阈值可以将可回答和无法回答的问题分开 — 测量,而不是假设。
- **近似最近邻检索未调优。** `hnsw.ef_search` 保留其默认值,而 HNSW 构建 *不确定* 测量可校准性。 → [#11](https://github.com/GiulioDER/RE-call/issues/11)
## 工程
**349 测试,3 次跳过。** 数据库触摸测试在真实的 pgvector 容器上运行 — 无模拟数据库。CI 运行 `ruff`,PostgreSQL 套件和 `pip-audit` 在已签入的 `uv.lock` 上,作为门控而不是报告。
测试被编写为出于正确的原因而失败。具有代表性的样本:
- RLS 测试连接为一个 **无法绕过 RLS** 的角色,因为作为超级用户它们将通过测试而非测试任何内容;
- 跨租户测试断言其他租户的行 **存在**,然后检查它不可见,因此悄悄失败的写入不能使其通过;
- 取代缓存测试计算实际表格扫描,因此“修复”悄悄 *重新扫描每次搜索* 将被捕获;
- 指标测试断言计数器在 **实际检索路径** 上移动 — 永远报告零的仪表盘被认为意味着“没有任何问题”。
在真实语料库和真实服务器上运行库也发现了几个缺陷,每个缺陷都有回归测试引用导致它的输入:一个文件中的单个 NUL 字节中止 792 文件的索引;每个声明的取代边都因 *格式* 而失败;五个测试编码开发人员的自身环境,在正确配置的主机上失败。
## 升级到 0.5.0
**块表增加了一个 `tenant_id` 列,其主键变为 `(tenant_id, id)`。** `ensure_schema()` 在现有表的原位执行此迁移,并将现有行分配给 `default` 租户,这也是默认的 `tenant=` — 因此单个租户的部署在未被注意到时升级。
密钥必须更改:块 ID 源自文件路径,因此两个租户索引相同的布局会产生 *相同的 ID*,而在旧的单列密钥下,一个租户的重新索引会悄悄覆盖另一个租户的行。
两个行为变化值得了解后再升级:
- **放弃阈值拟合方式不同**(中隙而非最低可回答样本)。它放弃 *更多*,并且在测量数据上更准确 — 无法回答的查询的假阳性从 0.205 下降到 0.000,适用于 1.5% 的可回答查询。重新运行 `recall calibrate` 并重新检查您固定的任何阈值。
- **`supersedes:` 匹配更宽容。** `name`、`name.md`、`[name]` 和 `[[name]]` 现在都解析为同一个文档,因此曾经静默悬空的边可能会开始应用。那是意图 — 在参考语料库上,它将工作边从 0 增加到 2 — 但这意味着曾经作为 `ok` 提供服务的内存现在可以正确返回 `superseded`。
## 复制
```bash
make eval # 消除 + 信任 + 近似匹配 → results/
python -m recall.eval.scale --embedder hashing --filler 50000 # 规模 + 延迟
```
## 许可证
MIT — 参见 [LICENSE](LICENSE)。