Files
Maven/internal/router/onnxembedder.go
kami 1e47eaca5a Record which embedder wrote the stored vectors and warn on a swap (#378)
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
2026-07-31 13:42:07 +04:00

365 lines
8.5 KiB
Go

package router
import (
"context"
"encoding/json"
"fmt"
"math"
"os"
"strings"
ort "github.com/yalue/onnxruntime_go"
"golang.org/x/text/unicode/norm"
)
// The deployed model is multilingual-e5-small. e5 was trained with these two
// words glued to the front of every text, and it scores badly without them —
// they are part of the model, not a style choice. Swapping back to a symmetric
// paraphrase model means dropping them again.
const (
queryPrefix = "query: "
passagePrefix = "passage: "
)
const (
padTokenID = 1
unkTokenID = 3
clsTokenID = 0
sepTokenID = 2
maxLength = 128
embedDim = 384
)
type onnxEmbedder struct {
tokenizer *unigramTokenizer
session *ort.DynamicSession[int64, float32]
id string
}
func NewONNXEmbedder(modelPath, tokenizerPath, libPath string) (*onnxEmbedder, error) {
ort.SetSharedLibraryPath(libPath)
if err := ort.InitializeEnvironment(); err != nil {
return nil, fmt.Errorf("onnx: init environment: %w", err)
}
tok, err := newUnigramTokenizer(tokenizerPath)
if err != nil {
return nil, fmt.Errorf("tokenizer: %w", err)
}
session, err := ort.NewDynamicSession[int64, float32](
modelPath,
[]string{"input_ids", "attention_mask", "token_type_ids"},
[]string{"last_hidden_state"},
)
if err != nil {
return nil, fmt.Errorf("onnx: create session: %w", err)
}
return &onnxEmbedder{
tokenizer: tok,
session: session,
id: modelIDFromPath(modelPath),
}, nil
}
func (e *onnxEmbedder) Dim() int { return embedDim }
// ID names the loaded model for the DB marker (Vikunja #378): the model file's
// own name plus the dimension, so pointing the config at another model changes
// the string on its own.
func (e *onnxEmbedder) ID() string { return e.id }
// modelIDFromPath turns /opt/.../multilingual-e5-small.onnx into
// "multilingual-e5-small@384".
func modelIDFromPath(modelPath string) string {
name := modelPath
if i := strings.LastIndexAny(name, "/\\"); i >= 0 {
name = name[i+1:]
}
name = strings.TrimSuffix(name, ".onnx")
if name == "" {
name = "onnx"
}
return fmt.Sprintf("%s@%d", name, embedDim)
}
// Embed treats the text as a query. The classifier compares one short
// utterance to another short seed phrase, so both sides get the same prefix
// and the comparison stays fair. The recall path must call EmbedQuery and
// EmbedPassage instead.
func (e *onnxEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
return e.embed(ctx, queryPrefix+text)
}
// EmbedQuery — the question the user just asked.
func (e *onnxEmbedder) EmbedQuery(ctx context.Context, text string) ([]float32, error) {
return e.embed(ctx, queryPrefix+text)
}
// EmbedPassage — a note or fact being stored, or re-scored at lookup time.
func (e *onnxEmbedder) EmbedPassage(ctx context.Context, text string) ([]float32, error) {
return e.embed(ctx, passagePrefix+text)
}
func (e *onnxEmbedder) embed(ctx context.Context, text string) ([]float32, error) {
inputIDs, attentionMask, _ := e.tokenizer.Encode(text)
inputShape := ort.NewShape(1, int64(maxLength))
inputT, err := ort.NewTensor(inputShape, inputIDs)
if err != nil {
return nil, fmt.Errorf("onnx: input tensor: %w", err)
}
defer inputT.Destroy()
maskT, err := ort.NewTensor(inputShape, attentionMask)
if err != nil {
return nil, fmt.Errorf("onnx: mask tensor: %w", err)
}
defer maskT.Destroy()
typeT, err := ort.NewTensor(inputShape, make([]int64, maxLength))
if err != nil {
return nil, fmt.Errorf("onnx: type tensor: %w", err)
}
defer typeT.Destroy()
outputShape := ort.NewShape(1, int64(maxLength), embedDim)
outputT, err := ort.NewTensor(outputShape, make([]float32, maxLength*embedDim))
if err != nil {
return nil, fmt.Errorf("onnx: output tensor: %w", err)
}
defer outputT.Destroy()
if err := e.session.Run(
[]*ort.Tensor[int64]{inputT, maskT, typeT},
[]*ort.Tensor[float32]{outputT},
); err != nil {
return nil, fmt.Errorf("onnx: run: %w", err)
}
emb := meanPool(outputT.GetData(), attentionMask, maxLength, embedDim)
return emb, nil
}
func (e *onnxEmbedder) Close() error {
e.session.Destroy()
return nil
}
func meanPool(hidden []float32, mask []int64, seqLen, dim int) []float32 {
out := make([]float32, dim)
var maskSum float32
for i := 0; i < seqLen; i++ {
if mask[i] == 0 {
continue
}
maskSum++
for j := 0; j < dim; j++ {
out[j] += hidden[i*dim+j]
}
}
if maskSum > 0 {
for j := 0; j < dim; j++ {
out[j] /= maskSum
}
}
var sumSq float64
for _, v := range out {
sumSq += float64(v) * float64(v)
}
if sumSq > 0 {
inv := float32(1.0 / math.Sqrt(sumSq))
for i := range out {
out[i] *= inv
}
}
return out
}
type unigramTokenizer struct {
vocab map[string]vocabEntry
unkScore float64
}
type vocabEntry struct {
id int64
score float64
}
func newUnigramTokenizer(path string) (*unigramTokenizer, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read tokenizer.json: %w", err)
}
var raw struct {
Model struct {
Type string `json:"type"`
Vocab json.RawMessage `json:"vocab"`
} `json:"model"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parse tokenizer.json: %w", err)
}
if raw.Model.Type != "Unigram" {
return nil, fmt.Errorf("unsupported tokenizer type: %s", raw.Model.Type)
}
var rawVocab [][]json.RawMessage
if err := json.Unmarshal(raw.Model.Vocab, &rawVocab); err != nil {
return nil, fmt.Errorf("parse vocab: %w", err)
}
vocab := make(map[string]vocabEntry, len(rawVocab))
var unkScore float64
for _, pair := range rawVocab {
if len(pair) < 2 {
continue
}
var token string
if err := json.Unmarshal(pair[0], &token); err != nil {
continue
}
var score float64
if err := json.Unmarshal(pair[1], &score); err != nil {
continue
}
vocab[token] = vocabEntry{score: score}
}
// Assign IDs based on order
i := int64(0)
for _, pair := range rawVocab {
var token string
if err := json.Unmarshal(pair[0], &token); err != nil {
continue
}
e := vocab[token]
e.id = i
vocab[token] = e
if i == unkTokenID {
unkScore = e.score
}
i++
}
return &unigramTokenizer{vocab: vocab, unkScore: unkScore}, nil
}
func (t *unigramTokenizer) Encode(text string) (inputIDs, attentionMask, tokenTypeIDs []int64) {
tokens := t.tokenize(text)
tokens = append([]int64{clsTokenID}, tokens...)
tokens = append(tokens, sepTokenID)
if len(tokens) > maxLength {
tokens = tokens[:maxLength-1]
tokens = append(tokens, sepTokenID)
}
inputIDs = make([]int64, maxLength)
attentionMask = make([]int64, maxLength)
tokenTypeIDs = make([]int64, maxLength)
for i, id := range tokens {
inputIDs[i] = id
attentionMask[i] = 1
}
return
}
func (t *unigramTokenizer) tokenize(text string) []int64 {
words := preTokenize(text)
var ids []int64
for _, word := range words {
wordIDs := t.encodeWord(word)
ids = append(ids, wordIDs...)
}
return ids
}
type cand struct {
start int
end int
id int64
score float64
}
func (t *unigramTokenizer) encodeWord(word string) []int64 {
runes := []rune(word)
n := len(runes)
if n == 0 {
return nil
}
var candidates []cand
for i := 0; i < n; i++ {
for j := i + 1; j <= n && j-i <= 50; j++ {
sub := string(runes[i:j])
if e, ok := t.vocab[sub]; ok {
candidates = append(candidates, cand{
start: i, end: j, id: e.id, score: e.score,
})
}
}
}
dp := make([]float64, n+1)
prev := make([]int, n+1)
bestID := make([]int64, n+1)
filled := make([]bool, n+1)
dp[0] = 0
filled[0] = true
for i := 1; i <= n; i++ {
bestScore := math.Inf(-1)
bestPrev := -1
bestTokenID := int64(unkTokenID)
for _, c := range candidates {
if c.end == i && filled[c.start] {
candScore := dp[c.start] + c.score
if candScore > bestScore {
bestScore = candScore
bestPrev = c.start
bestTokenID = c.id
}
}
}
if bestScore == math.Inf(-1) {
if filled[i-1] {
dp[i] = dp[i-1] + t.unkScore
prev[i] = i - 1
bestID[i] = unkTokenID
filled[i] = true
}
} else {
dp[i] = bestScore
prev[i] = bestPrev
bestID[i] = bestTokenID
filled[i] = true
}
}
var result []int64
for i := n; i > 0; i = prev[i] {
result = append([]int64{bestID[i]}, result...)
}
// Reverse
for l, r := 0, len(result)-1; l < r; l, r = l+1, r-1 {
result[l], result[r] = result[r], result[l]
}
return result
}
func preTokenize(text string) []string {
text = norm.NFKC.String(text)
text = strings.ToLower(text)
pieces := strings.Fields(text)
out := make([]string, 0, len(pieces))
for _, p := range pieces {
out = append(out, "\u2581"+p)
}
return out
}
var _ AsymmetricEmbedder = (*onnxEmbedder)(nil)