Skip to content
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ DETERMINISTIC_PKGS := \
./internal/memory/embed \
./internal/memory/graph \
./internal/memory/importdraft \
./internal/memory/internal/topk \
./internal/memory/model \
./internal/memory/search \
./internal/memory/setup \
Expand Down
574 changes: 574 additions & 0 deletions docs/development/benchmarks/2026-10-03-memory-after.txt

Large diffs are not rendered by default.

574 changes: 574 additions & 0 deletions docs/development/benchmarks/2026-10-03-memory-before.txt

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions docs/development/benchmarks/2026-10-03-memory-decode-after.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Isolated vector decode buffer comparison, 2026-10-03
# Production commit: 9393edbe4bc29e4c7068dc672e4f52b85fbd4e2e
# Benchmark sources: a21d783b; Apple M4; darwin/arm64; Go 1.25.4
# Three alternating rounds; N=10000; RecallHybrid and RecallWhy
# -test.bench=^BenchmarkMemoryRead$/^N=10000$/^(RecallHybrid|RecallWhy)$
# -test.run=^$ -test.benchtime=200ms -test.cpu=1 -test.benchmem
# No other local tests ran concurrently; cumulative B/op is not peak RSS

goos: darwin
goarch: arm64
pkg: github.com/mnemon-dev/mnemon/internal/memory
cpu: Apple M4
BenchmarkMemoryRead/N=10000/RecallHybrid 4 65679614 ns/op 65520000 cpu-ns/op 49110548 B/op 906667 allocs/op
BenchmarkMemoryRead/N=10000/RecallWhy 4 65544052 ns/op 65512250 cpu-ns/op 49180698 B/op 909459 allocs/op
PASS
goos: darwin
goarch: arm64
pkg: github.com/mnemon-dev/mnemon/internal/memory
cpu: Apple M4
BenchmarkMemoryRead/N=10000/RecallHybrid 3 67077111 ns/op 67044333 cpu-ns/op 49103488 B/op 906507 allocs/op
BenchmarkMemoryRead/N=10000/RecallWhy 4 66150698 ns/op 66126500 cpu-ns/op 49180458 B/op 909459 allocs/op
PASS
goos: darwin
goarch: arm64
pkg: github.com/mnemon-dev/mnemon/internal/memory
cpu: Apple M4
BenchmarkMemoryRead/N=10000/RecallHybrid 4 63996958 ns/op 63850500 cpu-ns/op 49114596 B/op 906761 allocs/op
BenchmarkMemoryRead/N=10000/RecallWhy 3 70158306 ns/op 69437333 cpu-ns/op 49179624 B/op 909421 allocs/op
PASS
29 changes: 29 additions & 0 deletions docs/development/benchmarks/2026-10-03-memory-decode-before.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Isolated vector decode buffer comparison, 2026-10-03
# Production commit: 6d1511aca64bc9892aeee23a9e45c8f3486f98ab
# Benchmark sources: a21d783b; Apple M4; darwin/arm64; Go 1.25.4
# Three alternating rounds; N=10000; RecallHybrid and RecallWhy
# -test.bench=^BenchmarkMemoryRead$/^N=10000$/^(RecallHybrid|RecallWhy)$
# -test.run=^$ -test.benchtime=200ms -test.cpu=1 -test.benchmem
# No other local tests ran concurrently; cumulative B/op is not peak RSS

