用 SQLite FTS5 + BM25 复现 Agent 会话记忆检索

验证 context-mode 采用的思路:把编码 Agent 的会话事件写入 SQLite FTS5,用 BM25 排序并以 RRF 融合 porter 与 trigram 两路检索,上下文压缩后按需取回少量相关记录。

已验证 Python 3.12.15 耗时 0.2s 运行于 2026-10-19 15:33 UTC
结论

RRF Top-3 recall = 0.88 (7/8)

以上内容从实际运行输出中提取,完整输出见下方。

python main.py
SQLite 3.45.1,事件数 25

Q: which database did the user choose
   期望 #0: User decided to use PostgreSQL instead of MySQL for the orders service
   RRF Top-1 #12  porter=✗  trigram=✗  rrf=✗
Q: how should email addresses appear in logs
   期望 #6: User said: never log customer email addresses, mask them as ***@domain
   RRF Top-1 #6  porter=✓  trigram=✓  rrf=✓
Q: payment gateway rate limit
   期望 #10: Payment gateway sandbox returns HTTP 429 when more than 10 requests pe
   RRF Top-1 #11  porter=✓  trigram=✓  rrf=✓
Q: feature flag for upsert
   期望 #18: User wants the feature behind flag ORDERS_UPSERT_V2, default off in pr
   RRF Top-1 #18  porter=✓  trigram=✓  rrf=✓
Q: refund retry backoff
   期望 #24: Edited src/payments/refund.py: retry up to 4 times with exponential ba
   RRF Top-1 #23  porter=✓  trigram=✓  rrf=✓
Q: how to deploy to staging
   期望 #21: Staging deploy is triggered by pushing a tag that matches staging-*
   RRF Top-1 #21  porter=✓  trigram=✓  rrf=✓
Q: upsert test failed duplicate
   期望 #3: pytest tests/test_orders.py failed: test_upsert_idempotent AssertionEr
   RRF Top-1 #3  porter=✓  trigram=✓  rrf=✓
Q: timeout for payment
   期望 #9: Edited config/settings.toml: raised http client timeout from 5s to 15s
   RRF Top-1 #9  porter=✓  trigram=✓  rrf=✓

Top-3 命中率:
  porter   7/8
  trigram  7/8
  rrf      7/8

上下文体积(粗略估算):全量回放 ≈ 448 tokens,单次检索 Top-3 平均 ≈ 46 tokens(约 10%)
说明:事件越多,全量回放线性增长,而检索 Top-K 基本不变。
未命中的问题(词面不重合,属于纯词法检索的固有局限):
  - which database did the user choose

RESULT: RRF Top-3 recall = 0.88 (7/8)

目的

今天的开源项目 context-mode 的一个核心机制是:把编码 Agent 的会话事件(文件修改、git 操作、用户决定)写入 SQLite,用 FTS5 建全文索引;上下文被压缩之后,Agent 不必回放全部历史,只按需检索几条相关记录。它的检索组合了 Porter 词干和 trigram 两种匹配,再用 Reciprocal Rank Fusion(RRF)融合。

本实验用纯 Python 标准库做一个最小复现,回答两个问题:

  1. 只靠词法检索(不用向量模型),能不能找回「压缩后 Agent 需要的那条记录」?
  2. 相比全量回放历史,检索能省下多少上下文?

步骤

  1. 构造 25 条模拟的会话事件(决定、编辑、测试、git、备注)
  2. 建两张 FTS5 虚拟表:porter unicode61 分词与 trigram 分词
  3. 对 8 个「压缩后可能会问的问题」分别做 BM25 检索,再用 RRF(k=60)融合
  4. 统计 Top-3 命中率,并粗略估算上下文体积(按 4 字符 ≈ 1 token,仅用于数量级对比)

运行:

python main.py

预期结果

  • 8 个问题中,RRF Top-3 至少命中 6 个,程序以 RESULT: RRF Top-3 recall = ... 结尾
  • 单次检索取回的内容只占全量历史的一小部分
  • 可能出现词面完全不重合的问题(例如问「database」而记录里写的是「PostgreSQL」)检索不到,这是纯词法检索的固有局限,正是很多工具会再叠加语义检索或同义词扩展的原因

可以继续尝试

  • 增加到几百条事件,观察全量回放与 Top-K 体积的差距如何拉开
  • 给查询加一个同义词表(database → postgresql/mysql),看未命中的问题能否找回
  • 调整 RRF 常数 k,或只用单路检索,对比命中率

内网复现

  • 无需任何第三方依赖,只用 Python 3.9+ 自带的 sqlite3 模块,无需联网,可直接在内网运行
  • 需要 SQLite 编译时启用 FTS5(主流发行版的 Python 默认已启用),且 trigram 分词器要求 SQLite ≥ 3.34
  • 把 main.py 拷贝到内网机器即可运行

