package memory import ( "context" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" ) // 本文件是入库前审查的**候选召回**(design/23 §15 D3-3:用既有 recall 路径, // 不许另造一套相似度实现)。 // // 复用面(逐条,都是整理侧已经存在的实现): // - 阈值:与整理同一个召回门(config.MemoryTidy.RecallThreshold,§13 开放点 2); // - 相似度:语义 embedding 的余弦(clampUnit + cosineSim)或零依赖的词项余弦 // (lexicalSimilarity); // - 收集与排序:collectTidyPairs —— 因此「阈值过滤 + 分数降序 + id 兜底」 // 与整理侧逐字一致,截断也可复现。 // // 与整理侧**唯一**的差异:候选只保留「新条目 vs 既有条目」的对(整理侧是 // 分区内两两),因为入库前审查永远以刚落库的那一条为中心。 // recallCandidates 在同一分区内召回候选(分区键 = persona_id + scope + // project_id,design §6 硬约束:分区之间永不比较)。 // // 返回的 degraded 表示本轮相似度是从语义路径**降级**到词项余弦的(embedder // 报错或向量条数错位):判定规则一字不改,只把「这一轮的依据不够好」如实 // 带进留痕(§15 D3-8 的「是否降级」)。 func (r *PreInsertReviewer) recallCandidates(ctx context.Context, item MemoryItem) ([]ingestCandidate, bool, error) { partition := tidyPartition{personaID: item.PersonaID, scope: item.Scope, projectID: item.ProjectID} existing, err := r.deps.Store.listReviewCandidates(ctx, partition, item.ID, r.cfg.ScanLimit) if err != nil { return nil, false, err } if len(existing) == 0 { return nil, false, nil } // 新条目固定在首位:reviewPairs 只认它为中心。 items := make([]MemoryItem, 0, len(existing)+1) items = append(items, item) items = append(items, existing...) if r.deps.Embedder == nil { // 未配置语义 embedding:与整理生产轮同一口径 —— 用零依赖词项余弦, // 不是降级(这是配置形态,不是故障)。 return reviewPairs(items, r.cfg.Threshold, lexicalSimFunc(items), tidyRecallLexical), false, nil } vecs, err := embedReviewTexts(ctx, r.deps.Embedder, items) if err != nil { // 语义召回不可用 ⇒ 降级到与整理生产轮**同一个**词项余弦,并如实标记 // degraded(design §4.1 生产轮回退口径)。降级绝不改变判定规则, // 也绝不改写任何一行。 r.log.Warn("memory ingest review embedding recall unavailable, falling back to lexical", "item_id", item.ID, "scope", string(item.Scope), "error", err) return reviewPairs(items, r.cfg.Threshold, lexicalSimFunc(items), tidyRecallLexical), true, nil } return reviewPairs(items, r.cfg.Threshold, vectorSimFunc(vecs), tidyRecallEmbedding), false, nil } // reviewPairs 用整理的收集内核(collectTidyPairs)产出候选对,再只保留以 // items[0](新条目)为中心的那些,转成审查候选。首位的 id 为空时无法区分 // 中心,直接返回空 —— 宁可少审一条,也绝不把两条既有记忆当成候选。 func reviewPairs(items []MemoryItem, threshold float64, sim func(i, j int) float64, mode string) []ingestCandidate { if len(items) < 2 || items[0].ID == "" { return nil } var sink TidyStats // 零值预算 = 无时间预算:入库前审查的对数由它自己的 ScanLimit 收口 // (那是 §15 D3 切片的护栏),本切片不改它的行为。 pairs, _ := collectTidyPairs(items, threshold, sim, mode, &sink, tidyBudget{}) out := make([]ingestCandidate, 0, len(pairs)) for _, p := range pairs { switch { case p.a.ID == items[0].ID: out = append(out, ingestCandidate{item: p.b, score: p.score, mode: p.mode}) case p.b.ID == items[0].ID: out = append(out, ingestCandidate{item: p.a, score: p.score, mode: p.mode}) } } return out } // embedReviewTexts 分批把条目向量化(批次上限复用整理侧的 tidyEmbedBatch: // 不把整个分区的文本一次性交给远端 embedding API)。单批失败即整体失败, // 由调用方决定降级还是上报,绝不半途改用混合表示。 func embedReviewTexts(ctx context.Context, emb embedding.Embedder, items []MemoryItem) ([]embedding.Vector, error) { out := make([]embedding.Vector, 0, len(items)) for start := 0; start < len(items); start += tidyEmbedBatch { end := min(start+tidyEmbedBatch, len(items)) texts := make([]string, 0, end-start) for _, it := range items[start:end] { texts = append(texts, it.Content) } vecs, err := emb.Embed(ctx, texts) if err != nil { return nil, err } if len(vecs) != len(texts) { return nil, ErrTidyEmbedCount } out = append(out, vecs...) } return out, nil }