goos: darwin
goarch: arm64
pkg: github.com/mnemon-dev/mnemon/internal/memory
cpu: Apple M4
BenchmarkMemoryRead/N=10000/RecallHybrid 3 67769264 ns/op 65984000 cpu-ns/op 59354144 B/op 916762 allocs/op
BenchmarkMemoryRead/N=10000/RecallWhy 3 68961611 ns/op 68912667 cpu-ns/op 59414122 B/op 919264 allocs/op
PASS
goos: darwin
goarch: arm64
pkg: github.com/mnemon-dev/mnemon/internal/memory
cpu: Apple M4
BenchmarkMemoryRead/N=10000/RecallHybrid 3 69582194 ns/op 69488333 cpu-ns/op 59354224 B/op 916763 allocs/op
BenchmarkMemoryRead/N=10000/RecallWhy 3 68606861 ns/op 68585333 cpu-ns/op 59418562 B/op 919419 allocs/op
PASS
goos: darwin
goarch: arm64
pkg: github.com/mnemon-dev/mnemon/internal/memory
cpu: Apple M4
BenchmarkMemoryRead/N=10000/RecallHybrid 3 67607722 ns/op 67581667 cpu-ns/op 59348413 B/op 916635 allocs/op
BenchmarkMemoryRead/N=10000/RecallWhy 3 69724722 ns/op 68933333 cpu-ns/op 59418562 B/op 919419 allocs/op
PASS
581 changes: 581 additions & 0 deletions docs/development/benchmarks/2026-10-03-memory-main.txt

Large diffs are not rendered by default.

581 changes: 581 additions & 0 deletions docs/development/benchmarks/2026-10-03-memory-synced.txt

Large diffs are not rendered by default.

449 changes: 449 additions & 0 deletions docs/development/memory-algorithm-performance.md

Large diffs are not rendered by default.

101 changes: 101 additions & 0 deletions docs/zh/development/memory-algorithm-performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Memory 算法性能调优报告

更新日期:2026-10-03。独立 worktree 中的性能分支已同步默认分支 `master`
(仓库没有使用 `main` 这个名称),基线为 `5c98ff9c`,合并提交为 `6d1511ac`,
最终算法实现为 `9393edbe`。

[完整分析、历史对照、原始数据和复现方式](../../development/memory-algorithm-performance.md)
保存在英文报告中。本页主表以最新 `master` 为基线,旧报告的大幅提升倍数
不再当作相对当前主线的增量收益。

## 同步与新增优化

主线已经合入邻接覆盖索引、同一请求内的转移缓存、调用方记录快照复用、模型
keep-alive 和数据库压缩。这些改动完整保留,收益计入基线。

合并冲突时保留了主线的关键规则:**先按意图和相似度排列完整邻域,再应用访问
预算**。同分时仍按记录 ID、边类型排列。在这个规则下复用本 PR 已计算的余弦
标量分数,避免为每条边重复计算相似度,也避免维护两套邻接缓存。

本轮另外复用了向量解码缓冲区:同一请求内逐条解码、立即计算分数,然后复用数组。
存储格式、浮点计算顺序和缓存生命周期保持不变。与刚合并主线的版本单独比较,
N=10,000 混合召回的单次分配从约 59.35 MB 降到 49.11 MB,减少约 17%。
这是累计内存分配量,不是进程峰值内存。

## 再次测量

N 是记录数;使用 N=1、10、100、1,000、10,000,108 个用例,每个版本交替
运行 5 轮,共 **1,080 个最终样本**。下面为 CPU 时间中位数;MB 使用十进制。
机器、数据和脚本与原审计一致,使用真实临时 SQLite WAL 和 128 维本地向量。

单位:**CPU 微秒/次**,最新主线 → 最终 PR。

| 路径 | N=1 | N=10 | N=100 |
|---|---:|---:|---:|
| 按重要性取前 10 条 | 22.9 → 21.4 | 45.6 → 42.3 | 50.1 → 41.4 |
| 同来源最新记录 | 15.9 → 14.4 | 15.9 → 14.7 | 21.0 → 14.6 |
| 关键词检索 | 4.3 → 3.4 | 37.8 → 30.2 | 361.6 → 288.7 |
| 混合召回 | 82.2 → 80.0 | 339.3 → 316.2 | 3,037.5 → 2,555.0 |
| WHY 召回 | 47.7 → 45.9 | 453.9 → 423.5 | 3,236.2 → 2,687.0 |
| 向量语义候选 | 11.6 → 13.4 | 54.0 → 53.5 | 70.6 → 64.0 |
| 两跳邻域 | 17.4 → 15.2 | 50.1 → 59.3 | 368.4 → 59.3 |
| N 条原子插入 | 75.7 → 76.6 | 278.9 → 177.5 | 2,291.2 → 1,185.3 |

