- P0-3 持久向量索引**不再空壳**:Store.Add 落库后真做 Embed+Add(失败只记日志、绝不让记忆写入失败); NewRetriever 拿到 WithVectorIndex 时**自动接上写入路径**(无需改组合根);索引无命中 → WARN + **回退内存余弦**; 新增每进程一次的**回填**(索引确实为空时把已落库条目向量化,分批 64、上限 512) - P0-4 工作记忆**按会话接线**:回合落定时写入(owner 会话 + 带会话 id 的窗口,LRU 上限 64), 组装上下文时按会话读取 ⇒ 前导块真的出现;原单窗口 API 语义不变 - P0-17 L4 学习回环**真回写**:新增 capacity.Judge(只跑判定、不施人格覆盖/不发事件/不改状态); loop 在回合落定"只写 owner 会话 + 跳过子代理分支",写 ScopeUser(L4 的读域)+ 结构化溯源 - 顺带红线加固:意图记录是主人**原话**的机器可读形态 ⇒ **绝不注入群聊/陌生人块**(design/03 §5) - 复核:三条各自的包 build/vet/test 绿;「真生效」断言含生产构造路径(vector.New + 真实 SQLite 后端 + n-gram embedder)、跑完一轮窗口非空、L4 回写后能唤醒一个自身无强信号的表达; **5 条变异反证**(其中两条专门打「受限会话零写入」与「群聊不泄露主人原话」)均已还原 - 未做(登记):Index.Search 无 ID 过滤(客户端按候选集过滤,极端情况走回退);每条记忆一次 Embed
111 lines
4.0 KiB
Go
111 lines
4.0 KiB
Go
package memory
|
|
|
|
import (
|
|
"context"
|
|
)
|
|
|
|
// 本文件是「持久语义索引的一次性回填」:让既有条目也进入语义路径。
|
|
//
|
|
// 写入侧(store_vector.go)保证**此后**每条记忆都被写进索引,但进程
|
|
// 启动前就已落库的 memory_items 没有对应向量。若不回填,语义路径
|
|
// 只覆盖新写入的少数条目,旧条目在索引里查不到 —— 对「配置了增强
|
|
// 就必须真生效」(AGENTS §9)而言仍是半个空壳。
|
|
//
|
|
// 回填触发条件极窄:每进程首次需要语义打分时探测一次索引,**只在索
|
|
// 引确实为空**(一条向量都没有)时回填全部条目,之后永不重复。因此
|
|
// 它不会在正常运行的每次检索上花代价,也不会重复嵌入已入索引的向量。
|
|
|
|
// indexProbeText 是探测「索引是否为空」用的固定文本:它的向量内容
|
|
// 无关紧要(只想知道索引里有没有任何向量),取常量以保证确定性。
|
|
const indexProbeText = "probe"
|
|
|
|
// indexBackfillBatch 是单次 Embed 调用喂给索引的条数上限,避免把
|
|
// 整库文本一次性交给远端 embedding API。
|
|
const indexBackfillBatch = 64
|
|
|
|
// maxBackfillItems 是单次回填处理的最大条目数。它给「一次启动就把
|
|
// 十万条历史记忆全量向量化」这种代价封顶;超出部分只记日志,不
|
|
// 阻塞检索(Store.List 最新在前,因此被截掉的是最旧的一批)。
|
|
const maxBackfillItems = 512
|
|
|
|
// ensureIndexSeeded 每进程最多执行一次:探测持久索引是否为空,为空
|
|
// 则把已落库的条目回填进去。未接入索引时是空操作;探测或嵌入失败
|
|
// 只记日志且不再重试(重试只会把同一条故障日志刷满每一次检索)。
|
|
func (r *Retriever) ensureIndexSeeded(ctx context.Context) {
|
|
if r.vindex == nil {
|
|
return
|
|
}
|
|
r.seedMu.Lock()
|
|
defer r.seedMu.Unlock()
|
|
if r.seeded {
|
|
return
|
|
}
|
|
r.seeded = true
|
|
|
|
probe, err := r.vindex.Embed(ctx, []string{indexProbeText})
|
|
if err != nil || len(probe) == 0 {
|
|
r.log.Warn("memory: vector index probe failed, semantic index not seeded", "error", err)
|
|
return
|
|
}
|
|
hits, err := r.vindex.Search(ctx, probe[0], 1)
|
|
if err != nil {
|
|
r.log.Warn("memory: vector index probe search failed, semantic index not seeded", "error", err)
|
|
return
|
|
}
|
|
if len(hits) > 0 {
|
|
// 索引里已有向量:它由写入侧或上一次运行填过,无需回填。
|
|
return
|
|
}
|
|
r.backfillIndex(ctx)
|
|
}
|
|
|
|
// backfillIndex 把已落库的条目向量化并写入索引。它分批 Embed,逐条
|
|
// Add;单条失败只记日志(其余条目继续),因此一次失败的 Add 不会让
|
|
// 整个回填白跑。按 maxBackfillItems 截断时记一条日志说明被跳过
|
|
// 的量,避免「配置了增强却只覆盖一部分」无人知晓。
|
|
func (r *Retriever) backfillIndex(ctx context.Context) {
|
|
items, err := r.store.List(ctx)
|
|
if err != nil {
|
|
r.log.Warn("memory: list items for vector backfill failed", "error", err)
|
|
return
|
|
}
|
|
if len(items) == 0 {
|
|
return
|
|
}
|
|
total := len(items)
|
|
if total > maxBackfillItems {
|
|
r.log.Warn("memory: vector backfill truncated, oldest items left unindexed",
|
|
"total", total, "limit", maxBackfillItems)
|
|
items = items[:maxBackfillItems]
|
|
}
|
|
|
|
indexed, failed := 0, 0
|
|
for start := 0; start < len(items); start += indexBackfillBatch {
|
|
end := min(start+indexBackfillBatch, len(items))
|
|
batch := items[start:end]
|
|
texts := make([]string, len(batch))
|
|
for i, it := range batch {
|
|
texts[i] = it.Content
|
|
}
|
|
vecs, err := r.vindex.Embed(ctx, texts)
|
|
if err != nil {
|
|
r.log.Warn("memory: vector backfill embed failed, seeding stopped",
|
|
"indexed", indexed, "error", err)
|
|
return
|
|
}
|
|
for i, v := range vecs {
|
|
if i >= len(batch) {
|
|
break
|
|
}
|
|
if err := r.vindex.Add(ctx, batch[i].ID, v); err != nil {
|
|
failed++
|
|
r.log.Warn("memory: vector backfill add failed", "id", batch[i].ID, "error", err)
|
|
continue
|
|
}
|
|
indexed++
|
|
}
|
|
}
|
|
r.log.Info("memory: vector index backfilled from persisted items",
|
|
"indexed", indexed, "failed", failed, "total", total)
|
|
}
|