1e47eaca5a
The embedder moved from paraphrase-multilingual-MiniLM-L12-v2 to multilingual-e5-small. Both are 384-dimensional, so nothing in the code noticed: cosine between an old stored vector and a new query vector is noise, and recall degrades silently. So the DB now records the embedder that wrote its vectors. One value for the whole DB (migration #11, a small `meta` key/value table) rather than a column on every vector row: the backfill re-embeds every note and fact in one pass, so a per-row marker would hold the same string in every row and cost a column on two tables for nothing. The identity comes from the embedder itself via a new optional ID() method ("multilingual-e5-small@384", model file name plus dimension), so pointing the config at another model changes the string without anyone editing a constant. mavend logs a loud WARNING at startup naming both the stored and the configured embedder when they differ. Detection only — recall behaviour is unchanged. TODO(#378) in store.CheckEmbedder marks where the backfill will hook in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
152 lines
4.8 KiB
Go
152 lines
4.8 KiB
Go
package router
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"unicode"
|
|
)
|
|
|
|
// Embedder produces a dense vector for a text utterance. The classifier is
|
|
// nearest-centroid over labeled-intent example vectors; the embedder is the
|
|
// one impure seam — the production path is the multilingual ONNX int8 model
|
|
// (multilingual-e5-small or paraphrase-multilingual-MiniLM-L12-v2, ~120mb,
|
|
// bilingual ru+en native, no separate ru/en path). That model is a later
|
|
// module; anything deterministic + dimension-fixed works here, which keeps
|
|
// the classifier + cascade unit-testable without the model loaded.
|
|
type Embedder interface {
|
|
Dim() int
|
|
Embed(ctx context.Context, text string) ([]float32, error)
|
|
Close() error
|
|
}
|
|
|
|
// IdentifiedEmbedder — an embedder that can name itself. The name goes into
|
|
// the DB next to the vectors it wrote, so a later model swap is caught instead
|
|
// of silently returning nonsense scores (Vikunja #378).
|
|
type IdentifiedEmbedder interface {
|
|
Embedder
|
|
ID() string
|
|
}
|
|
|
|
// EmbedderID is the stable string stored alongside the vectors. It comes from
|
|
// the embedder itself — nobody hand-types a model name twice — and changes
|
|
// whenever the model or its dimension changes.
|
|
func EmbedderID(e Embedder) string {
|
|
if i, ok := e.(IdentifiedEmbedder); ok {
|
|
return i.ID()
|
|
}
|
|
return fmt.Sprintf("unknown@%d", e.Dim())
|
|
}
|
|
|
|
// AsymmetricEmbedder — an embedder that wants to know whether a text is a
|
|
// search query or a stored passage. Recall is asymmetric: a short question
|
|
// goes in, a longer note comes out. The e5 family is trained for exactly that
|
|
// and needs the side written into the text ("query: " / "passage: ").
|
|
//
|
|
// Optional on purpose: HashEmbedder has no such notion, so callers go through
|
|
// EmbedQuery and EmbedPassage below, which fall back to plain Embed.
|
|
type AsymmetricEmbedder interface {
|
|
Embedder
|
|
EmbedQuery(ctx context.Context, text string) ([]float32, error)
|
|
EmbedPassage(ctx context.Context, text string) ([]float32, error)
|
|
}
|
|
|
|
// EmbedQuery embeds text that is being searched WITH — a question.
|
|
func EmbedQuery(ctx context.Context, e Embedder, text string) ([]float32, error) {
|
|
if a, ok := e.(AsymmetricEmbedder); ok {
|
|
return a.EmbedQuery(ctx, text)
|
|
}
|
|
return e.Embed(ctx, text)
|
|
}
|
|
|
|
// EmbedPassage embeds text that is being searched FOR — a note or a fact on
|
|
// its way into the store. Store and lookup must use these two calls, not one
|
|
// of them twice, or the asymmetry buys nothing.
|
|
func EmbedPassage(ctx context.Context, e Embedder, text string) ([]float32, error) {
|
|
if a, ok := e.(AsymmetricEmbedder); ok {
|
|
return a.EmbedPassage(ctx, text)
|
|
}
|
|
return e.Embed(ctx, text)
|
|
}
|
|
|
|
// HashEmbedder — a deterministic bag-of-words embedder used for tests and as a
|
|
// non-zero default floor. NOT semantically meaningful across languages; the
|
|
// real classifier swaps in the multilingual ONNX model wholesale.
|
|
//
|
|
// Token collisions are the point: same surface words ⇒ similar vectors ⇒ the
|
|
// centroid math is testable. Each token hashes into a dimension; weights
|
|
// accumulate then L2-normalize so cosine similarity is a clean inner product.
|
|
type HashEmbedder struct {
|
|
dim int
|
|
}
|
|
|
|
func NewHashEmbedder(dim int) *HashEmbedder {
|
|
if dim <= 0 {
|
|
dim = 128
|
|
}
|
|
return &HashEmbedder{dim: dim}
|
|
}
|
|
|
|
func (h *HashEmbedder) Dim() int { return h.dim }
|
|
|
|
// ID names this embedder for the DB marker. The dimension is part of it
|
|
// because a HashEmbedder of another width is a different vector space.
|
|
func (h *HashEmbedder) ID() string { return fmt.Sprintf("hash@%d", h.dim) }
|
|
|
|
func (h *HashEmbedder) Close() error { return nil }
|
|
|
|
func (h *HashEmbedder) Embed(_ context.Context, text string) ([]float32, error) {
|
|
v := make([]float32, h.dim)
|
|
for _, tok := range tokenize(text) {
|
|
idx := fnv1a(tok) % uint32(h.dim)
|
|
v[idx] += 1.0
|
|
}
|
|
var sum float64
|
|
for _, x := range v {
|
|
sum += float64(x) * float64(x)
|
|
}
|
|
if sum == 0 {
|
|
return v, nil
|
|
}
|
|
inv := float32(1.0 / math.Sqrt(sum))
|
|
for i := range v {
|
|
v[i] *= inv
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
func fnv1a(s string) uint32 {
|
|
h := uint32(2166136261)
|
|
for i := 0; i < len(s); i++ {
|
|
h ^= uint32(s[i])
|
|
h *= 16777619
|
|
}
|
|
return h
|
|
}
|
|
|
|
// tokenize — lowercase, split on non-letter/digit, drop empties + 1-char noise.
|
|
// Rune-based and Unicode-aware: Maven is ru-first, so a byte-only ASCII filter
|
|
// would drop every Cyrillic word (its bytes are all ≥ 0x80) and embed Russian
|
|
// utterances to the zero vector — cosine 0 across all intents, misrouting every
|
|
// RU command. unicode.IsLetter covers Cyrillic + Latin; the real ONNX model
|
|
// brings its own tokenizer.
|
|
func tokenize(s string) []string {
|
|
out := make([]string, 0, 8)
|
|
var b []rune
|
|
flush := func() {
|
|
if len(b) > 1 {
|
|
out = append(out, string(b))
|
|
}
|
|
b = b[:0]
|
|
}
|
|
for _, r := range s {
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
|
b = append(b, unicode.ToLower(r))
|
|
} else {
|
|
flush()
|
|
}
|
|
}
|
|
flush()
|
|
return out
|
|
}
|