N=10,000,单位:**CPU 毫秒/次**。

| 路径 | 主线 | 最终 PR | 倍数 |
|---|---:|---:|---:|
| 按重要性取前 10 条 | 0.9576 | 0.0398 | 24.04× |
| 同来源最新记录 | 0.6016 | 0.0146 | 41.35× |
| 关键词检索 | 33.7453 | 26.5098 | 1.27× |
| 混合召回 | 93.0620 | 66.5310 | 1.40× |
| WHY 召回 | 91.0855 | 67.1605 | 1.36× |
| 向量语义候选 | 1.8507 | 0.9890 | 1.87× |
| 两跳邻域 | 40.3057 | 0.0595 | 677.77× |
| N 条原子插入 | 524.3310 | 431.6040 | 1.21× |
| 显式提供实体的图写入 | 37.8323 | 2.3085 | 16.39× |

稠密图 N=100 的混合召回为 18.324 → 16.866 ms,约 1.09×。主线
已经带来大部分图缓存收益,因此不再沿用旧基线的 6.51×。普通 N=10,000 混合
召回的累计分配为 78.85 → 49.10 MB,减少 37.7%。

小规模仍有取舍:N=10 邻域查询为 50.09 → 59.34 µs;N=1 向量/文本语义路径
分别为 11.57 → 13.36 µs、11.75 → 13.79 µs。N=10,000 实体集合扫描约慢
4%,保留策略接近持平(170.104 → 171.657 ms)。这些退化保留在数据中。
未改算法的向量读取也有时间波动,不能把较小的差别全部归因于算法。以上是共享
开发机器上的观测,不是线上延迟承诺。

完整最终数据:[主线](../../development/benchmarks/2026-10-03-memory-main.txt)、
[最终 PR](../../development/benchmarks/2026-10-03-memory-synced.txt)。额外的解码缓冲
对照采用 3 轮、200 ms,每轮测混合/WHY 召回;它没有混入上述最终样本。

## 当前复杂度结论

| 部分 | 相对当前主线的变化与限制 |
|---|---|
| 来源/时间、重要性/时间的有限查询 | 兼容索引的查询约为 `O(log N + K)`;全库时间排序改为顺序索引扫描 |
| 多路召回 | 主线已有邻接缓存。设 E_U 为展开节点读到的邻边数,余弦相关成本从 `O((N+E_U+C)d)` 降为 `O(Nd+E_U+C)`;每个邻域的完整排序仍保留 |
| 精确向量扫描 | 仍是 `O(Nd)`;固定维度下,临时解码数组的累计分配从 `O(Nd)` 降为 `O(d)` |
| 语义候选与 Diff | 前 K 名的选择从 `O(M log M)` 改为 `O(M log K)`,选择空间从 `O(M)` 降为 `O(K)` |
| 有限 BFS | 按实际邻域读记录与边,保留原插入顺序、节点/深度限额;不再随无关的全库规模加载数据。高出度仍有成本 |
| 原子写入 | 事务内复用预编译语句;索引维护仍有 `O(B log(N+B))` 工作及提交 I/O |
| 未命中、保留策略、导入 | 子串/实体未命中仍可能全库扫描;保留策略一般 `O(E+N log N)`,逐条精确比对的批量导入仍可能 `O(BN+B²)` |

关键词与精确向量检索没有改成近似算法。访问预算限制访问数量,但不能免除读取、
排列高出度邻域的成本。小规模耗时不足以独立证明 Big-O,结论还结合代码、查询
计划、分配量和确定性的操作计数。

## 验证

- 最终实现通过 `go build -o mnemon .`、`make test`、Memory/命令层 race 检查,
以及全部 **258 项 CLI E2E** 断言。
- npm 测试 **13 项通过**;上游 OpenCode 运行时和 ZCode shell hook 套件在本机
通过。macOS 上跳过了 Windows PowerShell 专用探针,未将其写为已通过。
- 独立遍历对照已更新为主线 `5c98ff9c`,覆盖四种意图、三档预算、重叠锚点、
同分、环路、删除节点、零/负相似度及维度不匹配,分数和来源标签一致。
- 验证调用方无序快照不被修改、时间锚点结果不变,以及解码数组扩缩容、非法数据、
有符号零、非正规数、无穷和 NaN 的位值。原有索引迁移、事务回滚、压缩和 BFS
等价性检查也通过。