可以先用下面的命令检查环境,输出 ok 即可:

python -c "import sqlite3; sqlite3.connect(':memory:').execute(\"create virtual table t using fts5(x, tokenize='trigram')\"); print('ok')"

如果报 no such module: fts5 或不认识 trigram,说明系统自带的 SQLite 太旧,可以换用 Python 官方安装包或 conda 环境中的 Python。

代码

main.py158 行
"""用 SQLite FTS5 + BM25 + RRF 复现「会话记忆检索」的核心思路。

背景:context-mode 等工具把编码 Agent 的会话事件(改了哪些文件、执行了哪些
git 操作、用户做了哪些决定)写进 SQLite,并用 FTS5 建全文索引;上下文被压缩后,
Agent 只需按需检索几条相关记录,而不是把全部历史塞回上下文。

本实验只用 Python 标准库(sqlite3),完全离线:
  1. 构造一段模拟的会话事件日志
  2. 分别用 porter(词干)和 trigram(三字符片段)两种分词器建 FTS5 索引
  3. 用 BM25 排序,再用 Reciprocal Rank Fusion (RRF) 融合两路结果
  4. 对一组「压缩后 Agent 可能会问的问题」检查 Top-3 是否命中正确事件
  5. 粗略对比「全量回放历史」与「只取 Top-3」的上下文体积
"""
from __future__ import annotations

import sqlite3
import sys

# ---------------------------------------------------------------- 1. 模拟会话事件
EVENTS = [
    ("decision", "User decided to use PostgreSQL instead of MySQL for the orders service because of JSONB support"),
    ("edit", "Edited src/orders/repository.py: added upsert_order() using INSERT ... ON CONFLICT"),
    ("git", "git checkout -b feature/order-upsert"),
    ("test", "pytest tests/test_orders.py failed: test_upsert_idempotent AssertionError duplicate rows"),
    ("edit", "Edited src/orders/repository.py: fixed conflict target to (tenant_id, external_id)"),
    ("test", "pytest tests/test_orders.py passed: 14 passed in 2.31s"),
    ("decision", "User said: never log customer email addresses, mask them as ***@domain"),
    ("edit", "Edited src/common/logging.py: added mask_email() filter to the root logger"),
    ("git", "git commit -m 'orders: idempotent upsert keyed by tenant and external id'"),
    ("edit", "Edited config/settings.toml: raised http client timeout from 5s to 15s for the payment gateway"),
    ("note", "Payment gateway sandbox returns HTTP 429 when more than 10 requests per second are sent"),
    ("edit", "Edited src/payments/client.py: added token bucket rate limiter, 8 requests per second"),
    ("decision", "User prefers Chinese comments in new code but English identifiers"),
    ("test", "ruff check src/ found 3 issues: unused import in src/payments/client.py"),
    ("edit", "Edited src/payments/client.py: removed unused import json"),
    ("git", "git rebase origin/main resolved conflict in src/common/logging.py"),
    ("note", "CI pipeline uses Python 3.12 and runs migrations with alembic upgrade head"),
    ("edit", "Edited migrations/versions/20261019_add_external_id.py: unique index on orders(tenant_id, external_id)"),
    ("decision", "User wants the feature behind flag ORDERS_UPSERT_V2, default off in production"),
    ("edit", "Edited src/orders/service.py: read ORDERS_UPSERT_V2 from feature flag client"),
    ("test", "pytest -q passed: 212 passed, 3 skipped in 41.7s"),
    ("note", "Staging deploy is triggered by pushing a tag that matches staging-*"),
    ("git", "git push origin feature/order-upsert"),
    ("note", "Reviewer asked to add a retry with exponential backoff around payment refund calls"),
    ("edit", "Edited src/payments/refund.py: retry up to 4 times with exponential backoff 0.5s,1s,2s,4s"),
]

# 「压缩之后」Agent 可能要回答的问题 → 期望命中的事件下标
QUERIES = [
    ("which database did the user choose", 0),
    ("how should email addresses appear in logs", 6),
    ("payment gateway rate limit", 10),
    ("feature flag for upsert", 18),
    ("refund retry backoff", 24),
    ("how to deploy to staging", 21),
    ("upsert test failed duplicate", 3),
    ("timeout for payment", 9),
]

K_RRF = 60      # RRF 常数,常见取值
TOP_K = 3


def build_index(conn: sqlite3.Connection) -> None:
    conn.execute("CREATE TABLE events(id INTEGER PRIMARY KEY, kind TEXT, body TEXT)")
    conn.execute("CREATE VIRTUAL TABLE ev_porter USING fts5(body, content='events', content_rowid='id', tokenize='porter unicode61')")
    conn.execute("CREATE VIRTUAL TABLE ev_tri USING fts5(body, content='events', content_rowid='id', tokenize='trigram')")
    for i, (kind, body) in enumerate(EVENTS):
        conn.execute("INSERT INTO events(id, kind, body) VALUES (?,?,?)", (i, kind, body))
    conn.execute("INSERT INTO ev_porter(ev_porter) VALUES('rebuild')")
    conn.execute("INSERT INTO ev_tri(ev_tri) VALUES('rebuild')")


