@xdarkicex/openclaw-memory-libravdb 1.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/docs/README.md +14 -0
- package/docs/architecture-decisions/README.md +6 -0
- package/docs/architecture-decisions/adr-001-onnx-over-ollama.md +21 -0
- package/docs/architecture-decisions/adr-002-libravdb-over-lancedb.md +19 -0
- package/docs/architecture-decisions/adr-003-convex-gating-over-threshold.md +27 -0
- package/docs/architecture-decisions/adr-004-sidecar-over-native-ts.md +21 -0
- package/docs/architecture.md +188 -0
- package/docs/contributing.md +76 -0
- package/docs/dependencies.md +38 -0
- package/docs/embedding-profiles.md +42 -0
- package/docs/gating.md +329 -0
- package/docs/implementation.md +381 -0
- package/docs/installation.md +272 -0
- package/docs/mathematics.md +695 -0
- package/docs/models.md +63 -0
- package/docs/problem.md +64 -0
- package/docs/security.md +86 -0
- package/openclaw.plugin.json +84 -0
- package/package.json +41 -0
- package/scripts/build-sidecar.sh +30 -0
- package/scripts/postinstall.js +169 -0
- package/scripts/setup.sh +20 -0
- package/scripts/setup.ts +505 -0
- package/scripts/sidecar-release.d.ts +4 -0
- package/scripts/sidecar-release.js +17 -0
- package/sidecar/cmd/inspect_onnx/main.go +105 -0
- package/sidecar/compact/gate.go +273 -0
- package/sidecar/compact/gate_test.go +85 -0
- package/sidecar/compact/summarize.go +345 -0
- package/sidecar/compact/summarize_test.go +319 -0
- package/sidecar/compact/tokens.go +11 -0
- package/sidecar/config/config.go +119 -0
- package/sidecar/config/config_test.go +75 -0
- package/sidecar/embed/engine.go +696 -0
- package/sidecar/embed/engine_test.go +349 -0
- package/sidecar/embed/matryoshka.go +93 -0
- package/sidecar/embed/matryoshka_test.go +150 -0
- package/sidecar/embed/onnx_local.go +319 -0
- package/sidecar/embed/onnx_local_test.go +159 -0
- package/sidecar/embed/profile_contract_test.go +71 -0
- package/sidecar/embed/profile_eval_test.go +923 -0
- package/sidecar/embed/profiles.go +39 -0
- package/sidecar/go.mod +21 -0
- package/sidecar/go.sum +30 -0
- package/sidecar/health/check.go +33 -0
- package/sidecar/health/check_test.go +55 -0
- package/sidecar/main.go +151 -0
- package/sidecar/model/encoder.go +222 -0
- package/sidecar/model/registry.go +262 -0
- package/sidecar/model/registry_test.go +102 -0
- package/sidecar/model/seq2seq.go +133 -0
- package/sidecar/server/rpc.go +343 -0
- package/sidecar/server/rpc_test.go +350 -0
- package/sidecar/server/transport.go +160 -0
- package/sidecar/store/libravdb.go +676 -0
- package/sidecar/store/libravdb_test.go +472 -0
- package/sidecar/summarize/engine.go +360 -0
- package/sidecar/summarize/engine_test.go +148 -0
- package/sidecar/summarize/onnx_local.go +494 -0
- package/sidecar/summarize/onnx_local_test.go +48 -0
- package/sidecar/summarize/profiles.go +52 -0
- package/sidecar/summarize/tokenizer.go +13 -0
- package/sidecar/summarize/tokenizer_hf.go +76 -0
- package/sidecar/summarize/util.go +13 -0
- package/src/cli.ts +205 -0
- package/src/context-engine.ts +195 -0
- package/src/index.ts +27 -0
- package/src/memory-provider.ts +24 -0
- package/src/openclaw-plugin-sdk.d.ts +53 -0
- package/src/plugin-runtime.ts +67 -0
- package/src/recall-cache.ts +34 -0
- package/src/recall-utils.ts +22 -0
- package/src/rpc.ts +84 -0
- package/src/scoring.ts +58 -0
- package/src/sidecar.ts +506 -0
- package/src/tokens.ts +36 -0
- package/src/types.ts +146 -0
- package/tsconfig.json +20 -0
- package/tsconfig.tests.json +12 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
package embed
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/sugarme/tokenizer"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
type fakeMiniLMModel struct {
|
|
12
|
+
vector []float32
|
|
13
|
+
err error
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func (m fakeMiniLMModel) Compute(_ string, _ bool) ([]float32, error) {
|
|
17
|
+
if m.err != nil {
|
|
18
|
+
return nil, m.err
|
|
19
|
+
}
|
|
20
|
+
return append([]float32(nil), m.vector...), nil
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func (m fakeMiniLMModel) TokenCount(_ string, _ bool) (int, error) {
|
|
24
|
+
if m.err != nil {
|
|
25
|
+
return 0, m.err
|
|
26
|
+
}
|
|
27
|
+
return 4, nil
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func (m fakeMiniLMModel) Encode(_ string, _ bool) (*tokenizer.Encoding, error) {
|
|
31
|
+
if m.err != nil {
|
|
32
|
+
return nil, m.err
|
|
33
|
+
}
|
|
34
|
+
return &tokenizer.Encoding{
|
|
35
|
+
Ids: []int{1, 2, 3, 4},
|
|
36
|
+
TypeIds: []int{0, 0, 0, 0},
|
|
37
|
+
AttentionMask: []int{1, 1, 1, 1},
|
|
38
|
+
Tokens: []string{"a", "b", "c", "d"},
|
|
39
|
+
Offsets: [][]int{{0, 1}, {1, 2}, {2, 3}, {3, 4}},
|
|
40
|
+
Words: []int{0, 1, 2, 3},
|
|
41
|
+
}, nil
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func (m fakeMiniLMModel) ComputeEncoding(_ tokenizer.Encoding) ([]float32, error) {
|
|
45
|
+
return m.Compute("", true)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func TestNewUnavailableIsNotReady(t *testing.T) {
|
|
49
|
+
engine := NewUnavailable("missing runtime")
|
|
50
|
+
if engine.Ready() {
|
|
51
|
+
t.Fatalf("expected unavailable engine to be not ready")
|
|
52
|
+
}
|
|
53
|
+
if engine.Dimensions() != DefaultDimensions {
|
|
54
|
+
t.Fatalf("expected dimensions %d, got %d", DefaultDimensions, engine.Dimensions())
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func TestNewPrimaryRequiresRuntimePath(t *testing.T) {
|
|
59
|
+
originalResolver := resolveBundledSpec
|
|
60
|
+
originalFactory := newONNXLocalBackend
|
|
61
|
+
t.Cleanup(func() {
|
|
62
|
+
resolveBundledSpec = originalResolver
|
|
63
|
+
newONNXLocalBackend = originalFactory
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
resolveBundledSpec = func(cfg Config) (onnxLocalSpec, error) {
|
|
67
|
+
return onnxLocalSpec{}, errors.New("missing bundled runtime")
|
|
68
|
+
}
|
|
69
|
+
engine := NewPrimary("")
|
|
70
|
+
if !engine.Ready() {
|
|
71
|
+
t.Fatalf("expected missing runtime path to fall back to a ready local embedder")
|
|
72
|
+
}
|
|
73
|
+
if engine.Mode() != "fallback" {
|
|
74
|
+
t.Fatalf("expected fallback mode, got %q", engine.Mode())
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
resolveBundledSpec = func(cfg Config) (onnxLocalSpec, error) {
|
|
78
|
+
if cfg.RuntimePath != "/tmp/libonnxruntime.dylib" {
|
|
79
|
+
t.Fatalf("unexpected runtime path %q", cfg.RuntimePath)
|
|
80
|
+
}
|
|
81
|
+
if cfg.Profile != DefaultEmbeddingProfile {
|
|
82
|
+
t.Fatalf("expected default profile %q, got %q", DefaultEmbeddingProfile, cfg.Profile)
|
|
83
|
+
}
|
|
84
|
+
return onnxLocalSpec{RuntimePath: cfg.RuntimePath, Dimensions: 768, Normalize: true, Profile: buildProfile(Profile{
|
|
85
|
+
Backend: "bundled",
|
|
86
|
+
Family: DefaultEmbeddingProfile,
|
|
87
|
+
Dimensions: 768,
|
|
88
|
+
Normalize: true,
|
|
89
|
+
})}, nil
|
|
90
|
+
}
|
|
91
|
+
newONNXLocalBackend = func(spec onnxLocalSpec) (embeddingBackend, error) {
|
|
92
|
+
return fakeONNXLocalBackend{vector: make([]float32, 768)}, nil
|
|
93
|
+
}
|
|
94
|
+
engine = NewPrimary("/tmp/libonnxruntime.dylib")
|
|
95
|
+
if !engine.Ready() {
|
|
96
|
+
t.Fatalf("expected non-empty runtime path to mark engine ready")
|
|
97
|
+
}
|
|
98
|
+
if engine.Mode() != "primary" {
|
|
99
|
+
t.Fatalf("expected primary mode, got %q", engine.Mode())
|
|
100
|
+
}
|
|
101
|
+
if engine.Dimensions() != 768 {
|
|
102
|
+
t.Fatalf("expected dimensions 768, got %d", engine.Dimensions())
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
func TestBundledBackendUsesRealModelFactory(t *testing.T) {
|
|
107
|
+
originalResolver := resolveBundledSpec
|
|
108
|
+
originalFactory := newONNXLocalBackend
|
|
109
|
+
t.Cleanup(func() {
|
|
110
|
+
resolveBundledSpec = originalResolver
|
|
111
|
+
newONNXLocalBackend = originalFactory
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
resolveBundledSpec = func(cfg Config) (onnxLocalSpec, error) {
|
|
115
|
+
return onnxLocalSpec{
|
|
116
|
+
RuntimePath: cfg.RuntimePath,
|
|
117
|
+
Dimensions: 384,
|
|
118
|
+
Normalize: true,
|
|
119
|
+
Profile: buildProfile(Profile{
|
|
120
|
+
Backend: "bundled",
|
|
121
|
+
Family: "all-minilm-l6-v2",
|
|
122
|
+
Dimensions: 384,
|
|
123
|
+
Normalize: true,
|
|
124
|
+
}),
|
|
125
|
+
}, nil
|
|
126
|
+
}
|
|
127
|
+
newONNXLocalBackend = func(spec onnxLocalSpec) (embeddingBackend, error) {
|
|
128
|
+
if spec.RuntimePath != "/opt/onnx/libonnxruntime.so" {
|
|
129
|
+
t.Fatalf("unexpected runtime path %q", spec.RuntimePath)
|
|
130
|
+
}
|
|
131
|
+
vec := make([]float32, 384)
|
|
132
|
+
vec[0] = 3
|
|
133
|
+
vec[1] = 4
|
|
134
|
+
return miniLMBackend{
|
|
135
|
+
model: fakeMiniLMModel{vector: vec},
|
|
136
|
+
normalize: spec.Normalize,
|
|
137
|
+
}, nil
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
engine := NewWithConfig(Config{
|
|
141
|
+
Backend: "bundled",
|
|
142
|
+
RuntimePath: "/opt/onnx/libonnxruntime.so",
|
|
143
|
+
Dimensions: 384,
|
|
144
|
+
Normalize: true,
|
|
145
|
+
})
|
|
146
|
+
if !engine.Ready() {
|
|
147
|
+
t.Fatalf("expected bundled engine to be ready")
|
|
148
|
+
}
|
|
149
|
+
if engine.Mode() != "primary" {
|
|
150
|
+
t.Fatalf("expected primary mode, got %q", engine.Mode())
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
vec, err := engine.EmbedDocument(context.Background(), "hello")
|
|
154
|
+
if err != nil {
|
|
155
|
+
t.Fatalf("EmbedDocument() error = %v", err)
|
|
156
|
+
}
|
|
157
|
+
if len(vec) != 384 {
|
|
158
|
+
t.Fatalf("expected 384 dimensions, got %d", len(vec))
|
|
159
|
+
}
|
|
160
|
+
if vec[0] == 3 || vec[1] == 4 {
|
|
161
|
+
t.Fatalf("expected normalized output from bundled backend")
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
func TestNewWithConfigSupportsPowerUserSeam(t *testing.T) {
|
|
166
|
+
engine := NewWithConfig(Config{
|
|
167
|
+
Backend: "custom-local",
|
|
168
|
+
ModelPath: "/models/custom.onnx",
|
|
169
|
+
Dimensions: 768,
|
|
170
|
+
Normalize: true,
|
|
171
|
+
})
|
|
172
|
+
if !engine.Ready() {
|
|
173
|
+
t.Fatalf("expected custom-local engine to be ready")
|
|
174
|
+
}
|
|
175
|
+
if engine.Mode() != "custom-local" {
|
|
176
|
+
t.Fatalf("expected custom-local mode, got %q", engine.Mode())
|
|
177
|
+
}
|
|
178
|
+
if engine.Dimensions() != 768 {
|
|
179
|
+
t.Fatalf("expected dimensions 768, got %d", engine.Dimensions())
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
fallback := NewWithConfig(Config{
|
|
183
|
+
Backend: "custom-local",
|
|
184
|
+
Dimensions: 512,
|
|
185
|
+
})
|
|
186
|
+
if fallback.Mode() != "fallback" {
|
|
187
|
+
t.Fatalf("expected missing model path to fall back, got %q", fallback.Mode())
|
|
188
|
+
}
|
|
189
|
+
if fallback.Dimensions() != 512 {
|
|
190
|
+
t.Fatalf("expected fallback dimensions 512, got %d", fallback.Dimensions())
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
unavailable := NewWithConfig(Config{
|
|
194
|
+
Backend: "made-up-backend",
|
|
195
|
+
Dimensions: 256,
|
|
196
|
+
})
|
|
197
|
+
if unavailable.Ready() {
|
|
198
|
+
t.Fatalf("expected unsupported backend to be unavailable")
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
func TestEmbedDeterministic384Dimensions(t *testing.T) {
|
|
203
|
+
engine := NewFallback("test fallback")
|
|
204
|
+
first, err := engine.EmbedDocument(context.Background(), "remember the red book")
|
|
205
|
+
if err != nil {
|
|
206
|
+
t.Fatalf("EmbedDocument(first) error = %v", err)
|
|
207
|
+
}
|
|
208
|
+
second, err := engine.EmbedDocument(context.Background(), "remember the red book")
|
|
209
|
+
if err != nil {
|
|
210
|
+
t.Fatalf("EmbedDocument(second) error = %v", err)
|
|
211
|
+
}
|
|
212
|
+
other, err := engine.EmbedDocument(context.Background(), "different text entirely")
|
|
213
|
+
if err != nil {
|
|
214
|
+
t.Fatalf("EmbedDocument(other) error = %v", err)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if len(first) != DefaultDimensions {
|
|
218
|
+
t.Fatalf("expected %d dimensions, got %d", DefaultDimensions, len(first))
|
|
219
|
+
}
|
|
220
|
+
if len(second) != DefaultDimensions || len(other) != DefaultDimensions {
|
|
221
|
+
t.Fatalf("expected stable %d-dimensional output", DefaultDimensions)
|
|
222
|
+
}
|
|
223
|
+
for i := range first {
|
|
224
|
+
if first[i] != second[i] {
|
|
225
|
+
t.Fatalf("expected deterministic embedding at index %d", i)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
var differs bool
|
|
230
|
+
for i := range first {
|
|
231
|
+
if first[i] != other[i] {
|
|
232
|
+
differs = true
|
|
233
|
+
break
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if !differs {
|
|
237
|
+
t.Fatalf("expected different text to change the embedding")
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
func TestBundledFallsBackToConfiguredFallbackProfile(t *testing.T) {
|
|
242
|
+
originalResolver := resolveBundledSpec
|
|
243
|
+
originalFactory := newONNXLocalBackend
|
|
244
|
+
t.Cleanup(func() {
|
|
245
|
+
resolveBundledSpec = originalResolver
|
|
246
|
+
newONNXLocalBackend = originalFactory
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
resolveCalls := make([]string, 0, 2)
|
|
250
|
+
resolveBundledSpec = func(cfg Config) (onnxLocalSpec, error) {
|
|
251
|
+
resolveCalls = append(resolveCalls, cfg.Profile)
|
|
252
|
+
if cfg.Profile == DefaultEmbeddingProfile {
|
|
253
|
+
return onnxLocalSpec{}, errors.New("primary profile unavailable")
|
|
254
|
+
}
|
|
255
|
+
return onnxLocalSpec{
|
|
256
|
+
RuntimePath: cfg.RuntimePath,
|
|
257
|
+
Dimensions: 384,
|
|
258
|
+
Normalize: true,
|
|
259
|
+
Profile: buildProfile(Profile{
|
|
260
|
+
Backend: "bundled",
|
|
261
|
+
Family: FallbackEmbeddingProfile,
|
|
262
|
+
Dimensions: 384,
|
|
263
|
+
Normalize: true,
|
|
264
|
+
}),
|
|
265
|
+
}, nil
|
|
266
|
+
}
|
|
267
|
+
newONNXLocalBackend = func(spec onnxLocalSpec) (embeddingBackend, error) {
|
|
268
|
+
return fakeONNXLocalBackend{vector: make([]float32, spec.Dimensions)}, nil
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
engine := NewWithConfig(Config{
|
|
272
|
+
Backend: "bundled",
|
|
273
|
+
Profile: DefaultEmbeddingProfile,
|
|
274
|
+
FallbackProfile: FallbackEmbeddingProfile,
|
|
275
|
+
RuntimePath: "/opt/onnx/libonnxruntime.so",
|
|
276
|
+
})
|
|
277
|
+
if !engine.Ready() {
|
|
278
|
+
t.Fatalf("expected bundled engine to be ready via fallback profile")
|
|
279
|
+
}
|
|
280
|
+
if engine.Profile().Family != FallbackEmbeddingProfile {
|
|
281
|
+
t.Fatalf("expected fallback family %q, got %q", FallbackEmbeddingProfile, engine.Profile().Family)
|
|
282
|
+
}
|
|
283
|
+
if len(resolveCalls) != 2 || resolveCalls[0] != DefaultEmbeddingProfile || resolveCalls[1] != FallbackEmbeddingProfile {
|
|
284
|
+
t.Fatalf("unexpected resolve order %v", resolveCalls)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
func TestNomicUsesAsymmetricDocumentAndQueryPrefixes(t *testing.T) {
|
|
289
|
+
originalResolver := resolveBundledSpec
|
|
290
|
+
originalFactory := newONNXLocalBackend
|
|
291
|
+
t.Cleanup(func() {
|
|
292
|
+
resolveBundledSpec = originalResolver
|
|
293
|
+
newONNXLocalBackend = originalFactory
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
resolveBundledSpec = func(cfg Config) (onnxLocalSpec, error) {
|
|
297
|
+
return onnxLocalSpec{
|
|
298
|
+
Family: "nomic-embed-text-v1.5",
|
|
299
|
+
Dimensions: 768,
|
|
300
|
+
Normalize: true,
|
|
301
|
+
Profile: buildProfile(Profile{
|
|
302
|
+
Backend: "onnx-local",
|
|
303
|
+
Family: "nomic-embed-text-v1.5",
|
|
304
|
+
Dimensions: 768,
|
|
305
|
+
Normalize: true,
|
|
306
|
+
}),
|
|
307
|
+
}, nil
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
var calls []string
|
|
311
|
+
newONNXLocalBackend = func(spec onnxLocalSpec) (embeddingBackend, error) {
|
|
312
|
+
return deterministicRecorderBackend{calls: &calls}, nil
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
engine := NewWithConfig(Config{
|
|
316
|
+
Backend: "bundled",
|
|
317
|
+
Profile: "nomic-embed-text-v1.5",
|
|
318
|
+
RuntimePath: "/opt/onnx/libonnxruntime.so",
|
|
319
|
+
})
|
|
320
|
+
if !engine.Ready() {
|
|
321
|
+
t.Fatalf("expected nomic engine to be ready")
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if _, err := engine.EmbedDocument(context.Background(), "project note"); err != nil {
|
|
325
|
+
t.Fatalf("EmbedDocument() error = %v", err)
|
|
326
|
+
}
|
|
327
|
+
if _, err := engine.EmbedQuery(context.Background(), "project note"); err != nil {
|
|
328
|
+
t.Fatalf("EmbedQuery() error = %v", err)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if len(calls) != 3 {
|
|
332
|
+
t.Fatalf("expected 3 backend calls including dimension probe, got %d", len(calls))
|
|
333
|
+
}
|
|
334
|
+
if calls[1] != "search_document: project note" {
|
|
335
|
+
t.Fatalf("unexpected document prefix call %q", calls[1])
|
|
336
|
+
}
|
|
337
|
+
if calls[2] != "search_query: project note" {
|
|
338
|
+
t.Fatalf("unexpected query prefix call %q", calls[2])
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
type deterministicRecorderBackend struct {
|
|
343
|
+
calls *[]string
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
func (b deterministicRecorderBackend) Embed(text string, dimensions int) ([]float32, error) {
|
|
347
|
+
*b.calls = append(*b.calls, text)
|
|
348
|
+
return make([]float32, dimensions), nil
|
|
349
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
package embed
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"math"
|
|
7
|
+
"strings"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
const (
|
|
11
|
+
DimsL1 = 64
|
|
12
|
+
DimsL2 = 256
|
|
13
|
+
DimsL3 = 768
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
type MatryoshkaVec struct {
|
|
17
|
+
L1 []float32
|
|
18
|
+
L2 []float32
|
|
19
|
+
L3 []float32
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type MatryoshkaEmbedder interface {
|
|
23
|
+
Embedder
|
|
24
|
+
EmbedDocumentM(ctx context.Context, text string) (MatryoshkaVec, error)
|
|
25
|
+
EmbedQueryM(ctx context.Context, text string) (MatryoshkaVec, error)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func SupportsMatryoshka(e Embedder) bool {
|
|
29
|
+
if e == nil {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
return strings.EqualFold(e.Profile().Family, "nomic-embed-text-v1.5")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func NewMatryoshkaVec(full []float32) (MatryoshkaVec, error) {
|
|
36
|
+
if len(full) < DimsL3 {
|
|
37
|
+
return MatryoshkaVec{}, fmt.Errorf("matryoshka requires %d-dim vector, got %d", DimsL3, len(full))
|
|
38
|
+
}
|
|
39
|
+
return MatryoshkaVec{
|
|
40
|
+
L1: truncateAndNormalize(full, DimsL1),
|
|
41
|
+
L2: truncateAndNormalize(full, DimsL2),
|
|
42
|
+
L3: truncateAndNormalize(full, DimsL3),
|
|
43
|
+
}, nil
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func truncateAndNormalize(v []float32, dims int) []float32 {
|
|
47
|
+
if dims <= 0 {
|
|
48
|
+
return nil
|
|
49
|
+
}
|
|
50
|
+
if dims > len(v) {
|
|
51
|
+
dims = len(v)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
t := make([]float32, dims)
|
|
55
|
+
copy(t, v[:dims])
|
|
56
|
+
|
|
57
|
+
var norm float64
|
|
58
|
+
for _, x := range t {
|
|
59
|
+
norm += float64(x) * float64(x)
|
|
60
|
+
}
|
|
61
|
+
norm = math.Sqrt(norm)
|
|
62
|
+
if norm < 1e-9 {
|
|
63
|
+
return t
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
scale := float32(norm)
|
|
67
|
+
for i := range t {
|
|
68
|
+
t[i] /= scale
|
|
69
|
+
}
|
|
70
|
+
return t
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func (e *Engine) EmbedDocumentM(ctx context.Context, text string) (MatryoshkaVec, error) {
|
|
74
|
+
if !SupportsMatryoshka(e) {
|
|
75
|
+
return MatryoshkaVec{}, fmt.Errorf("matryoshka unavailable for profile family %q", e.Profile().Family)
|
|
76
|
+
}
|
|
77
|
+
full, err := e.EmbedDocument(ctx, text)
|
|
78
|
+
if err != nil {
|
|
79
|
+
return MatryoshkaVec{}, err
|
|
80
|
+
}
|
|
81
|
+
return NewMatryoshkaVec(full)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func (e *Engine) EmbedQueryM(ctx context.Context, text string) (MatryoshkaVec, error) {
|
|
85
|
+
if !SupportsMatryoshka(e) {
|
|
86
|
+
return MatryoshkaVec{}, fmt.Errorf("matryoshka unavailable for profile family %q", e.Profile().Family)
|
|
87
|
+
}
|
|
88
|
+
full, err := e.EmbedQuery(ctx, text)
|
|
89
|
+
if err != nil {
|
|
90
|
+
return MatryoshkaVec{}, err
|
|
91
|
+
}
|
|
92
|
+
return NewMatryoshkaVec(full)
|
|
93
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
package embed
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"math"
|
|
6
|
+
"math/rand"
|
|
7
|
+
"os"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestTruncateAndNormalize(t *testing.T) {
|
|
13
|
+
full := randomUnitVec(DimsL3)
|
|
14
|
+
mv, err := NewMatryoshkaVec(full)
|
|
15
|
+
if err != nil {
|
|
16
|
+
t.Fatal(err)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
for _, tc := range []struct {
|
|
20
|
+
name string
|
|
21
|
+
v []float32
|
|
22
|
+
}{
|
|
23
|
+
{"L1", mv.L1},
|
|
24
|
+
{"L2", mv.L2},
|
|
25
|
+
{"L3", mv.L3},
|
|
26
|
+
} {
|
|
27
|
+
var norm float64
|
|
28
|
+
for _, x := range tc.v {
|
|
29
|
+
norm += float64(x) * float64(x)
|
|
30
|
+
}
|
|
31
|
+
norm = math.Sqrt(norm)
|
|
32
|
+
if math.Abs(norm-1.0) > 1e-5 {
|
|
33
|
+
t.Errorf("%s: L2 norm = %.8f, want 1.0", tc.name, norm)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func TestMatryoshkaSimilarityPreservation(t *testing.T) {
|
|
39
|
+
if testing.Short() {
|
|
40
|
+
t.Skip("requires real Nomic model")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
engine := loadNomicEngine(t)
|
|
44
|
+
ctx := context.Background()
|
|
45
|
+
|
|
46
|
+
pairs := []struct {
|
|
47
|
+
doc string
|
|
48
|
+
query string
|
|
49
|
+
}{
|
|
50
|
+
{
|
|
51
|
+
doc: "The eviction formula uses logarithmic frequency damping.",
|
|
52
|
+
query: "how does the system decide which model to remove",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
doc: "Compaction clusters session turns into summarized records.",
|
|
56
|
+
query: "memory cleanup after a conversation ends",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
doc: "Nomic embeddings support Matryoshka representation learning.",
|
|
60
|
+
query: "truncated vectors for fast approximate search",
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
tiers := []struct {
|
|
65
|
+
label string
|
|
66
|
+
threshold float64
|
|
67
|
+
selectVec func(MatryoshkaVec) []float32
|
|
68
|
+
}{
|
|
69
|
+
{
|
|
70
|
+
label: "L2 (256d)",
|
|
71
|
+
threshold: 0.90,
|
|
72
|
+
selectVec: func(v MatryoshkaVec) []float32 { return v.L2 },
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
label: "L1 (64d)",
|
|
76
|
+
threshold: 0.70,
|
|
77
|
+
selectVec: func(v MatryoshkaVec) []float32 { return v.L1 },
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for _, pair := range pairs {
|
|
82
|
+
dFull, err := engine.EmbedDocumentM(ctx, pair.doc)
|
|
83
|
+
if err != nil {
|
|
84
|
+
t.Fatalf("EmbedDocumentM() error = %v", err)
|
|
85
|
+
}
|
|
86
|
+
qFull, err := engine.EmbedQueryM(ctx, pair.query)
|
|
87
|
+
if err != nil {
|
|
88
|
+
t.Fatalf("EmbedQueryM() error = %v", err)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
simFull := cosineEval(dFull.L3, qFull.L3)
|
|
92
|
+
for _, tier := range tiers {
|
|
93
|
+
simTier := cosineEval(tier.selectVec(dFull), tier.selectVec(qFull))
|
|
94
|
+
if math.Abs(simFull) < 0.05 {
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
ratio := simTier / simFull
|
|
99
|
+
if ratio < tier.threshold {
|
|
100
|
+
label := pair.doc
|
|
101
|
+
if len(label) > 40 {
|
|
102
|
+
label = label[:40]
|
|
103
|
+
}
|
|
104
|
+
t.Errorf("%s preservation below threshold for pair %q: full=%.4f tier=%.4f ratio=%.4f threshold=%.2f",
|
|
105
|
+
tier.label, label, simFull, simTier, ratio, tier.threshold)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func randomUnitVec(dims int) []float32 {
|
|
112
|
+
rng := rand.New(rand.NewSource(42))
|
|
113
|
+
vec := make([]float32, dims)
|
|
114
|
+
var norm float64
|
|
115
|
+
for i := range vec {
|
|
116
|
+
value := rng.NormFloat64()
|
|
117
|
+
vec[i] = float32(value)
|
|
118
|
+
norm += value * value
|
|
119
|
+
}
|
|
120
|
+
norm = math.Sqrt(norm)
|
|
121
|
+
scale := float32(norm)
|
|
122
|
+
for i := range vec {
|
|
123
|
+
vec[i] /= scale
|
|
124
|
+
}
|
|
125
|
+
return vec
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
func loadNomicEngine(t *testing.T) *Engine {
|
|
129
|
+
t.Helper()
|
|
130
|
+
|
|
131
|
+
runtimePath := strings.TrimSpace(os.Getenv("LIBRAVDB_EVAL_ONNX_RUNTIME"))
|
|
132
|
+
modelDir := strings.TrimSpace(os.Getenv("LIBRAVDB_EVAL_NOMIC_DIR"))
|
|
133
|
+
if runtimePath == "" || modelDir == "" {
|
|
134
|
+
t.Skip("set LIBRAVDB_EVAL_ONNX_RUNTIME and LIBRAVDB_EVAL_NOMIC_DIR to run real Nomic Matryoshka tests")
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
engine := NewWithConfig(Config{
|
|
138
|
+
Backend: "onnx-local",
|
|
139
|
+
Profile: "nomic-embed-text-v1.5",
|
|
140
|
+
RuntimePath: runtimePath,
|
|
141
|
+
ModelPath: modelDir,
|
|
142
|
+
})
|
|
143
|
+
if !engine.Ready() {
|
|
144
|
+
t.Fatalf("engine not ready: %s", engine.Reason())
|
|
145
|
+
}
|
|
146
|
+
if !SupportsMatryoshka(engine) {
|
|
147
|
+
t.Fatalf("expected Nomic engine to support Matryoshka")
|
|
148
|
+
}
|
|
149
|
+
return engine
|
|
150
|
+
}
|