保留原有等分锚点/结果顺序的不确定性,不承诺逐字节排序不变。未调用付费模型,
未运行 Agency/Docker 集成总入口;基准不包含 CLI 启动、网络或冷文件缓存。
20 changes: 16 additions & 4 deletions internal/memory/embed/vector.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,26 @@ func SerializeVector(v []float64) []byte {

// DeserializeVector decodes a little-endian float32 blob into a float64 slice.
func DeserializeVector(b []byte) []float64 {
return DeserializeVectorInto(b, nil)
}

// DeserializeVectorInto decodes the same format as DeserializeVector, reusing
// dst's backing array when it has enough capacity. The caller owns that array
// and must consume its contents before reusing it. Invalid blobs return nil.
func DeserializeVectorInto(b []byte, dst []float64) []float64 {
if len(b) == 0 || len(b)%4 != 0 {
return nil
}
v := make([]float64, len(b)/4)
for i := range v {
v[i] = float64(math.Float32frombits(binary.LittleEndian.Uint32(b[i*4:])))
n := len(b) / 4
if cap(dst) < n {
dst = make([]float64, n)
} else {
dst = dst[:n]
}
return v
for i := range dst {
dst[i] = float64(math.Float32frombits(binary.LittleEndian.Uint32(b[i*4:])))
}
return dst
}

// DeserializeLegacyVector decodes a pre-migration little-endian float64 blob.
Expand Down
43 changes: 43 additions & 0 deletions internal/memory/embed/vector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,49 @@ func TestDeserializeVector_InvalidLength(t *testing.T) {
}
}

func TestDeserializeVectorInto_ReusesCapacityAndResizes(t *testing.T) {
dst := make([]float64, 0, 4)
backing := &dst[:cap(dst)][0]
for _, input := range [][]float64{{1, -2, 3}, {4}, {5, 6, 7, 8}, {9, 10, 11, 12, 13}, {-14, 15}} {
oldCapacity := cap(dst)
dst = DeserializeVectorInto(SerializeVector(input), dst)
if len(dst) != len(input) {
t.Fatalf("decoded length = %d, want %d", len(dst), len(input))
}
if len(input) <= oldCapacity && &dst[0] != backing {
t.Fatal("decoder did not reuse available capacity")
}
for i, want := range input {
if dst[i] != want {
t.Fatalf("decoded[%d] = %v, want %v", i, dst[i], want)
}
}
backing = &dst[0]
}
for _, blob := range [][]byte{nil, {}, {1}, {1, 2, 3, 4, 5}} {
if got := DeserializeVectorInto(blob, dst); got != nil {
t.Fatalf("invalid blob returned stale vector: %v", got)
}
}
}

func TestDeserializeVectorInto_PreservesFloatBits(t *testing.T) {
// Include subnormal, signed zero, infinity and NaN encodings as well as
// normal values. Buffer reuse must not change float32-to-float64 decoding.
bits := []uint32{0, 1, 0x80000000, 0x3f800000, 0xbf800000, 0x7f7fffff, 0x7f800000, 0xff800000, 0x7fc00001}
blob := make([]byte, len(bits)*4)
for i, value := range bits {
binary.LittleEndian.PutUint32(blob[i*4:], value)
}
got := DeserializeVectorInto(blob, make([]float64, len(bits)))
for i, value := range bits {
want := float64(math.Float32frombits(value))
if math.Float64bits(got[i]) != math.Float64bits(want) {
t.Fatalf("float bits changed at %d: got %x want %x", i, math.Float64bits(got[i]), math.Float64bits(want))
}
}
}

func TestDeserializeLegacyVector_InvalidLength(t *testing.T) {
if v := DeserializeLegacyVector(make([]byte, 7)); v != nil {
t.Errorf("invalid legacy blob length: want nil, got %v", v)
Expand Down
86 changes: 66 additions & 20 deletions internal/memory/graph/bfs.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package graph

import (
"database/sql"
"errors"
"github.com/mnemon-dev/mnemon/internal/memory/model"
"github.com/mnemon-dev/mnemon/internal/memory/store"
)
Expand All @@ -19,30 +21,18 @@ type BFSOptions struct {
EdgeFilter model.EdgeType // filter by edge type (empty = all types)
}

// BFS performs breadth-first traversal from startID over the full graph.
// Pre-loads all active insights and edges to avoid N+1 queries.
// The start node is excluded from results. Only active (non-deleted) nodes are visited.
// BFS performs breadth-first traversal. Bounded neighborhoods use indexed
// reads so disconnected history is not loaded. Unlimited traversals preload the
// graph to avoid a query per node. Both paths preserve edge insertion order.
// The start node is excluded. Only active (non-deleted) nodes are visited.
func BFS(db *store.DB, startID string, opts BFSOptions) []BFSNode {
allInsights, err := db.GetAllActiveInsights()
if err != nil {
if opts.MaxDepth <= 0 {
return nil
}
insightMap := make(map[string]*model.Insight, len(allInsights))
for _, ins := range allInsights {
insightMap[ins.ID] = ins
}

allEdges, err := db.GetAllEdges()
view, err := newBFSView(db, opts)
if err != nil {
return nil
}
edgeAdj := make(map[string][]*model.Edge)
for _, e := range allEdges {
edgeAdj[e.SourceID] = append(edgeAdj[e.SourceID], e)
if e.SourceID != e.TargetID {
edgeAdj[e.TargetID] = append(edgeAdj[e.TargetID], e)
}
}

type entry struct {
id string
Expand All @@ -65,7 +55,11 @@ func BFS(db *store.DB, startID string, opts BFSOptions) []BFSNode {
continue
}

for _, edge := range edgeAdj[cur.id] {
edges, err := view.edgesFor(cur.id, opts.EdgeFilter)
if err != nil {
return nil
}
for _, edge := range edges {
if opts.EdgeFilter != "" && edge.EdgeType != opts.EdgeFilter {
continue
}
Expand All @@ -80,7 +74,10 @@ func BFS(db *store.DB, startID string, opts BFSOptions) []BFSNode {
}
visited[neighborID] = true

insight := insightMap[neighborID]
insight, err := view.insightFor(neighborID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil
}
if insight == nil {
continue // soft-deleted or missing
}
Expand All @@ -104,3 +101,52 @@ func BFS(db *store.DB, startID string, opts BFSOptions) []BFSNode {

return result
}

// bfsView owns either the bulk snapshot or indexed reads for this traversal.
// It never retains state between requests or starts background work.
type bfsView struct {
db *store.DB
insights map[string]*model.Insight
adj map[string][]*model.Edge
}

func newBFSView(db *store.DB, opts BFSOptions) (*bfsView, error) {
v := &bfsView{db: db}
if opts.MaxNodes > 0 {
return v, nil
}
all, err := db.GetAllActiveInsights()
if err != nil {
return nil, err
}
v.insights = make(map[string]*model.Insight, len(all))
for _, ins := range all {
v.insights[ins.ID] = ins
}
edges, err := db.GetAllEdges()
if err != nil {
return nil, err
}
v.adj = make(map[string][]*model.Edge)
for _, edge := range edges {
v.adj[edge.SourceID] = append(v.adj[edge.SourceID], edge)
if edge.SourceID != edge.TargetID {
v.adj[edge.TargetID] = append(v.adj[edge.TargetID], edge)
}
}
return v, nil
}

func (v *bfsView) edgesFor(id string, edgeType model.EdgeType) ([]*model.Edge, error) {
if v.adj != nil {
return v.adj[id], nil
}
return v.db.GetTraversalEdges(id, edgeType)
}

func (v *bfsView) insightFor(id string) (*model.Insight, error) {
if v.insights != nil {
return v.insights[id], nil
}
return v.db.GetInsightByID(id)
}
Loading
Loading