simile-search 0.3.2 → 0.4.1
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 +74 -1
- package/dist/ann.d.ts +110 -0
- package/dist/ann.js +374 -0
- package/dist/cache.d.ts +94 -0
- package/dist/cache.js +179 -0
- package/dist/embedder.d.ts +55 -4
- package/dist/embedder.js +144 -12
- package/dist/engine.d.ts +16 -3
- package/dist/engine.js +279 -64
- package/dist/engine.test.js +70 -256
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/quantization.d.ts +50 -0
- package/dist/quantization.js +271 -0
- package/dist/similarity.d.ts +24 -0
- package/dist/similarity.js +105 -0
- package/dist/types.d.ts +37 -0
- package/dist/updater.d.ts +172 -0
- package/dist/updater.js +336 -0
- package/package.json +1 -1
package/dist/engine.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import { embed, embedBatch, vectorToBase64, base64ToVector } from "./embedder.js";
|
|
2
|
-
import { cosine, fuzzyScore, keywordScore, calculateScoreStats } from "./similarity.js";
|
|
1
|
+
import { embed, embedBatch, vectorToBase64, base64ToVector, } from "./embedder.js";
|
|
2
|
+
import { cosine, fuzzyScore, keywordScore, calculateScoreStats, } from "./similarity.js";
|
|
3
3
|
import { hybridScore, getDefaultWeights } from "./ranker.js";
|
|
4
4
|
import { extractText, normalizeScore } from "./utils.js";
|
|
5
|
-
|
|
5
|
+
import { VectorCache, createCacheKey } from "./cache.js";
|
|
6
|
+
import { HNSWIndex } from "./ann.js";
|
|
7
|
+
import { BackgroundUpdater } from "./updater.js";
|
|
8
|
+
const PACKAGE_VERSION = "0.4.0";
|
|
6
9
|
export class Simile {
|
|
7
10
|
constructor(items, vectors, config = {}) {
|
|
11
|
+
this.cache = null;
|
|
12
|
+
this.annIndex = null;
|
|
8
13
|
this.items = items;
|
|
9
14
|
this.vectors = vectors;
|
|
10
15
|
this.itemIndex = new Map(items.map((item, i) => [item.id, i]));
|
|
@@ -13,7 +18,40 @@ export class Simile {
|
|
|
13
18
|
model: config.model ?? "Xenova/all-MiniLM-L6-v2",
|
|
14
19
|
textPaths: config.textPaths ?? [],
|
|
15
20
|
normalizeScores: config.normalizeScores ?? true,
|
|
21
|
+
cache: config.cache ?? true,
|
|
22
|
+
quantization: config.quantization ?? "float32",
|
|
23
|
+
useANN: config.useANN ?? false,
|
|
24
|
+
annThreshold: config.annThreshold ?? 1000,
|
|
16
25
|
};
|
|
26
|
+
// Initialize Cache
|
|
27
|
+
if (this.config.cache) {
|
|
28
|
+
this.cache = new VectorCache(typeof this.config.cache === "object" ? this.config.cache : {});
|
|
29
|
+
}
|
|
30
|
+
// Initialize ANN Index if threshold reached or forced
|
|
31
|
+
if (this.config.useANN || this.items.length >= this.config.annThreshold) {
|
|
32
|
+
// Optimize HNSW for speed when not explicitly configured
|
|
33
|
+
const hnswConfig = typeof this.config.useANN === "object"
|
|
34
|
+
? this.config.useANN
|
|
35
|
+
: {
|
|
36
|
+
efSearch: 20, // Reduced from default 50 for faster search
|
|
37
|
+
M: 16, // Keep default
|
|
38
|
+
efConstruction: 200, // Keep default for build quality
|
|
39
|
+
};
|
|
40
|
+
this.buildANNIndex(hnswConfig);
|
|
41
|
+
}
|
|
42
|
+
// Initialize Updater
|
|
43
|
+
this.updater = new BackgroundUpdater(this);
|
|
44
|
+
}
|
|
45
|
+
buildANNIndex(config) {
|
|
46
|
+
if (this.vectors.length === 0)
|
|
47
|
+
return;
|
|
48
|
+
const dims = this.vectors[0].length;
|
|
49
|
+
const hnswConfig = config ||
|
|
50
|
+
(typeof this.config.useANN === "object" ? this.config.useANN : {});
|
|
51
|
+
this.annIndex = new HNSWIndex(dims, hnswConfig);
|
|
52
|
+
for (let i = 0; i < this.vectors.length; i++) {
|
|
53
|
+
this.annIndex.add(i, this.vectors[i]);
|
|
54
|
+
}
|
|
17
55
|
}
|
|
18
56
|
/**
|
|
19
57
|
* Extract searchable text from an item using configured paths.
|
|
@@ -28,10 +66,57 @@ export class Simile {
|
|
|
28
66
|
static async from(items, config = {}) {
|
|
29
67
|
const model = config.model ?? "Xenova/all-MiniLM-L6-v2";
|
|
30
68
|
const textPaths = config.textPaths ?? [];
|
|
31
|
-
//
|
|
69
|
+
// For initialization, we create a temporary cache to avoid duplicate embeddings
|
|
70
|
+
// even if caching is disabled in config, it's useful during bulk init
|
|
71
|
+
const tempCache = new VectorCache({ maxSize: items.length });
|
|
32
72
|
const texts = items.map((item) => extractText(item, textPaths.length > 0 ? textPaths : undefined));
|
|
33
|
-
const vectors =
|
|
34
|
-
|
|
73
|
+
const vectors = [];
|
|
74
|
+
const textsToEmbed = [];
|
|
75
|
+
const textToVectorIdx = new Map();
|
|
76
|
+
for (let i = 0; i < texts.length; i++) {
|
|
77
|
+
const text = texts[i];
|
|
78
|
+
const cacheKey = createCacheKey(text, model);
|
|
79
|
+
const cached = tempCache.get(cacheKey);
|
|
80
|
+
if (cached) {
|
|
81
|
+
vectors[i] = cached;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
textToVectorIdx.set(textsToEmbed.length, i);
|
|
85
|
+
textsToEmbed.push(text);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (textsToEmbed.length > 0) {
|
|
89
|
+
const newVectors = await embedBatch(textsToEmbed, model);
|
|
90
|
+
for (let i = 0; i < newVectors.length; i++) {
|
|
91
|
+
const originalIdx = textToVectorIdx.get(i);
|
|
92
|
+
vectors[originalIdx] = newVectors[i];
|
|
93
|
+
tempCache.set(createCacheKey(textsToEmbed[i], model), newVectors[i]);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const engine = new Simile(items, vectors, config);
|
|
97
|
+
// Warm up the engine's cache with the vectors we just computed
|
|
98
|
+
if (engine.cache) {
|
|
99
|
+
for (let i = 0; i < texts.length; i++) {
|
|
100
|
+
engine.cache.set(createCacheKey(texts[i], model), vectors[i]);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return engine;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Internal helper for embedding text with caching.
|
|
107
|
+
*/
|
|
108
|
+
async embedWithCache(text) {
|
|
109
|
+
const cacheKey = createCacheKey(text, this.config.model);
|
|
110
|
+
if (this.cache) {
|
|
111
|
+
const cached = this.cache.get(cacheKey);
|
|
112
|
+
if (cached)
|
|
113
|
+
return cached;
|
|
114
|
+
}
|
|
115
|
+
const vector = await embed(text, this.config.model);
|
|
116
|
+
if (this.cache) {
|
|
117
|
+
this.cache.set(cacheKey, vector);
|
|
118
|
+
}
|
|
119
|
+
return vector;
|
|
35
120
|
}
|
|
36
121
|
/**
|
|
37
122
|
* Load a Simile instance from a previously saved snapshot.
|
|
@@ -72,29 +157,76 @@ export class Simile {
|
|
|
72
157
|
toJSON() {
|
|
73
158
|
return JSON.stringify(this.save());
|
|
74
159
|
}
|
|
75
|
-
/**
|
|
76
|
-
* Add new items to the index
|
|
77
|
-
*/
|
|
78
160
|
async add(items) {
|
|
79
161
|
const texts = items.map((item) => this.getSearchableText(item));
|
|
80
|
-
|
|
162
|
+
// Use embedBatch with cache optimization
|
|
163
|
+
const newVectors = [];
|
|
164
|
+
const textsToEmbed = [];
|
|
165
|
+
const textToIdx = new Map();
|
|
166
|
+
for (let i = 0; i < texts.length; i++) {
|
|
167
|
+
const cacheKey = createCacheKey(texts[i], this.config.model);
|
|
168
|
+
const cached = this.cache?.get(cacheKey);
|
|
169
|
+
if (cached) {
|
|
170
|
+
newVectors[i] = cached;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
textToIdx.set(textsToEmbed.length, i);
|
|
174
|
+
textsToEmbed.push(texts[i]);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (textsToEmbed.length > 0) {
|
|
178
|
+
const embedded = await embedBatch(textsToEmbed, this.config.model);
|
|
179
|
+
for (let i = 0; i < embedded.length; i++) {
|
|
180
|
+
const originalIdx = textToIdx.get(i);
|
|
181
|
+
newVectors[originalIdx] = embedded[i];
|
|
182
|
+
this.cache?.set(createCacheKey(textsToEmbed[i], this.config.model), embedded[i]);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
81
185
|
for (let i = 0; i < items.length; i++) {
|
|
82
186
|
const item = items[i];
|
|
83
187
|
const existingIdx = this.itemIndex.get(item.id);
|
|
84
188
|
if (existingIdx !== undefined) {
|
|
85
|
-
// Update existing item
|
|
86
189
|
this.items[existingIdx] = item;
|
|
87
190
|
this.vectors[existingIdx] = newVectors[i];
|
|
191
|
+
this.annIndex?.remove(existingIdx);
|
|
192
|
+
this.annIndex?.add(existingIdx, newVectors[i]);
|
|
88
193
|
}
|
|
89
194
|
else {
|
|
90
|
-
// Add new item
|
|
91
195
|
const newIdx = this.items.length;
|
|
92
196
|
this.items.push(item);
|
|
93
197
|
this.vectors.push(newVectors[i]);
|
|
94
198
|
this.itemIndex.set(item.id, newIdx);
|
|
199
|
+
// Auto-enable ANN if threshold reached
|
|
200
|
+
if (!this.annIndex && this.items.length >= this.config.annThreshold) {
|
|
201
|
+
this.buildANNIndex();
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
this.annIndex?.add(newIdx, newVectors[i]);
|
|
205
|
+
}
|
|
95
206
|
}
|
|
96
207
|
}
|
|
97
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Queue items for background indexing (non-blocking).
|
|
211
|
+
*/
|
|
212
|
+
enqueue(items) {
|
|
213
|
+
this.updater.enqueue(items);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Get indexing information and stats.
|
|
217
|
+
*/
|
|
218
|
+
getIndexInfo() {
|
|
219
|
+
let memoryBytes = 0;
|
|
220
|
+
for (const v of this.vectors)
|
|
221
|
+
memoryBytes += v.byteLength;
|
|
222
|
+
return {
|
|
223
|
+
type: this.annIndex ? "hnsw" : "linear",
|
|
224
|
+
size: this.items.length,
|
|
225
|
+
memory: `${(memoryBytes / 1024 / 1024).toFixed(2)} MB`,
|
|
226
|
+
cacheStats: this.cache?.getStats(),
|
|
227
|
+
annStats: this.annIndex?.getStats(),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
98
230
|
/**
|
|
99
231
|
* Remove items by ID
|
|
100
232
|
*/
|
|
@@ -111,6 +243,10 @@ export class Simile {
|
|
|
111
243
|
this.items = newItems;
|
|
112
244
|
this.vectors = newVectors;
|
|
113
245
|
this.itemIndex = new Map(this.items.map((item, i) => [item.id, i]));
|
|
246
|
+
// Rebuild ANN index if it exists
|
|
247
|
+
if (this.annIndex) {
|
|
248
|
+
this.buildANNIndex();
|
|
249
|
+
}
|
|
114
250
|
}
|
|
115
251
|
/**
|
|
116
252
|
* Get item by ID
|
|
@@ -145,62 +281,141 @@ export class Simile {
|
|
|
145
281
|
* @returns Sorted results by relevance (highest score first)
|
|
146
282
|
*/
|
|
147
283
|
async search(query, options = {}) {
|
|
148
|
-
const { topK = 5, explain = false, filter, threshold = 0, minLength = 1, } = options;
|
|
284
|
+
const { topK = 5, explain = false, filter, threshold = 0, minLength = 1, semanticOnly = false, } = options;
|
|
149
285
|
// Min character limit - don't search until query meets minimum length
|
|
150
286
|
if (query.length < minLength) {
|
|
151
287
|
return [];
|
|
152
288
|
}
|
|
153
|
-
const qVector = await
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
289
|
+
const qVector = await this.embedWithCache(query);
|
|
290
|
+
// Use ANN if enabled and available
|
|
291
|
+
if (this.annIndex && (options.useANN ?? true)) {
|
|
292
|
+
// Optimize: get fewer candidates for faster search
|
|
293
|
+
const candidateCount = semanticOnly ? topK : Math.min(topK * 2, 20);
|
|
294
|
+
const annResults = this.annIndex.search(qVector, candidateCount);
|
|
295
|
+
// Fast path: semantic-only search (no fuzzy/keyword)
|
|
296
|
+
if (semanticOnly) {
|
|
297
|
+
const results = [];
|
|
298
|
+
for (const res of annResults) {
|
|
299
|
+
const item = this.items[res.id];
|
|
300
|
+
if (filter && !filter(item.metadata))
|
|
301
|
+
continue;
|
|
302
|
+
const semantic = 1 - res.distance;
|
|
303
|
+
if (semantic < threshold)
|
|
304
|
+
continue;
|
|
305
|
+
results.push({
|
|
306
|
+
id: item.id,
|
|
307
|
+
text: item.text,
|
|
308
|
+
metadata: item.metadata,
|
|
309
|
+
score: semantic,
|
|
310
|
+
explain: explain ? { semantic, fuzzy: 0, keyword: 0 } : undefined,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
return results.sort((a, b) => b.score - a.score).slice(0, topK);
|
|
314
|
+
}
|
|
315
|
+
// Full hybrid search path
|
|
316
|
+
const rawResults = [];
|
|
317
|
+
for (const res of annResults) {
|
|
318
|
+
const item = this.items[res.id];
|
|
319
|
+
if (filter && !filter(item.metadata))
|
|
320
|
+
continue;
|
|
321
|
+
const searchableText = this.getSearchableText(item);
|
|
322
|
+
const semantic = 1 - res.distance; // distance to similarity
|
|
323
|
+
const fuzzy = fuzzyScore(query, searchableText);
|
|
324
|
+
const keyword = keywordScore(query, searchableText);
|
|
325
|
+
rawResults.push({ index: res.id, item, semantic, fuzzy, keyword });
|
|
179
326
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
:
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
327
|
+
// Calculate score statistics for normalization
|
|
328
|
+
const stats = calculateScoreStats(rawResults);
|
|
329
|
+
// Second pass: normalize scores and compute hybrid score
|
|
330
|
+
const results = [];
|
|
331
|
+
for (const raw of rawResults) {
|
|
332
|
+
let semantic = raw.semantic;
|
|
333
|
+
let fuzzy = raw.fuzzy;
|
|
334
|
+
let keyword = raw.keyword;
|
|
335
|
+
// Normalize scores if enabled
|
|
336
|
+
if (this.config.normalizeScores) {
|
|
337
|
+
semantic = normalizeScore(raw.semantic, stats.semantic.min, stats.semantic.max);
|
|
338
|
+
fuzzy = normalizeScore(raw.fuzzy, stats.fuzzy.min, stats.fuzzy.max);
|
|
339
|
+
keyword = normalizeScore(raw.keyword, stats.keyword.min, stats.keyword.max);
|
|
340
|
+
}
|
|
341
|
+
const score = hybridScore(semantic, fuzzy, keyword, this.config.weights);
|
|
342
|
+
// Apply threshold filter
|
|
343
|
+
if (score < threshold)
|
|
344
|
+
continue;
|
|
345
|
+
results.push({
|
|
346
|
+
id: raw.item.id,
|
|
347
|
+
text: raw.item.text,
|
|
348
|
+
metadata: raw.item.metadata,
|
|
349
|
+
score,
|
|
350
|
+
explain: explain
|
|
351
|
+
? {
|
|
352
|
+
semantic,
|
|
353
|
+
fuzzy,
|
|
354
|
+
keyword,
|
|
355
|
+
raw: {
|
|
356
|
+
semantic: raw.semantic,
|
|
357
|
+
fuzzy: raw.fuzzy,
|
|
358
|
+
keyword: raw.keyword,
|
|
359
|
+
},
|
|
360
|
+
}
|
|
361
|
+
: undefined,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
// Sort by relevance (highest score first)
|
|
365
|
+
return results.sort((a, b) => b.score - a.score).slice(0, topK);
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
// Fallback to linear scan
|
|
369
|
+
const rawResults = [];
|
|
370
|
+
for (let i = 0; i < this.items.length; i++) {
|
|
371
|
+
const item = this.items[i];
|
|
372
|
+
if (filter && !filter(item.metadata))
|
|
373
|
+
continue;
|
|
374
|
+
const searchableText = this.getSearchableText(item);
|
|
375
|
+
const semantic = cosine(qVector, this.vectors[i]);
|
|
376
|
+
const fuzzy = fuzzyScore(query, searchableText);
|
|
377
|
+
const keyword = keywordScore(query, searchableText);
|
|
378
|
+
rawResults.push({ index: i, item, semantic, fuzzy, keyword });
|
|
379
|
+
}
|
|
380
|
+
// Calculate score statistics for normalization
|
|
381
|
+
const stats = calculateScoreStats(rawResults);
|
|
382
|
+
// Second pass: normalize scores and compute hybrid score
|
|
383
|
+
const results = [];
|
|
384
|
+
for (const raw of rawResults) {
|
|
385
|
+
let semantic = raw.semantic;
|
|
386
|
+
let fuzzy = raw.fuzzy;
|
|
387
|
+
let keyword = raw.keyword;
|
|
388
|
+
// Normalize scores if enabled
|
|
389
|
+
if (this.config.normalizeScores) {
|
|
390
|
+
semantic = normalizeScore(raw.semantic, stats.semantic.min, stats.semantic.max);
|
|
391
|
+
fuzzy = normalizeScore(raw.fuzzy, stats.fuzzy.min, stats.fuzzy.max);
|
|
392
|
+
keyword = normalizeScore(raw.keyword, stats.keyword.min, stats.keyword.max);
|
|
393
|
+
}
|
|
394
|
+
const score = hybridScore(semantic, fuzzy, keyword, this.config.weights);
|
|
395
|
+
// Apply threshold filter
|
|
396
|
+
if (score < threshold)
|
|
397
|
+
continue;
|
|
398
|
+
results.push({
|
|
399
|
+
id: raw.item.id,
|
|
400
|
+
text: raw.item.text,
|
|
401
|
+
metadata: raw.item.metadata,
|
|
402
|
+
score,
|
|
403
|
+
explain: explain
|
|
404
|
+
? {
|
|
405
|
+
semantic,
|
|
406
|
+
fuzzy,
|
|
407
|
+
keyword,
|
|
408
|
+
raw: {
|
|
409
|
+
semantic: raw.semantic,
|
|
410
|
+
fuzzy: raw.fuzzy,
|
|
411
|
+
keyword: raw.keyword,
|
|
412
|
+
},
|
|
413
|
+
}
|
|
414
|
+
: undefined,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
// Sort by relevance (highest score first)
|
|
418
|
+
return results.sort((a, b) => b.score - a.score).slice(0, topK);
|
|
419
|
+
}
|
|
205
420
|
}
|
|
206
421
|
}
|