Files
Cyrene_v2/internal/runtime/memory/embed_incremental.go
T
AskaEth 058fa141bf docs(memory): 请求上界按条数维+字符维两维算准(review-m61 P2-1)
embedding.Embed 同时受条数界(8)与字符界(480 字)约束 ⇒ 一次 embedOnce(B 条)
会发 C 个请求(C 由正文长度决定,1..B),因此:

  正常批            C
  含一条坏正文      ≤ C + B(短正文 9;均长 75 汉字的现实批 10;病理上限 2B=16)
  服务整体挂死      首个 chunk 即失败 + 逐条 B ⇒ 1 + B = 9(与字符维无关)

修正 embed_isolation.go 文件头的上界表、approxRequests 的「两者一致」表述、
embedReuseStats.Sent 的注释,以及 embed_resilience_test.go 的上界注释与日志文案。
零行为改动:判据、实现、请求数都未动,测试全绿。
2026-10-30 09:04:14 +08:00

286 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package memory
import (
"context"
"errors"
"log/slog"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
)
// 本文件是「增量向量化」:召回链只嵌**索引里还没有**的行。
//
// ## 背景(M15,2026-10-13 登记的放大源)
//
// 整理轮每轮把整个分区重新向量化(user 分区一轮 ≈ 250 个请求)、入库审查每条把
// 200 条既有候选重新嵌一遍(26 个请求)—— 而 `vec_items` 里本来就有这些行的向量
// (写入侧 store_vector.go 每条 Add 都写一条)。同一段正文在下一轮从零再算一遍是
// 纯浪费,也是 M15 记的「请求放大」在**记忆层**的那一半(另一半是 embedding 包的
// 切片形状,已由 M15 把 4 条/片改成 8 条/片)。
//
// ## 增量判据:向量存在性 + 维度一致(不用内容哈希)
//
// 复用条件是「`vec_items` 里该 item.ID 的向量存在,且长度等于索引维度」。不用
// 内容哈希,理由是可证的一条:**记忆正文在本仓不存在就地改写** —— `Store.Add` 是
// 唯一 INSERT 路径、merge 是软删除(`status`+`merged_into`)、reclassify 只改分类列、
// 结构性改写一律「新行 + 旧行置为被合并」(design/23 §15 D3-6)。正文只增不改 ⇒
// 「有向量」等价于「那个向量就是这段正文的向量」,哈希比对不提供额外信息。
//
// ⚠️ 已知边界(既存缺口,不在本切片内):`vec_items` 不带**模型标签**。若换了
// embedding 模型而维度恰好相同,复用会把两种表示混进同一轮的余弦里。可用的判据
// 只有维度(本层用的就是它)——要根治得给向量表加一列 model,属迁移级改动。
//
// ## 算完回填索引(否则「增量」只会增到同样的那几条)
//
// 只复用不填,第二轮还是复用到同样那几条:索引的覆盖面本来只有「索引接入之后
// 写入的条目 + 一次性 ≤512 条回填」(retrieve_index.go),一个 2500 条的分区因此
// 长期大部分没有向量。所以本层在算完之后把向量 upsert 回索引(与
// store_vector.indexItem 同一件事、同一条 id),使**第二轮起**同一分区的请求数
// 趋近于 0。回填失败只计数 + 一条 WARN,绝不让召回失败(召回已经在手上的向量上
// 跑完了)。
// ErrNilEmbedder 在「要增量向量化却没有 embedder」时返回。生产调用点都先判了
// nil(未配置语义 embedding 就走词项余弦),因此它只防内部装配错误。
var ErrNilEmbedder = errors.New("memory: nil embedder")
// embedReuseStats 是一轮召回**实际向远端发了什么**的计数。它进日志而不是进
// 候选/留痕表:请求数是运维观测,不改变任何判定结果。
type embedReuseStats struct {
// Requested 是参与召回的条目数(本轮需要向量的条目)。
Requested int
// Reused 是索引里已有向量、一次远端都没发的条目数。
Reused int
// Sent 是真正送进 embedder 的**条数**;HTTP 请求数 ≥ ⌈Sent/批上限⌉ ——
// embedding 包还会按字符界(480 字)再切,长正文时请求数更多(见 approxRequests)。
Sent int
// Dropped 是逐条隔离重试后仍失败、本轮没有向量的条目数(缺口)。
Dropped int
// Filled 是本轮回填进索引的向量数。
Filled int
// FillFailed 是回填失败的条数(不影响召回)。
FillFailed int
}
// approxRequests 估算本轮发出的 HTTP 请求数:条数 ÷ 批上限(向上取整)。
//
// 🔴 它是**按条数界算的下限估算**,不是精确值:embedding.Embed 还按字符界
// (DefaultBatchChars=480)切片,长正文批会被多切一刀 ⇒ 真实请求数 ≥ 本估算
// (短正文、≤480 字/批时两者相等)。它只进日志做观测,不是任何判据。
func (s embedReuseStats) approxRequests(batch int) int {
if s.Sent <= 0 {
return 0
}
if batch <= 0 {
batch = tidyEmbedBatch
}
return (s.Sent + batch - 1) / batch
}
// logEmbedReuse 如实记一笔本轮「复用 / 真发请求 / 缺口」。缺口必须看得见
// (绝不静默),正常的复用只记 Debug —— 它每轮每分区都会发生,不该刷屏。
func logEmbedReuse(log *slog.Logger, caller string, stats embedReuseStats, batch int) {
if log == nil || stats.Requested == 0 {
return
}
attrs := []any{
"caller", caller,
"items", stats.Requested,
"reused", stats.Reused,
"sent", stats.Sent,
"requests", stats.approxRequests(batch),
"dropped", stats.Dropped,
"filled", stats.Filled,
}
if stats.Dropped > 0 {
log.Warn("memory: semantic recall ran with embedding gaps", attrs...)
return
}
log.Debug("memory: semantic recall reused indexed vectors", attrs...)
}
// embedIncremental 返回与 items 对齐的向量(拿不到的位置为 nil):索引里已有的
// 直接复用,只有缺的才送进 embedder。batch 是单次 Embed 的条数上限(生产传
// tidyEmbedBatch,它 = embedding.DefaultBatchLimit)。
//
// 失败语义(design/23 §15.3 已登记的韧性口径:**一片失败不丢整条**):
// - 单批内部失败由 embedIsolated **逐条隔离**,仍失败的那几条留下**缺口**,其余
// 条目照常带向量返回;
// - 整批一条都没拿到(整体性故障)时停止后续批,已拿到的向量照常返回;
// - 只有「一条向量都没拿到」才返回错误 —— 调用方据此走既有的词项余弦降级。
func (s *Store) embedIncremental(ctx context.Context, emb embedding.Embedder, items []MemoryItem, batch int) ([]embedding.Vector, embedReuseStats, error) {
stats := embedReuseStats{Requested: len(items)}
vecs := make([]embedding.Vector, len(items))
if len(items) == 0 {
return vecs, stats, nil
}
if emb == nil {
return nil, stats, ErrNilEmbedder
}
idx := s.vectorIndex()
if idx != nil && emb.Dim() > 0 && emb.Dim() != idx.Dim() {
// 🔴 维度不一致说明索引与当前 embedder 不是同一套表示(换过模型 / 换过维度)。
// 此时**既不复用也不回填**:复用会把两种维度的向量混进同一轮余弦(长度不符
// ⇒ 相似度恒 0,静默降质),回填则必然逐条 ErrDimMismatch。退回「全量嵌入」,
// 并如实留一条 WARN,让「索引该重建了」这件事看得见。
s.log.Warn("memory: embedder dim differs from vector index dim, incremental reuse disabled",
"embedder_dim", emb.Dim(), "index_dim", idx.Dim())
idx = nil
}
if idx != nil {
reused, err := s.reuseIndexedVectors(ctx, idx, items, vecs)
if err != nil {
// 索引读失败**不**降级语义召回:退回「全量嵌入」(改动前的行为),代价是
// 这一轮又放大成 ⌈N/批⌉ 个请求 —— 如实记 WARN,让请求数异常有据可查。
s.log.Warn("memory: read indexed vectors failed, embedding the whole partition", "error", err)
vecs = make([]embedding.Vector, len(items))
} else {
stats.Reused = reused
}
}
missing := missingVectorIndexes(vecs)
if len(missing) == 0 {
return vecs, stats, nil
}
stats.Sent = len(missing)
if err := s.embedMissing(ctx, emb, items, missing, vecs, &stats, batch); err != nil {
return nil, stats, err
}
if idx != nil {
s.fillIndexVectors(ctx, idx, items, missing, vecs, &stats)
}
return vecs, stats, nil
}
// reuseIndexedVectors 把索引里已有的向量摊到 vecs 的对应位置,返回复用条数。
// 只读:它不写 vecs 之外的任何东西,也不产生 embedding 请求。
func (s *Store) reuseIndexedVectors(ctx context.Context, idx vector.Index, items []MemoryItem, vecs []embedding.Vector) (int, error) {
ids := make([]string, 0, len(items))
positions := make([]int, 0, len(items))
for i, it := range items {
if it.ID == "" {
continue
}
ids = append(ids, it.ID)
positions = append(positions, i)
}
if len(ids) == 0 {
return 0, nil
}
stored, err := idx.Vectors(ctx, ids)
if err != nil {
return 0, err
}
reused := 0
for k, pos := range positions {
if k >= len(stored) || len(stored[k]) == 0 {
continue
}
vecs[pos] = embedding.Vector(stored[k])
reused++
}
return reused, nil
}
// missingVectorIndexes 返回还没有向量的那些条目的下标(顺序即条目顺序,
// 入库审查的新条目因此在最前)。
func missingVectorIndexes(vecs []embedding.Vector) []int {
missing := make([]int, 0, len(vecs))
for i, v := range vecs {
if len(v) == 0 {
missing = append(missing, i)
}
}
return missing
}
// embedMissing 只嵌入缺向量的那些条目,把结果写回 vecs 的对应位置。
//
// 批级失败(整体性故障)当场收手:对已经挂掉的服务继续发后续批就是 M15 记的
// 那种放大。缺口条数进 stats,绝不静默。
func (s *Store) embedMissing(ctx context.Context, emb embedding.Embedder, items []MemoryItem,
missing []int, vecs []embedding.Vector, stats *embedReuseStats, batch int) error {
if batch <= 0 {
batch = tidyEmbedBatch
}
var firstErr error
for start := 0; start < len(missing); start += batch {
end := min(start+batch, len(missing))
idxs := missing[start:end]
texts := make([]string, len(idxs))
for k, i := range idxs {
texts[k] = items[i].Content
}
got, err := embedIsolated(ctx, emb, texts)
if err != nil {
if firstErr == nil {
firstErr = err
}
break
}
for k, i := range idxs {
if k >= len(got) || len(got[k]) == 0 {
continue
}
vecs[i] = got[k]
}
}
stats.Dropped = stats.Sent - countVectorsAt(vecs, missing)
if stats.Sent == stats.Dropped {
if firstErr != nil {
return firstErr
}
return ErrEmbedUnavailable
}
if stats.Dropped > 0 {
s.log.Warn("memory: embedding left gaps in semantic recall",
"missing", len(missing), "dropped", stats.Dropped, "error", firstErr)
}
return nil
}
// countVectorsAt 数 idxs 这些位置里已有向量的条数。
func countVectorsAt(vecs []embedding.Vector, idxs []int) int {
n := 0
for _, i := range idxs {
if i < len(vecs) && len(vecs[i]) > 0 {
n++
}
}
return n
}
// fillIndexVectors 把本轮**算出来的**向量 upsert 回索引(与 store_vector.indexItem
// 同一件事、同一条 id),使下一轮不再重算同一批正文。
//
// 失败只计数 + 一条 WARN:召回已经在本轮拿到的向量上跑完了,回填失败不该让它
// 变成一次失败。维度不符(索引与当前 embedder 不是同一套表示)时当场收手 ——
// 继续逐条试只会刷出上千条同样的失败。
func (s *Store) fillIndexVectors(ctx context.Context, idx vector.Index, items []MemoryItem,
idxs []int, vecs []embedding.Vector, stats *embedReuseStats) {
mismatch := false
for _, i := range idxs {
if items[i].ID == "" || len(vecs[i]) == 0 {
continue
}
err := idx.Add(ctx, items[i].ID, vector.Vector(vecs[i]))
if err == nil {
stats.Filled++
continue
}
stats.FillFailed++
if errors.Is(err, vector.ErrDimMismatch) {
mismatch = true
break
}
}
switch {
case mismatch:
s.log.Warn("memory: vector index dim mismatch, index fill stopped for this round",
"filled", stats.Filled, "failed", stats.FillFailed)
case stats.FillFailed > 0:
s.log.Warn("memory: vector index fill failed for some items",
"filled", stats.Filled, "failed", stats.FillFailed)
}
}