design/23 §19 的第 ② / ③ 步:读侧时效闸(M1)只负责「看不见」,本切片负责 「真删掉」。三条护栏照 §19.3:删除是本包内唯一 DELETE 出口、有界(500 行/批 × 20 批)、删前留审计。 - internal/runtime/memory/expire.go:扫描集合写死为「expires_at ≠ '' ∧ expires_at ≤ now ∧ status='active' ∧ 来源非受限」。空 expires_at(长期/日常 记忆,含 M2 登记的「INSERT 与补写之间中断」窗口)结构性进不了集合;受限来源 复用 nonOwnerSource(与整理/审查同一处判据)。键集游标 (expires_at, rowid) 保证被跳过的行只读一次、扫描位置一定前移。审计串 = id + persona_id + scope + expires_at + 按 rune 截断(48)的摘要 —— 本仓唯一一处允许携带内容片段的审计。 - internal/runtime/memory/expire_loop.go:清扫循环挂在 TidyService 的生命周期上 (AttachExpirySweep 由组合根调用),随心跳 ctx 跑、随 Stop 停;一轮只写一行 汇总日志,逐条的那一行进审计。 - internal/runtime/memory/vector/delete.go:新增可选删除能力 vector.Deleter + 两个后端实现,删行时同步清向量(M1 reviewer 的 finding:过期向量占 top-K 预算)。 - cmd/cyrene/wire_memory_tidy.go:挂上清扫;顺带把已过时的「这十键」改成「这十一 键」(M2 加了第十一键 tempTtlSec)。
91 lines
3.8 KiB
Go
91 lines
3.8 KiB
Go
package vector
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
)
|
||
|
||
// 本文件是**向量删除**(索引的清除侧)。动因是一条实测 finding(切片 M1 的
|
||
// 读侧时效闸交下来的):临时记忆到期后**行不在了、向量还在** —— 过期行的向量
|
||
// 仍留在 vec_items 里,占掉 idx.Search 的 top-K 预算,于是「语义检索前 K 名」
|
||
// 里有一半是永远不会被返回的死条目(读侧闸已挡住那些行),**召回质量静默下降**。
|
||
// 到期清扫(internal/runtime/memory/expire.go)在物理删行时同步调这里,把
|
||
// 向量一并清掉。
|
||
//
|
||
// 🔴 **为什么不是 Index 的必需方法**(Deleter 与 Index 并列、由调用方类型断言):
|
||
// Index 是本包对外的稠密索引抽象,加一个必需方法会**立刻打破所有既有实现** ——
|
||
// 包括别的包里的测试桩(例如 memory 包的 mockVectorIndex)。清除能力因此按
|
||
// **可选扩展**建模:两种生产后端(SQLiteIndex / PGVectorIndex)都实现它,
|
||
// 只读的桩可以不实现,调用方断言失败时退化为「不清向量」并留一条日志。
|
||
// 这与本仓既有的可选能力手法一致(例如 `a.vindex.(*vector.PGVectorIndex)`)。
|
||
|
||
// Deleter 是 Index 的**可选扩展**:把已不存在的 id 从索引里清掉。id 不存在时
|
||
// 视为成功(删不存在的向量本就是空操作),因此重复调用是幂等的。
|
||
//
|
||
// 调用方(memory 包)在物理删行之后调用它;**不是**读路径的一部分。
|
||
type Deleter interface {
|
||
Delete(ctx context.Context, ids ...string) error
|
||
}
|
||
|
||
// deleteVecItemsSQLTemplate 是两种后端共用的删除语句骨架:`%s` 是占位串
|
||
// (SQLite `?` / pgvector `$1,$2,...`)。表名是编译期常量,绝不来自输入。
|
||
const deleteVecItemsSQLTemplate = `DELETE FROM vec_items WHERE id IN (%s)`
|
||
|
||
// vecDeletePlaceholders 为 ids 生成删除语句的占位串与参数表。`dollar` 为 true
|
||
// 时用 Postgres 的 `$n` 形式,否则用 SQLite 的 `?`。
|
||
//
|
||
// 空白 id 被**跳过**而不是报错:删一个不存在的向量是空操作,不该让整批失败
|
||
// (Add 拒绝空 id,是因为那会写出一行不可寻址的数据;删除没有这个问题)。
|
||
// 全部为空时返回空占位串,调用方据此直接返回。
|
||
func vecDeletePlaceholders(ids []string, dollar bool) (string, []any) {
|
||
args := make([]any, 0, len(ids))
|
||
for _, id := range ids {
|
||
if strings.TrimSpace(id) == "" {
|
||
continue
|
||
}
|
||
args = append(args, id)
|
||
}
|
||
if len(args) == 0 {
|
||
return "", nil
|
||
}
|
||
var b strings.Builder
|
||
for i := range args {
|
||
if i > 0 {
|
||
b.WriteByte(',')
|
||
}
|
||
if dollar {
|
||
fmt.Fprintf(&b, "$%d", i+1)
|
||
} else {
|
||
b.WriteByte('?')
|
||
}
|
||
}
|
||
return b.String(), args
|
||
}
|
||
|
||
// Delete 把 ids 对应的向量从 SQLite 索引里删掉。id 不存在时无事发生(幂等);
|
||
// 空 ids 是空操作。ctx 被取消时中止。
|
||
func (s *SQLiteIndex) Delete(ctx context.Context, ids ...string) error {
|
||
placeholders, args := vecDeletePlaceholders(ids, false)
|
||
if placeholders == "" {
|
||
return nil
|
||
}
|
||
if _, err := s.db.ExecContext(ctx, fmt.Sprintf(deleteVecItemsSQLTemplate, placeholders), args...); err != nil {
|
||
return fmt.Errorf("memory/vector: delete %d sqlite vectors: %w", len(args), err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Delete 把 ids 对应的向量从 pgvector 索引里删掉。语义与 SQLiteIndex.Delete
|
||
// 逐字相同(幂等、空 ids 空操作、ctx 取消即中止),只用 `$n` 占位。
|
||
func (p *PGVectorIndex) Delete(ctx context.Context, ids ...string) error {
|
||
placeholders, args := vecDeletePlaceholders(ids, true)
|
||
if placeholders == "" {
|
||
return nil
|
||
}
|
||
if _, err := p.db.ExecContext(ctx, fmt.Sprintf(deleteVecItemsSQLTemplate, placeholders), args...); err != nil {
|
||
return fmt.Errorf("memory/vector: delete %d pg vectors: %w", len(args), err)
|
||
}
|
||
return nil
|
||
}
|