def fts_query(words: list[str], table: str) -> str:
    if table == "ev_tri":
        # trigram 需要 ≥3 个字符的片段;用 OR 组合每个词
        terms = [f'"{w}"' for w in words if len(w) >= 3]
    else:
        terms = [w for w in words if w.isalnum()]
    return " OR ".join(terms) or '""'


STOPWORDS = {"which", "did", "the", "how", "should", "appear", "in", "to", "for", "what", "a", "is"}


def search(conn, table: str, q: str, limit: int = 10) -> list[int]:
    words = [w.lower() for w in q.split() if w.lower() not in STOPWORDS]
    sql = f"SELECT rowid FROM {table} WHERE {table} MATCH ? ORDER BY bm25({table}) LIMIT ?"
    return [r[0] for r in conn.execute(sql, (fts_query(words, table), limit))]


def rrf(*rankings: list[int]) -> list[int]:
    score: dict[int, float] = {}
    for ranking in rankings:
        for rank, doc in enumerate(ranking, start=1):
            score[doc] = score.get(doc, 0.0) + 1.0 / (K_RRF + rank)
    return sorted(score, key=lambda d: -score[d])


def approx_tokens(text: str) -> int:
    # 粗略估算:英文约 4 字符 ≈ 1 token。只用于数量级对比,不代表任何模型的真实计数。
    return max(1, len(text) // 4)


def main() -> int:
    conn = sqlite3.connect(":memory:")
    build_index(conn)
    print(f"SQLite {sqlite3.sqlite_version},事件数 {len(EVENTS)}\n")

    hits = {"porter": 0, "trigram": 0, "rrf": 0}
    for q, expected in QUERIES:
        p = search(conn, "ev_porter", q)
        t = search(conn, "ev_tri", q)
        fused = rrf(p, t)
        res = {"porter": p[:TOP_K], "trigram": t[:TOP_K], "rrf": fused[:TOP_K]}
        marks = []
        for name, top in res.items():
            ok = expected in top
            hits[name] += ok
            marks.append(f"{name}={'✓' if ok else '✗'}")
        print(f"Q: {q}")
        print(f"   期望 #{expected}: {EVENTS[expected][1][:70]}")
        print(f"   RRF Top-1 #{fused[0] if fused else '-'}  " + "  ".join(marks))

    n = len(QUERIES)
    print("\nTop-3 命中率:")
    for name in ("porter", "trigram", "rrf"):
        print(f"  {name:<8} {hits[name]}/{n}")

    full = sum(approx_tokens(b) for _, b in EVENTS)
    per_query = []
    for q, _ in QUERIES:
        fused = rrf(search(conn, "ev_porter", q), search(conn, "ev_tri", q))[:TOP_K]
        per_query.append(sum(approx_tokens(EVENTS[i][1]) for i in fused))
    top3 = sum(per_query) / len(per_query)
    print(f"\n上下文体积(粗略估算):全量回放 ≈ {full} tokens,单次检索 Top-3 平均 ≈ {top3:.0f} tokens"
          f"(约 {top3 / full:.0%})")
    print("说明:事件越多,全量回放线性增长,而检索 Top-K 基本不变。")

    misses = [q for q, e in QUERIES if e not in rrf(search(conn, "ev_porter", q), search(conn, "ev_tri", q))[:TOP_K]]
    if misses:
        print("未命中的问题(词面不重合,属于纯词法检索的固有局限):")
        for q in misses:
            print(f"  - {q}")

    recall = hits["rrf"] / n
    print(f"\nRESULT: RRF Top-3 recall = {recall:.2f} ({hits['rrf']}/{n})")
    if recall < 0.75:
        print("RRF 融合检索命中率低于 0.75,实验未达到预期", file=sys.stderr)
        return 1
    if hits["rrf"] < max(hits["porter"], hits["trigram"]):
        print("STATUS: partial")
        print("说明:融合结果不如单路检索,需要调整查询构造或 RRF 参数")
    return 0


if __name__ == "__main__":
    sys.exit(main())
requirements.txt1 行
# 无第三方依赖,只使用 Python 标准库
Esc
↑ ↓ 选择Enter 打开

键盘快捷键

j / k
下一条 / 上一条
o
展开或收起当前条目
Enter
打开当前条目详情
c
复制当前条目链接
s
收藏 / 取消收藏当前条目
/ 或 ⌘K
搜索
1 2 3
切换密度:标题 / 摘要 / 完整
t
切换主题
?
显示本帮助