@lincoln504/pi-research 1.6.19 → 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.
@@ -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