@context-vault/core 2.14.0 → 2.17.0

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.
@@ -7,10 +7,27 @@
7
7
  * Agent Constraint: Read-only access to DB. Never writes.
8
8
  */
9
9
 
10
- const FTS_WEIGHT = 0.4;
11
- const VEC_WEIGHT = 0.6;
12
10
  const NEAR_DUP_THRESHOLD = 0.92;
13
11
 
12
+ const RRF_K = 60;
13
+
14
+ const MMR_LAMBDA = 0.7;
15
+
16
+ /**
17
+ * Exponential recency decay score based on updated_at timestamp.
18
+ * Returns e^(-decayRate * ageDays) for valid dates, or 0.5 as a neutral
19
+ * score when updatedAt is null/undefined.
20
+ *
21
+ * @param {string|null|undefined} updatedAt - ISO timestamp
22
+ * @param {number} decayRate - Decay rate per day (default 0.05)
23
+ * @returns {number} Score in [0, 1]
24
+ */
25
+ export function recencyDecayScore(updatedAt, decayRate = 0.05) {
26
+ if (updatedAt == null) return 0.5;
27
+ const ageDays = (Date.now() - new Date(updatedAt).getTime()) / 86400000;
28
+ return Math.exp(-decayRate * ageDays);
29
+ }
30
+
14
31
  /**
15
32
  * Dot product of two Float32Array vectors (cosine similarity for unit vectors).
16
33
  */
