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/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
- const PACKAGE_VERSION = "0.3.2";
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
- // Extract text using paths if configured
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 = await embedBatch(texts, model);
34
- return new Simile(items, vectors, config);
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
- const newVectors = await embedBatch(texts, this.config.model);
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 embed(query, this.config.model);
154
- // First pass: calculate raw scores
155
- const rawResults = [];
156
- for (let i = 0; i < this.items.length; i++) {
157
- const item = this.items[i];
158
- if (filter && !filter(item.metadata))
159
- continue;
160
- const searchableText = this.getSearchableText(item);
161
- const semantic = cosine(qVector, this.vectors[i]);
162
- const fuzzy = fuzzyScore(query, searchableText);
163
- const keyword = keywordScore(query, searchableText);
164
- rawResults.push({ index: i, item, semantic, fuzzy, keyword });
165
- }
166
- // Calculate score statistics for normalization
167
- const stats = calculateScoreStats(rawResults);
168
- // Second pass: normalize scores and compute hybrid score
169
- const results = [];
170
- for (const raw of rawResults) {
171
- let semantic = raw.semantic;
172
- let fuzzy = raw.fuzzy;
173
- let keyword = raw.keyword;
174
- // Normalize scores if enabled
175
- if (this.config.normalizeScores) {
176
- semantic = normalizeScore(raw.semantic, stats.semantic.min, stats.semantic.max);
177
- fuzzy = normalizeScore(raw.fuzzy, stats.fuzzy.min, stats.fuzzy.max);
178
- keyword = normalizeScore(raw.keyword, stats.keyword.min, stats.keyword.max);
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
- const score = hybridScore(semantic, fuzzy, keyword, this.config.weights);
181
- // Apply threshold filter
182
- if (score < threshold)
183
- continue;
184
- results.push({
185
- id: raw.item.id,
186
- text: raw.item.text,
187
- metadata: raw.item.metadata,
188
- score,
189
- explain: explain
190
- ? {
191
- semantic,
192
- fuzzy,
193
- keyword,
194
- raw: {
195
- semantic: raw.semantic,
196
- fuzzy: raw.fuzzy,
197
- keyword: raw.keyword,
198
- },
199
- }
200
- : undefined,
201
- });
202
- }
203
- // Sort by relevance (highest score first)
204
- return results.sort((a, b) => b.score - a.score).slice(0, topK);
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
  }