@lincoln504/pi-research 1.6.18 → 1.6.20

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/src/index.ts CHANGED
@@ -91,9 +91,11 @@ function extractResultText(result: AgentToolResult<unknown>): string {
91
91
  * Pi Research Extension
92
92
  */
93
93
  export default async function (pi: ExtensionAPI) {
94
- // Runtime version check — must match the @earendil-works/* dependency minimum (>=0.84.0).
95
- // The floor is 0.84.0 because setRuntimeApiKey() lost its `allowNetwork` option there
96
- // and only 0.84.0+ hardcodes the equivalent guard internally (see pi-version.ts). The
94
+ // Runtime version check — must match the @earendil-works/* dependency minimum (>=0.87.0).
95
+ // The floor was raised to 0.84.0 because setRuntimeApiKey() lost its `allowNetwork` option
96
+ // there and only 0.84.0+ hardcodes the equivalent guard internally (see pi-version.ts), then
97
+ // to 0.85.0 to track the pi extension package line, and to 0.87.0 for the 0.86
98
+ // TranscriptContext / 0.87 SessionEntry-union surface (see pi-version.ts). The
97
99
  // APIs it rests on arrived in 0.80.8, which introduced ModelRuntime and removed
98
100
  // AuthStorage/ModelRegistry.create(); buildModelRegistry (model-registry-factory.ts)
99
101
  // unconditionally calls ModelRuntime.create(), and createAgentSession() is invoked
@@ -2699,6 +2699,13 @@ var ConfigSchema = Type.Object({
2699
2699
  WORKER_CONCURRENCY: Type.Number({ minimum: 1, maximum: 10, default: 2 }),
2700
2700
  /** Knowledge store isolation mode (default: 'global' — one shared store across every directory) */
2701
2701
  KNOWLEDGE_STORE_MODE: Type.Union([Type.Literal("none"), Type.Literal("project"), Type.Literal("global")], { default: "global" }),
2702
+ /** Knowledge store retrieval strategy (default: 'vector' — current hybrid dense+sparse behavior).
2703
+ * 'vector' = hybrid search (embedding similarity + BM25/FTS fused via RRF; requires the
2704
+ * optional @huggingface/transformers dependency and a downloaded embedding model).
2705
+ * 'bm25' = pure lexical/BM25 retrieval over the same stored findings — NO embedding model:
2706
+ * never initialized, downloaded, or invoked. Uses a separate lexical table in the same
2707
+ * LanceDB database, so existing vector stores are untouched (no migration). */
2708
+ KNOWLEDGE_STORE_RETRIEVAL: Type.Union([Type.Literal("vector"), Type.Literal("bm25")], { default: "vector" }),
2702
2709
  /** Embedding model to use for the knowledge store */
2703
2710
  EMBEDDING_MODEL: Type.String({ default: "onnx-community/granite-embedding-small-english-r2-ONNX" }),
2704
2711
  /** Hardware backend for embeddings: 'auto' (probe WebGPU viability out-of-process, fall back to CPU), 'webgpu' (force), or 'cpu' (force).
@@ -50,6 +50,10 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
50
50
  // mode change (project↔global re-scope, or enabled→none) and re-initialize so a /research-config
51
51
  // change applies without a Pi restart — not just the DISABLED→enabled revival.
52
52
  private _initializedMode: string | undefined = undefined;
53
+ // The retrieval strategy the current build reflects ('vector' hybrid / 'bm25'
54
+ // lexical) — drives the embedder-optional invariants below and the live
55
+ // re-init detection on a /research-config retrieval change.
56
+ private _initializedRetrieval: 'vector' | 'bm25' | undefined = undefined;
53
57
 
54
58
  // Initialization promise to prevent concurrent initialization
55
59
  private _initializationPromise: Promise<void> | null = null;
@@ -88,7 +92,12 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
88
92
  // - INITIALIZED and mode != the built mode : project↔global re-scope, or enabled→'none'.
89
93
  // A native-unavailable DISABLED ('native') stays memoized — retrying is futile on that platform.
90
94
  if ((this.lifecycle === ServiceLifecycle.INITIALIZED || this.lifecycle === ServiceLifecycle.DISABLED) && this._cwd === newCwd) {
91
- const liveMode: string = (ctx?.config || getConfig(this._cwd)).KNOWLEDGE_STORE_MODE;
95
+ const liveConfig = ctx?.config || getConfig(this._cwd);
96
+ const liveMode: string = liveConfig.KNOWLEDGE_STORE_MODE;
97
+ // Retrieval strategy ('vector' hybrid / 'bm25' lexical) participates in the same
98
+ // live-change detection: a /research-config switch must re-scope the store onto
99
+ // the OTHER table (lexical vs vector schema) without a Pi restart.
100
+ const liveRetrieval: string = liveConfig.KNOWLEDGE_STORE_RETRIEVAL;
92
101
  // Reviving a DISABLED store creates handles (no live state to tear down), so it is safe even
93
102
  // from a lazy call and uses the live config. Re-scoping/disabling a LIVE (INITIALIZED) store,
94
103
  // however, disposes handles — restrict that to an EXPLICIT ctx.config (mirrors the ctx.cwd
@@ -101,10 +110,15 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
101
110
  ctx?.config !== undefined &&
102
111
  this._initializedMode !== undefined &&
103
112
  this._initializedMode !== ctx.config.KNOWLEDGE_STORE_MODE;
104
- if (!reviveFromMode && !modeChanged) {
113
+ const retrievalChanged =
114
+ this.lifecycle === ServiceLifecycle.INITIALIZED &&
115
+ ctx?.config !== undefined &&
116
+ this._initializedRetrieval !== undefined &&
117
+ this._initializedRetrieval !== ctx.config.KNOWLEDGE_STORE_RETRIEVAL;
118
+ if (!reviveFromMode && !modeChanged && !retrievalChanged) {
105
119
  return;
106
120
  }
107
- logger.log(`[KnowledgeStoreService] Knowledge Mode changed (${this._initializedMode ?? this._disabledReason} → ${liveMode}); re-initializing store (no restart needed).`);
121
+ logger.log(`[KnowledgeStoreService] Knowledge config changed (${this._initializedMode ?? this._disabledReason} → ${liveMode}, retrieval ${this._initializedRetrieval ?? '?'} → ${liveRetrieval}); re-initializing store (no restart needed).`);
108
122
  // An INITIALIZED store holds live handles (embedder/LanceDB/writer) — dispose before rebuilding.
109
123
  if (this.lifecycle === ServiceLifecycle.INITIALIZED) {
110
124
  await this.dispose();
@@ -170,6 +184,7 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
170
184
  const config = ctx?.config || getConfig(this._cwd);
171
185
  // Record the mode this build reflects so a later runtime mode change is detected above.
172
186
  this._initializedMode = config.KNOWLEDGE_STORE_MODE;
187
+ this._initializedRetrieval = config.KNOWLEDGE_STORE_RETRIEVAL;
173
188
  const embedderFactory = () => getEmbedder(config);
174
189
  const reconnectFactory = async () => {
175
190
  // MUST await: clearEmbeddingInstance() only nulls the cached instance AFTER its
@@ -260,7 +275,7 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
260
275
  // calls don't re-run init, and so the healthcheck/config surfaces
261
276
  // report the package-level reason rather than a user choice the user
262
277
  // never made.
263
- const depsMissing = !probeKnowledgeStoreAvailability().available;
278
+ const depsMissing = !probeKnowledgeStoreAvailability(config.KNOWLEDGE_STORE_RETRIEVAL).available;
264
279
  logger.debug(`[KnowledgeStoreService] Knowledge store is disabled (${depsMissing ? 'dependencies missing' : 'mode = none'}). Setting lifecycle to DISABLED.`);
265
280
  this.lifecycle = ServiceLifecycle.DISABLED;
266
281
  this._disabledReason = depsMissing ? 'native' : 'mode';
@@ -431,6 +446,11 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
431
446
  * Check if the knowledge store is ready
432
447
  */
433
448
  isReady(): boolean {
449
+ // bm25 mode has no embedder by design — the store+writer pair is the readiness
450
+ // contract there. Vector mode keeps the original three-way check.
451
+ if (this._initializedRetrieval === 'bm25') {
452
+ return this._store !== null && this._writerQueue !== null;
453
+ }
434
454
  return this._embedder !== null && this._store !== null && this._writerQueue !== null;
435
455
  }
436
456
 
@@ -458,6 +478,11 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
458
478
  if (this.lifecycle === ServiceLifecycle.DISABLED) {
459
479
  return null;
460
480
  }
481
+ if (this._initializedRetrieval === 'bm25') {
482
+ // Lexical mode has no embedder — null is the CORRECT answer here, not an
483
+ // error. Callers must treat it as 'no embedding model configured'.
484
+ return null;
485
+ }
461
486
  if (!this._embedder) {
462
487
  throw new Error('[KnowledgeStoreService] Embedder not initialized');
463
488
  }
@@ -46,17 +46,36 @@ export interface KnowledgeStoreAvailability {
46
46
  }
47
47
 
48
48
  /**
49
- * The packages the store cannot run without. `@lancedb/lancedb` is the vector
50
- * store itself; `@huggingface/transformers` is the only embedding backend
51
- * (see transformers-loader.ts — there is no remote-embedding fallback).
49
+ * The packages the store cannot run without, per retrieval mode.
50
+ *
51
+ * Every mode needs `@lancedb/lancedb` — it backs the store in ALL modes (its
52
+ * native FTS/Tantivy engine provides BM25 ranking in lexical mode too).
53
+ *
54
+ * Only the 'vector' (hybrid) retrieval mode additionally needs
55
+ * `@huggingface/transformers` (the only embedding backend — see
56
+ * transformers-loader.ts, there is no remote-embedding fallback). Selecting
57
+ * retrieval='bm25' is a HARD dependency boundary: the embedding package is
58
+ * never resolved, loaded, or downloaded in lexical mode, so a bm25-only host
59
+ * (e.g. an install with --omit=optional) runs the store fully.
52
60
  */
53
- const REQUIRED_PACKAGES: readonly string[] = ['@lancedb/lancedb', '@huggingface/transformers'];
61
+ function requiredPackages(retrieval: KnowledgeStoreRetrieval): readonly string[] {
62
+ return retrieval === 'bm25'
63
+ ? ['@lancedb/lancedb']
64
+ : ['@lancedb/lancedb', '@huggingface/transformers'];
65
+ }
66
+
67
+ /** Knowledge store retrieval strategy. 'vector' = hybrid dense+sparse (default,
68
+ * embedding-backed); 'bm25' = pure lexical, no embedding model. */
69
+ export type KnowledgeStoreRetrieval = 'vector' | 'bm25';
54
70
 
55
71
  // Memoized per-process: resolution touches the filesystem on every miss, the
56
72
  // answer cannot change within a live process without a reinstall mid-flight,
57
73
  // and the probe sits on settings/health paths that run often. Tests reset it
58
74
  // via clearAvailabilityCache.
59
75
  let _cached: KnowledgeStoreAvailability | null = null;
76
+ // The retrieval mode the cached probe answer was computed for — the cache must
77
+ // be keyed on it because the required-package set differs between modes.
78
+ let _cachedRetrieval: KnowledgeStoreRetrieval | null = null;
60
79
 
61
80
  /** Resolver signature — injectable so tests never need the real filesystem. */
62
81
  export type PackageResolver = (specifier: string) => string;
@@ -75,10 +94,10 @@ const defaultResolver: PackageResolver = (specifier) =>
75
94
  * Probe whether the knowledge store's required packages are resolvable.
76
95
  * Memoized after the first call; {@link clearAvailabilityCache} resets.
77
96
  */
78
- export function probeKnowledgeStoreAvailability(resolver: PackageResolver = defaultResolver): KnowledgeStoreAvailability {
79
- if (cachedIsValidFor(resolver)) return _cached!;
97
+ export function probeKnowledgeStoreAvailability(retrieval: KnowledgeStoreRetrieval = 'vector', resolver: PackageResolver = defaultResolver): KnowledgeStoreAvailability {
98
+ if (cachedIsValidFor(retrieval, resolver)) return _cached!;
80
99
  const missing: string[] = [];
81
- for (const pkg of REQUIRED_PACKAGES) {
100
+ for (const pkg of requiredPackages(retrieval)) {
82
101
  try {
83
102
  resolver(pkg);
84
103
  } catch {
@@ -88,17 +107,21 @@ export function probeKnowledgeStoreAvailability(resolver: PackageResolver = defa
88
107
  }
89
108
  }
90
109
  const result: KnowledgeStoreAvailability = { available: missing.length === 0, missing };
91
- if (resolver === defaultResolver) _cached = result;
110
+ if (resolver === defaultResolver) {
111
+ _cached = result;
112
+ _cachedRetrieval = retrieval;
113
+ }
92
114
  return result;
93
115
  }
94
116
 
95
- function cachedIsValidFor(resolver: PackageResolver): boolean {
96
- return _cached !== null && resolver === defaultResolver;
117
+ function cachedIsValidFor(retrieval: KnowledgeStoreRetrieval, resolver: PackageResolver): boolean {
118
+ return _cached !== null && _cachedRetrieval === retrieval && resolver === defaultResolver;
97
119
  }
98
120
 
99
121
  /** Reset the memoized probe result (test isolation). */
100
122
  export function clearAvailabilityCache(): void {
101
123
  _cached = null;
124
+ _cachedRetrieval = null;
102
125
  }
103
126
 
104
127
  /**
@@ -33,7 +33,9 @@ function getMigrationStrategy(config: Config): MigrationStrategy | undefined {
33
33
  * Result of knowledge store initialization
34
34
  */
35
35
  export interface KnowledgeStoreComponents {
36
- embedder: IEmbedder;
36
+ /** null in bm25 (lexical) retrieval mode — the store runs with NO embedding
37
+ * model; non-null in 'vector' (hybrid) mode. */
38
+ embedder: IEmbedder | null;
37
39
  store: IKnowledgeStore;
38
40
  writerQueue: WriterQueue;
39
41
  }
@@ -51,7 +53,7 @@ export async function createKnowledgeStoreComponents(
51
53
  withLock?: <T>(fn: () => Promise<T>) => Promise<T>,
52
54
  config: Config = getConfig(),
53
55
  workspace: string = process.cwd(),
54
- availability: KnowledgeStoreAvailability = probeKnowledgeStoreAvailability()
56
+ availability: KnowledgeStoreAvailability = probeKnowledgeStoreAvailability(config.KNOWLEDGE_STORE_RETRIEVAL)
55
57
  ): Promise<KnowledgeStoreComponents | null> {
56
58
  if (config.KNOWLEDGE_STORE_MODE === 'none') {
57
59
  logger.debug('[knowledge] Knowledge store is disabled in configuration');
@@ -76,15 +78,20 @@ export async function createKnowledgeStoreComponents(
76
78
  for (let attempt = 1; attempt <= MAX_INIT_RETRIES; attempt++) {
77
79
  let store: KnowledgeStore | undefined;
78
80
  try {
79
- logger.info(`[knowledge] Creating Knowledge Store components (attempt ${attempt}/${MAX_INIT_RETRIES})...`);
81
+ logger.info(`[knowledge] Creating Knowledge Store components (attempt ${attempt}/${MAX_INIT_RETRIES}, retrieval=${config.KNOWLEDGE_STORE_RETRIEVAL})...`);
80
82
 
81
- const embedder = await embedderFactory();
83
+ // Hard dependency boundary: in bm25 (lexical) retrieval mode the embedder
84
+ // factory is NEVER invoked — no @huggingface/transformers resolution, no
85
+ // model download, no initialization, no inference. The store runs purely
86
+ // on LanceDB's FTS/BM25 engine.
87
+ const embedder = config.KNOWLEDGE_STORE_RETRIEVAL === 'bm25' ? null : await embedderFactory();
82
88
  const migrationStrategy = getMigrationStrategy(config);
83
89
 
84
90
  store = new KnowledgeStore({
85
91
  dbDir: getDbDir(config, workspace),
86
92
  embedder,
87
93
  modelName: config.EMBEDDING_MODEL,
94
+ retrieval: config.KNOWLEDGE_STORE_RETRIEVAL,
88
95
  migrationStrategy: migrationStrategy,
89
96
  reconnectFactory,
90
97
  withLock,
@@ -149,8 +156,12 @@ export async function forceDeleteKnowledgeStore(config?: Config, workspace?: str
149
156
  const manifestPath = path.join(dbDir, 'store-manifest.json');
150
157
  try {
151
158
  if (fs.existsSync(manifestPath)) {
152
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as { activeTableName?: string };
153
- if (manifest.activeTableName) tableName = manifest.activeTableName;
159
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as { activeTableName?: string; activeBm25TableName?: string };
160
+ // Read the manifest key that belongs to the CONFIGURED retrieval mode —
161
+ // each mode owns its own key so a shared dbDir never cross-wires them.
162
+ const key = config?.KNOWLEDGE_STORE_RETRIEVAL === 'bm25' ? 'activeBm25TableName' : 'activeTableName';
163
+ const saved = key === 'activeBm25TableName' ? manifest.activeBm25TableName : manifest.activeTableName;
164
+ if (saved) tableName = saved;
154
165
  }
155
166
  } catch (err) {
156
167
  logger.warn(`[knowledge] Failed to read manifest before forced delete, defaulting to table '${tableName}':`, err);
@@ -188,6 +188,183 @@ export async function searchStore(
188
188
  return filteredResults;
189
189
  }
190
190
 
191
+ /**
192
+ * Add documents to the BM25 (lexical) table — the vector write path minus the
193
+ * embedding step. Rows carry no vector at all; lexical ranking comes from the
194
+ * Tantivy FTS indexes (see store-schema.ts). Everything else — row shape,
195
+ * scope columns, ingestion_type fallback, metrics — matches addDocumentsToStore
196
+ * so the two backends stay interchangeable behind IKnowledgeStore.
197
+ */
198
+ export async function addDocumentsBm25ToStore(
199
+ table: lancedb.Table,
200
+ docs: StoreDocument[],
201
+ isClosing: () => boolean,
202
+ workspace: string,
203
+ isGlobal: boolean
204
+ ): Promise<AddDocumentsOutcome> {
205
+ if (docs.length === 0) return 'empty';
206
+ if (isClosing()) {
207
+ logger.warn('[store] Ignoring addDocuments during close');
208
+ return 'ignored_closing';
209
+ }
210
+
211
+ const startTime = Date.now();
212
+
213
+ try {
214
+ const data = docs.map((doc) => ({
215
+ url: doc.url,
216
+ text: doc.text,
217
+ content: doc.content ?? null,
218
+ metadata: JSON.stringify(doc.metadata),
219
+ workspace,
220
+ is_global: isGlobal,
221
+ ingestion_type: doc.ingestion_type || (doc.metadata['ingestionType'] as string) || 'unknown',
222
+ timestamp: BigInt(doc.timestamp),
223
+ }));
224
+
225
+ await table.add(data);
226
+ const duration = Date.now() - startTime;
227
+ metrics.observe('knowledge_store_add_documents_duration_ms', duration, { retrieval: 'bm25' });
228
+ metrics.increment('knowledge_store_chunks_added_total', docs.length);
229
+ logger.log(`[store] Added ${docs.length} chunk(s) for ${docs[0]?.url} [retrieval=bm25, workspace=${workspace}, global=${isGlobal}]`);
230
+ return 'written';
231
+ } catch (err) {
232
+ const duration = Date.now() - startTime;
233
+ metrics.observe('knowledge_store_add_documents_duration_ms', duration, { status: 'error', retrieval: 'bm25' });
234
+ // Same accounting posture as the vector path: attempt-level failures are
235
+ // thrown; outcome accounting belongs to KnowledgeStore.addDocuments.
236
+ logger.debug(`[store] bm25 add attempt failed after ${duration}ms: ${err instanceof Error ? err.message : String(err)}`);
237
+ throw err;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Search the BM25 (lexical) table — pure full-text retrieval, no embedding.
243
+ *
244
+ * Runs the SAME lexical half the hybrid path already uses: Tantivy BM25 over
245
+ * BOTH 'text' (synthesis descriptions) and 'content' (full article body),
246
+ * ranked by BM25 score alone (no nearestTo, no RRF fusion — there is no second
247
+ * signal to fuse). Filters, result mapping, and metrics match searchStore so
248
+ * callers see identical StoreDocument results either way.
249
+ */
250
+ export async function searchStoreBm25(
251
+ table: lancedb.Table,
252
+ query: string,
253
+ limit: number,
254
+ scopeFilter?: string
255
+ ): Promise<StoreDocument[]> {
256
+ const startTime = Date.now();
257
+
258
+ const rowCount = scopeFilter ? await table.countRows(scopeFilter) : await table.countRows();
259
+ if (rowCount === 0) {
260
+ metrics.increment('knowledge_store_search_total', 1, { status: 'empty', retrieval: 'bm25' });
261
+ return [];
262
+ }
263
+
264
+ let filter = "ingestion_type = 'synthesis-description'";
265
+ if (scopeFilter) {
266
+ filter = `(${filter}) AND (${scopeFilter})`;
267
+ }
268
+
269
+ // Pure FTS query: no nearestTo() — a Query (not a VectorQuery) ranked by BM25.
270
+ const results = await table
271
+ .query()
272
+ .where(filter)
273
+ .fullTextSearch(query, { columns: ['text', 'content'] })
274
+ .limit(limit)
275
+ .toArray();
276
+
277
+ const filteredResults = results
278
+ .map(r => {
279
+ let metadata: Record<string, unknown>;
280
+ try {
281
+ metadata = JSON.parse(r.metadata as string) as Record<string, unknown>;
282
+ } catch {
283
+ logger.debug('[store] Corrupted metadata in searchStoreBm25, skipping row');
284
+ return null;
285
+ }
286
+ return {
287
+ url: r.url as string,
288
+ text: r.text as string,
289
+ content: (r.content as string | null) ?? undefined,
290
+ metadata,
291
+ timestamp: Number(r.timestamp),
292
+ };
293
+ })
294
+ .filter((doc): doc is NonNullable<typeof doc> => doc !== null);
295
+
296
+ const duration = Date.now() - startTime;
297
+ metrics.observe('knowledge_store_search_duration_ms', duration);
298
+ metrics.increment('knowledge_store_search_total', 1, { status: 'success', retrieval: 'bm25' });
299
+ metrics.increment('knowledge_store_search_results_total', filteredResults.length);
300
+
301
+ return filteredResults;
302
+ }
303
+
304
+ /**
305
+ * Find relevant URLs in the BM25 (lexical) table — the vector variant minus
306
+ * the embedding step: BM25 ranking over 'text' + 'content', same filters,
307
+ * same provenance decoration, same result shape as findRelevantUrls.
308
+ */
309
+ export async function findRelevantUrlsBm25(
310
+ table: lancedb.Table,
311
+ query: string,
312
+ limit: number,
313
+ scopeFilter?: string
314
+ ): Promise<{ url: string; description: string; provenance?: string }[]> {
315
+ const startTime = Date.now();
316
+ const rowCount = scopeFilter ? await table.countRows(scopeFilter) : await table.countRows();
317
+ if (rowCount === 0) {
318
+ metrics.increment('knowledge_store_find_urls_total', 1, { status: 'empty', retrieval: 'bm25' });
319
+ return [];
320
+ }
321
+
322
+ let filter = "ingestion_type = 'synthesis-description'";
323
+ if (scopeFilter) {
324
+ filter = `(${filter}) AND (${scopeFilter})`;
325
+ }
326
+
327
+ const results = await table
328
+ .query()
329
+ .where(filter)
330
+ .fullTextSearch(query, { columns: ['text', 'content'] })
331
+ .limit(limit)
332
+ .toArray();
333
+
334
+ const entries: { url: string; description: string; provenance?: string }[] = [];
335
+ const seen = new Set<string>();
336
+ for (const r of results) {
337
+ const url = r.url as string;
338
+ if (seen.has(url)) continue;
339
+ seen.add(url);
340
+
341
+ let description = '';
342
+ let provenance = 'description-unverified';
343
+ try {
344
+ const meta = JSON.parse(r.metadata as string);
345
+ description = meta.description as string ?? '';
346
+ // Surface provenance metadata so consumers can prefer verified entries
347
+ if (meta.provenance) provenance = meta.provenance;
348
+ else if (meta.hasContent === true) provenance = 'scraped-verified';
349
+ } catch { logger.debug('[store] Corrupted metadata in findRelevantUrlsBm25, skipping row'); }
350
+
351
+ if (!description) description = (r.text as string ?? '').substring(0, 300);
352
+
353
+ // Add store scope to provenance
354
+ const scope = r.is_global === true ? 'Global Store' : `Local Project (${r.workspace || 'unknown'})`;
355
+ const finalProvenance = `${provenance} [${scope}]`;
356
+
357
+ entries.push({ url, description, provenance: finalProvenance });
358
+ }
359
+
360
+ const duration = Date.now() - startTime;
361
+ metrics.observe('knowledge_store_find_urls_duration_ms', duration);
362
+ metrics.increment('knowledge_store_find_urls_total', 1, { status: 'success', retrieval: 'bm25' });
363
+ metrics.increment('knowledge_store_urls_found_total', entries.length);
364
+
365
+ return entries;
366
+ }
367
+
191
368
  /**
192
369
  * Find documents by URL
193
370
  */
@@ -40,6 +40,38 @@ function createStoreSchema(dim: number, modelName: string): Schema {
40
40
  ]));
41
41
  }
42
42
 
43
+ /**
44
+ * Schema metadata for BM25 (lexical) tables.
45
+ *
46
+ * BM25 tables carry NO vector column at all — rows are stored without any
47
+ * embedding and ranking comes entirely from the Tantivy FTS inverted indexes
48
+ * over 'text' and 'content' (computed at query time, nothing score-shaped is
49
+ * persisted). The schema is otherwise identical to the vector schema, so the
50
+ * same store operations, scope filters, and result mapping apply.
51
+ */
52
+ export const BM25_TABLE_NAME = 'knowledge-bm25';
53
+ export const BM25_EMBEDDING_MODEL_TAG = 'bm25-lexical';
54
+
55
+ /**
56
+ * Create the BM25 (lexical) store table schema — the vector schema minus the
57
+ * 'vector' column, tagged so the two table kinds are distinguishable on disk.
58
+ */
59
+ function createBm25StoreSchema(): Schema {
60
+ return new Schema([
61
+ new Field('url', new Utf8(), false),
62
+ new Field('text', new Utf8(), false),
63
+ new Field('content', new Utf8(), true), // full page markdown, nullable
64
+ new Field('metadata', new Utf8(), false), // JSON stringified
65
+ new Field('workspace', new Utf8(), false), // local workspace path or 'global'
66
+ new Field('is_global', new Bool(), false), // boolean indicating if it's shared globally
67
+ new Field('ingestion_type', new Utf8(), false), // e.g. 'synthesis-description'
68
+ new Field('timestamp', new Int64(), false),
69
+ ], new Map([
70
+ ['embedding_model', BM25_EMBEDDING_MODEL_TAG],
71
+ ['schema_version', CURRENT_SCHEMA_VERSION]
72
+ ]));
73
+ }
74
+
43
75
  /**
44
76
  * Create an empty table with the knowledge store schema
45
77
  */
@@ -50,6 +82,32 @@ export async function createStoreTable(
50
82
  modelName: string
51
83
  ): Promise<lancedb.Table> {
52
84
  const schema = createStoreSchema(dim, modelName);
85
+ return createKnowledgeTable(db, name, schema);
86
+ }
87
+
88
+ /**
89
+ * Create an empty BM25 (lexical) table — same indexes as the vector table, no
90
+ * vector column. Used by retrieval mode 'bm25' (separate table so existing
91
+ * vector stores are never touched — no migration either way).
92
+ */
93
+ export async function createBm25StoreTable(
94
+ db: lancedb.Connection,
95
+ name: string = BM25_TABLE_NAME
96
+ ): Promise<lancedb.Table> {
97
+ const schema = createBm25StoreSchema();
98
+ return createKnowledgeTable(db, name, schema);
99
+ }
100
+
101
+ /**
102
+ * Shared table creation: FTS indexes for lexical search + btree scalar indexes
103
+ * for scope/eviction performance. Identical for both table kinds — the ONLY
104
+ * difference between them is the presence of the 'vector' column.
105
+ */
106
+ async function createKnowledgeTable(
107
+ db: lancedb.Connection,
108
+ name: string,
109
+ schema: Schema
110
+ ): Promise<lancedb.Table> {
53
111
  const { Index } = await getLancedb();
54
112
 
55
113
  // Create empty table with schema and metadata