@@ -57,6 +74,7 @@ export function recencyBoost(createdAt, category, decayDays = 30) {
57
74
  */
58
75
  export function buildFilterClauses({
59
76
  categoryFilter,
77
+ excludeEvents = false,
60
78
  since,
61
79
  until,
62
80
  userIdFilter,
@@ -77,6 +95,9 @@ export function buildFilterClauses({
77
95
  clauses.push("e.category = ?");
78
96
  params.push(categoryFilter);
79
97
  }
98
+ if (excludeEvents && !categoryFilter) {
99
+ clauses.push("e.category != 'event'");
100
+ }
80
101
  if (since) {
81
102
  clauses.push("e.created_at >= ?");
82
103
  params.push(since);
@@ -93,7 +114,126 @@ export function buildFilterClauses({
93
114
  }
94
115
 
95
116
  /**
96
- * Hybrid search combining FTS5 text matching and vector similarity.
117
+ * Reciprocal Rank Fusion: merge multiple ranked lists into a single score.
118
+ * Each document receives 1/(k + rank) from each list it appears in.
119
+ *
120
+ * @param {Array<string[]>} rankedLists - Arrays of document IDs in rank order (best first).
121
+ * @param {number} k - Smoothing constant (default RRF_K = 60).
122
+ * @returns {Map<string, number>} Map of id -> RRF score.
123
+ */
124
+ export function reciprocalRankFusion(rankedLists, k = RRF_K) {
125
+ const scores = new Map();
126
+ for (const list of rankedLists) {
127
+ for (let rank = 0; rank < list.length; rank++) {
128
+ const id = list[rank];
129
+ scores.set(id, (scores.get(id) ?? 0) + 1 / (k + rank + 1));
130
+ }
131
+ }
132
+ return scores;
133
+ }
134
+
135
+ /**
136
+ * Jaccard similarity between two strings based on word sets.
137
+ * Used as a fallback for MMR when embedding vectors are unavailable.
138
+ *
139
+ * @param {string} a
140
+ * @param {string} b
141
+ * @returns {number} Similarity in [0, 1].
142
+ */
143
+ export function jaccardSimilarity(a, b) {
144
+ const wordsA = new Set((a ?? "").toLowerCase().split(/\W+/).filter(Boolean));
145
+ const wordsB = new Set((b ?? "").toLowerCase().split(/\W+/).filter(Boolean));
146
+ if (wordsA.size === 0 && wordsB.size === 0) return 1;
147
+ if (wordsA.size === 0 || wordsB.size === 0) return 0;
148
+ let intersection = 0;
149
+ for (const w of wordsA) if (wordsB.has(w)) intersection++;
150
+ return intersection / (wordsA.size + wordsB.size - intersection);
151
+ }
152
+
153
+ /**
154
+ * Maximal Marginal Relevance reranking.
155
+ *
156
+ * Selects up to n candidates that balance relevance to the query and
157
+ * diversity from already-selected results.
158
+ *
159
+ * MMR_score = lambda * querySim(doc) - (1 - lambda) * max(sim(doc, selected))
160
+ *
161
+ * @param {Array<object>} candidates - Entries with at least {id, title, body}.
162
+ * @param {Map<string, number>} querySimMap - Map of id -> relevance score.
163
+ * @param {Map<string, Float32Array|null>} embeddingMap - Map of id -> embedding (null if unavailable).
164
+ * @param {number} n - Number of results to select.
165
+ * @param {number} lambda - Trade-off weight (default MMR_LAMBDA = 0.7).
166
+ * @returns {Array<object>} Reranked subset of candidates (length <= n).
167
+ */
168
+ export function maximalMarginalRelevance(
169
+ candidates,
170
+ querySimMap,
171
+ embeddingMap,
172
+ n,
173
+ lambda = MMR_LAMBDA,
174
+ ) {
175
+ if (candidates.length === 0) return [];
176
+
177
+ const remaining = [...candidates];
178
+ const selected = [];
179
+ const selectedVecs = [];
180
+ const selectedEntries = [];
181
+
182
+ while (selected.length < n && remaining.length > 0) {
183
+ let bestIdx = -1;
184
+ let bestScore = -Infinity;
185
+
186
+ for (let i = 0; i < remaining.length; i++) {
187
+ const candidate = remaining[i];
188
+ const relevance = querySimMap.get(candidate.id) ?? 0;
189
+
190
+ let maxRedundancy = 0;
191
+ if (selectedVecs.length > 0) {
192
+ const vec = embeddingMap.get(candidate.id);
193
+ for (let j = 0; j < selectedVecs.length; j++) {
194
+ let sim;
195
+ if (vec && selectedVecs[j]) {
196
+ sim = dotProduct(vec, selectedVecs[j]);
197
+ } else {
198
+ const selEntry = selectedEntries[j];
199
+ sim = jaccardSimilarity(
200
+ `${candidate.title} ${candidate.body}`,
201
+ `${selEntry.title} ${selEntry.body}`,
202
+ );
203
+ }
204
+ if (sim > maxRedundancy) maxRedundancy = sim;
205
+ }
206
+ }
207
+
208
+ const score = lambda * relevance - (1 - lambda) * maxRedundancy;
209
+ if (score > bestScore) {
210
+ bestScore = score;
211
+ bestIdx = i;
212
+ }
213
+ }
214
+
215
+ if (bestIdx === -1) break;
216
+
217
+ const chosen = remaining.splice(bestIdx, 1)[0];
218
+ selected.push(chosen);
219
+ selectedVecs.push(embeddingMap.get(chosen.id) ?? null);
220
+ selectedEntries.push(chosen);
221
+ }
222
+
223
+ return selected;
224
+ }
225
+
226
+ /**
227
+ * Hybrid search combining FTS5 text matching and vector similarity,
228
+ * with RRF merging and MMR reranking for diversity.
229
+ *
230
+ * Pipeline:
231
+ * 1. FTS5 ranked list
232
+ * 2. Vector (semantic) ranked list
233
+ * 3. RRF: merge the two ranked lists into a single score
234
+ * 4. Apply recency decay to RRF scores
235
+ * 5. MMR: rerank top candidates for diversity (uses embeddings or Jaccard fallback)
236
+ * 6. Near-duplicate suppression on the final selection
97
237
  *
98
238
  * @param {import('../server/types.js').BaseCtx} ctx
99
239
  * @param {string} query
@@ -106,6 +246,7 @@ export async function hybridSearch(
106
246
  {
107
247
  kindFilter = null,
108
248
  categoryFilter = null,
249
+ excludeEvents = false,
109
250
  since = null,
110
251
  until = null,
111
252
  limit = 20,
@@ -116,11 +257,13 @@ export async function hybridSearch(
116
257
  includeSuperseeded = false,
117
258
  } = {},
118
259
  ) {
119
- const results = new Map();
260
+ const rowMap = new Map();
120
261
  const idToRowid = new Map();
121
262
  let queryVec = null;
263
+
122
264
  const extraFilters = buildFilterClauses({
123
265
  categoryFilter,
266
+ excludeEvents,
124
267
  since,
125
268
  until,
126
269
  userIdFilter,
@@ -128,7 +271,9 @@ export async function hybridSearch(
128
271
  includeSuperseeded,
129
272
  });
130
273
 
131
- // FTS5 search
274
+ const ftsRankedIds = [];
275
+
276
+ // Stage 1a: FTS5 — collect ranked list of IDs
132
277
  const ftsQuery = buildFtsQuery(query);
133
278
  if (ftsQuery) {
134
279
  try {
@@ -145,25 +290,21 @@ export async function hybridSearch(
145
290
  const ftsSQL = `SELECT e.*, rank FROM vault_fts f JOIN vault e ON f.rowid = e.rowid WHERE ${whereParts.join(" AND ")} ORDER BY rank LIMIT 15`;
146
291
  const rows = ctx.db.prepare(ftsSQL).all(...ftsParams);
147
292
 
148
- // Normalize FTS scores to [0, 1]
149
- const ftsScores = rows.map((r) => Math.abs(r.rank || 0));
150
- const maxFts = Math.max(...ftsScores, 1);
151
-
152
- for (let i = 0; i < rows.length; i++) {
153
- const { rank: _rank, ...row } = rows[i];
154
- const normalized = ftsScores[i] / maxFts;
155
- results.set(row.id, { ...row, score: normalized * FTS_WEIGHT });
293
+ for (const { rank: _rank, ...row } of rows) {
294
+ ftsRankedIds.push(row.id);
295
+ if (!rowMap.has(row.id)) rowMap.set(row.id, row);
156
296
  }
157
297
  } catch (err) {
158
- if (err.message?.includes("fts5: syntax error")) {
159
- // Expected: malformed query, fall through to vector search
160
- } else {
298
+ if (!err.message?.includes("fts5: syntax error")) {
161
299
  console.error(`[retrieve] FTS search error: ${err.message}`);
162
300
  }
163
301
  }
164
302
  }
165
303
 
166
- // Vector similarity search (skipped if embedding unavailable)
304
+ const vecRankedIds = [];
305
+ const vecSimMap = new Map();
306
+
307
+ // Stage 1b: Vector similarity — collect ranked list of IDs and raw similarity scores
167
308
  try {
168
309
  const vecCount = ctx.db
169
310
  .prepare("SELECT COUNT(*) as c FROM vault_vec")
@@ -171,7 +312,6 @@ export async function hybridSearch(
171
312
  if (vecCount > 0) {
172
313
  queryVec = await ctx.embed(query);
173
314
  if (queryVec) {
174
- // Increase limits in hosted mode to compensate for post-filtering
175
315
  const hasPostFilter = userIdFilter !== undefined || teamIdFilter;
176
316
  const vecLimit = hasPostFilter
177
317
  ? kindFilter
@@ -187,7 +327,6 @@ export async function hybridSearch(
187
327
  .all(queryVec, vecLimit);
188
328
 
189
329
  if (vecRows.length) {
190
- // Batch hydration: single query instead of N+1
191
330
  const rowids = vecRows.map((vr) => vr.rowid);
192
331
  const placeholders = rowids.map(() => "?").join(",");
193
332
  const hydrated = ctx.db
@@ -207,6 +346,7 @@ export async function hybridSearch(
207
346
  if (teamIdFilter && row.team_id !== teamIdFilter) continue;
208
347
  if (kindFilter && row.kind !== kindFilter) continue;
209
348
  if (categoryFilter && row.category !== categoryFilter) continue;
349
+ if (excludeEvents && row.category === "event") continue;
210
350
  if (since && row.created_at < since) continue;
211
351
  if (until && row.created_at > until) continue;
212
352
  if (row.expires_at && new Date(row.expires_at) <= new Date())
@@ -214,70 +354,111 @@ export async function hybridSearch(
214
354
 
215
355
  const { rowid: _rowid, ...cleanRow } = row;
216
356
  idToRowid.set(cleanRow.id, Number(row.rowid));
357
+
217
358
  // sqlite-vec returns L2 distance [0, 2] for normalized vectors.
218
- // Convert to similarity [1, 0] with: 1 - distance/2
219
- const vecScore = Math.max(0, 1 - vr.distance / 2) * VEC_WEIGHT;
220
- const existing = results.get(cleanRow.id);
221
- if (existing) {
222
- existing.score += vecScore;
223
- } else {
224
- results.set(cleanRow.id, { ...cleanRow, score: vecScore });
225
- }
359
+ // Convert to similarity [0, 1]: 1 - distance/2
360
+ const vecSim = Math.max(0, 1 - vr.distance / 2);
361
+ vecSimMap.set(cleanRow.id, vecSim);
362
+ vecRankedIds.push(cleanRow.id);
363
+
364
+ if (!rowMap.has(cleanRow.id)) rowMap.set(cleanRow.id, cleanRow);
226
365
  }
227
366
  }
228
367
  }
229
368
  }
230
369
  } catch (err) {
231
- if (err.message?.includes("no such table")) {
232
- // Expected on fresh vaults with no vec table yet
233
- } else {
370
+ if (!err.message?.includes("no such table")) {
234
371
  console.error(`[retrieve] Vector search error: ${err.message}`);
235
372
  }
236
373
  }
237
374
 
238
- // Apply category-aware recency boost
239
- for (const [, entry] of results) {
240
- entry.score *= recencyBoost(entry.created_at, entry.category, decayDays);
375
+ if (rowMap.size === 0) return [];
376
+
377
+ // Stage 2: RRF — merge FTS and vector ranked lists into a single score
378
+ const rrfScores = reciprocalRankFusion([ftsRankedIds, vecRankedIds]);
379
+
380
+ // Stage 3: Apply category-aware recency boost to RRF scores
381
+ for (const [id, entry] of rowMap) {
382
+ const boost = recencyBoost(entry.created_at, entry.category, decayDays);
383
+ rrfScores.set(id, (rrfScores.get(id) ?? 0) * boost);
241
384
  }
242
385
 
243
- const sorted = [...results.values()].sort((a, b) => b.score - a.score);
386
+ // Stage 3b: Frequency signal — log(1 + hit_count) / log(1 + max_hit_count)
387
+ const allRows = [...rowMap.values()];
388
+ const maxHitCount = Math.max(...allRows.map((e) => e.hit_count || 0), 0);
389
+ if (maxHitCount > 0) {
390
+ const logMax = Math.log(1 + maxHitCount);
391
+ for (const entry of allRows) {
392
+ const freqScore = Math.log(1 + (entry.hit_count || 0)) / logMax;
393
+ rrfScores.set(
394
+ entry.id,
395
+ (rrfScores.get(entry.id) ?? 0) + freqScore * 0.13,
396
+ );
397
+ }
398
+ }
244
399
 
245
- // Near-duplicate suppression: when embeddings are available and we have more
246
- // candidates than needed, skip results that are too similar to already-selected ones.
247
- if (queryVec && idToRowid.size > 0 && sorted.length > limit) {
248
- const rowidsToFetch = sorted
249
- .filter((c) => idToRowid.has(c.id))
250
- .map((c) => idToRowid.get(c.id));
400
+ // Attach final score to each entry and sort by RRF score descending
401
+ const candidates = [...rowMap.values()].map((entry) => ({
402
+ ...entry,
403
+ score: rrfScores.get(entry.id) ?? 0,
404
+ }));
405
+ candidates.sort((a, b) => b.score - a.score);
251
406
 
252
- const embeddingMap = new Map();
253
- if (rowidsToFetch.length > 0) {
254
- try {
255
- const placeholders = rowidsToFetch.map(() => "?").join(",");
256
- const vecData = ctx.db
257
- .prepare(
258
- `SELECT rowid, embedding FROM vault_vec WHERE rowid IN (${placeholders})`,
259
- )
260
- .all(...rowidsToFetch);
261
- for (const row of vecData) {
262
- const buf = row.embedding;
263
- if (buf) {
264
- embeddingMap.set(
265
- Number(row.rowid),
266
- new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4),
267
- );
268
- }
407
+ // Stage 4: Fetch embeddings for all candidates that have a rowid
408
+ const embeddingMap = new Map();
409
+ if (queryVec && idToRowid.size > 0) {
410
+ const rowidToId = new Map();
411
+ for (const [id, rowid] of idToRowid) rowidToId.set(rowid, id);
412
+
413
+ const rowidsToFetch = [...idToRowid.values()];
414
+ try {
415
+ const placeholders = rowidsToFetch.map(() => "?").join(",");
416
+ const vecData = ctx.db
417
+ .prepare(
418
+ `SELECT rowid, embedding FROM vault_vec WHERE rowid IN (${placeholders})`,
419
+ )
420
+ .all(...rowidsToFetch);
421
+ for (const row of vecData) {
422
+ const id = rowidToId.get(Number(row.rowid));
423
+ const buf = row.embedding;
424
+ if (id && buf) {
425
+ embeddingMap.set(
426
+ id,
427
+ new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4),
428
+ );
269
429
  }
270
- } catch (_) {
271
- return sorted.slice(offset, offset + limit);
272
430
  }
431
+ } catch (_) {
432
+ // Embeddings unavailable — MMR will fall back to Jaccard similarity
273
433
  }
434
+ }
435
+
436
+ // Use vecSim as the query-relevance signal for MMR; fall back to RRF score
437
+ const querySimMap = new Map();
438
+ for (const candidate of candidates) {
439
+ querySimMap.set(
440
+ candidate.id,
441
+ vecSimMap.has(candidate.id)
442
+ ? vecSimMap.get(candidate.id)
443
+ : candidate.score,
444
+ );
445
+ }
274
446
 
447
+ // Stage 5: MMR — rerank for diversity using embeddings or Jaccard fallback
448
+ const mmrSelected = maximalMarginalRelevance(
449
+ candidates,
450
+ querySimMap,
451
+ embeddingMap,
452
+ offset + limit,
453
+ );
454
+
455
+ // Stage 6: Near-duplicate suppression (hard filter, not reorder)
456
+ if (queryVec && embeddingMap.size > 0 && mmrSelected.length > limit) {
275
457
  const selected = [];
276
458
  const selectedVecs = [];
277
- for (const candidate of sorted) {
459
+ for (const candidate of mmrSelected) {
278
460
  if (selected.length >= offset + limit) break;
279
- const rowid = idToRowid.get(candidate.id);
280
- const vec = rowid !== undefined ? embeddingMap.get(rowid) : null;
461
+ const vec = embeddingMap.get(candidate.id);
281
462
  if (vec && selectedVecs.length > 0) {
282
463
  let maxSim = 0;
283
464
  for (const sv of selectedVecs) {
@@ -289,8 +470,31 @@ export async function hybridSearch(
289
470
  selected.push(candidate);
290
471
  if (vec) selectedVecs.push(vec);
291
472
  }
292
- return selected.slice(offset, offset + limit);
473
+ const dedupedPage = selected.slice(offset, offset + limit);
474
+ trackAccess(ctx.db, dedupedPage);
475
+ return dedupedPage;
293
476
  }
294
477
 
295
- return sorted.slice(offset, offset + limit);
478
+ const finalPage = mmrSelected.slice(offset, offset + limit);
479
+ trackAccess(ctx.db, finalPage);
480
+ return finalPage;
481
+ }
482
+
483
+ /**
484
+ * Increment hit_count and set last_accessed_at for a batch of retrieved entries.
485
+ * Single batched UPDATE for efficiency.
486
+ *
487
+ * @param {import('node:sqlite').DatabaseSync} db
488
+ * @param {Array<{id: string}>} entries
489
+ */
490
+ function trackAccess(db, entries) {
491
+ if (!entries.length) return;
492
+ try {
493
+ const placeholders = entries.map(() => "?").join(",");
494
+ db.prepare(
495
+ `UPDATE vault SET hit_count = hit_count + 1, last_accessed_at = datetime('now') WHERE id IN (${placeholders})`,
496
+ ).run(...entries.map((e) => e.id));
497
+ } catch (_) {
498
+ // Non-fatal: frequency tracking is best-effort
499
+ }
296
500
  }
@@ -146,6 +146,13 @@ export function handler(_args, ctx) {
146
146
  for (const w of growth.warnings) {
147
147
  lines.push(` ${w.message}`);
148
148
  }
149
+ if (growth.kindBreakdown.length) {
150
+ lines.push("");
151
+ lines.push(" Breakdown by kind:");
152
+ for (const { kind, count, pct } of growth.kindBreakdown) {
153
+ lines.push(` ${kind}: ${count.toLocaleString()} (${pct}%)`);
154
+ }
155
+ }
149
156
  if (growth.actions.length) {
150
157
  lines.push("", "Suggested growth actions:");
151
158
  for (const a of growth.actions) {
@@ -20,6 +20,12 @@ export const inputSchema = {
20
20
  .array(z.string())
21
21
  .optional()
22
22
  .describe("Optional tag filters — entries must match at least one"),
23
+ buckets: z
24
+ .array(z.string())
25
+ .optional()
26
+ .describe(
27
+ "Filter by project-scoped buckets. Each name expands to a 'bucket:<name>' tag. Composes with 'tags' via OR (entries matching any tag or any bucket are included).",
28
+ ),
23
29
  kinds: z
24
30
  .array(z.string())
25
31
  .optional()
@@ -99,7 +105,7 @@ function slugifyTopic(topic) {
99
105
  }
100
106
 
101
107
  export async function handler(
102
- { topic, tags, kinds, identity_key },
108
+ { topic, tags, buckets, kinds, identity_key },
103
109
  ctx,
104
110
  { ensureIndexed },
105
111
  ) {
@@ -116,6 +122,9 @@ export async function handler(
116
122
  await ensureIndexed();
117
123
 
118
124
  const normalizedKinds = kinds?.map(normalizeKind) ?? [];
125
+ // Expand buckets to bucket: prefixed tags and merge with explicit tags
126
+ const bucketTags = buckets?.length ? buckets.map((b) => `bucket:${b}`) : [];
127
+ const effectiveTags = [...(tags ?? []), ...bucketTags];
119
128
 
120
129
  let candidates = [];
121
130
 
@@ -143,10 +152,10 @@ export async function handler(
143
152
  });
144
153
  }
145
154
 
146
- if (tags?.length) {
155
+ if (effectiveTags.length) {
147
156
  candidates = candidates.filter((r) => {
148
157
  const entryTags = r.tags ? JSON.parse(r.tags) : [];
149
- return tags.some((t) => entryTags.includes(t));
158
+ return effectiveTags.some((t) => entryTags.includes(t));
150
159
  });
151
160
  }
152
161