@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.
@@ -21,8 +21,8 @@ import * as fsPromises from 'node:fs/promises';
21
21
  import * as path from 'node:path';
22
22
  import { MigrationStrategy, MigrationResult } from './migration.ts';
23
23
  import { metrics } from '../utils/metrics.ts';
24
- import { createStoreTable, CURRENT_SCHEMA_VERSION } from './store-schema.ts';
25
- import { addDocumentsToStore, assertValidEmbeddingVectors, searchStore, findDocumentsByUrl, findRelevantUrls } from './store-operations.ts';
24
+ import { createStoreTable, createBm25StoreTable, BM25_TABLE_NAME, CURRENT_SCHEMA_VERSION } from './store-schema.ts';
25
+ import { addDocumentsToStore, assertValidEmbeddingVectors, addDocumentsBm25ToStore, searchStore, searchStoreBm25, findDocumentsByUrl, findRelevantUrls, findRelevantUrlsBm25 } from './store-operations.ts';
26
26
  import type { AddDocumentsOutcome } from './store-operations.ts';
27
27
  import { isEmbedderUnreachable } from './embedder-utils.ts';
28
28
  import { ServiceLifecycle } from '../core/service-registry.ts';
@@ -54,8 +54,20 @@ export function isLanceCommitConflict(err: unknown): boolean {
54
54
 
55
55
  export interface StoreOptions {
56
56
  dbDir: string;
57
- embedder: IEmbedder;
57
+ /** The embedding backend. REQUIRED in 'vector' (hybrid) retrieval mode; MUST be
58
+ * null in 'bm25' mode — this is the hard dependency boundary that keeps
59
+ * @huggingface/transformers out of the lexical path entirely (no resolution,
60
+ * no download, no initialization, no inference). */
61
+ embedder: IEmbedder | null;
58
62
  modelName: string;
63
+ /** Retrieval strategy. 'vector' (default) = today's hybrid dense+sparse search
64
+ * (embedding similarity + BM25/FTS fused via RRF). 'bm25' = pure lexical BM25
65
+ * over the Tantivy FTS indexes — no embedding model anywhere; uses a separate
66
+ * lexical table (BM25_TABLE_NAME) in the same dbDir so existing vector stores
67
+ * are untouched and no migration is needed in either direction. Optional and
68
+ * defaulting to 'vector' so constructors predating the retrieval axis (tests,
69
+ * external embedders of the class) keep the exact historical behavior. */
70
+ retrieval?: 'vector' | 'bm25';
59
71
  migrationStrategy?: MigrationStrategy;
60
72
  /** Called when embedder connection fails — should return a fresh IEmbedder. */
61
73
  reconnectFactory?: () => Promise<IEmbedder>;
@@ -83,7 +95,12 @@ export class KnowledgeStore implements IKnowledgeStore {
83
95
  // Coalesces concurrent open() calls (see open()).
84
96
  private _openPromise: Promise<void> | null = null;
85
97
  private options: StoreOptions;
86
- private tableName = 'knowledge';
98
+ private tableName: string;
99
+ // The OTHER retrieval mode's active table name, preserved across manifest
100
+ // save/load so the two modes can share one dbDir without clobbering each
101
+ // other's migration-renamed tables ('vector' owns activeTableName,
102
+ // 'bm25' owns activeBm25TableName).
103
+ private otherModeTableName: string | null = null;
87
104
  private manifestPath: string;
88
105
  private isClosing = false;
89
106
  private activeWrites = new Set<Promise<any>>();
@@ -113,9 +130,26 @@ export class KnowledgeStore implements IKnowledgeStore {
113
130
 
114
131
  constructor(options: StoreOptions) {
115
132
  this.options = options;
133
+ this.tableName = this.retrieval === 'bm25' ? BM25_TABLE_NAME : 'knowledge';
116
134
  this.manifestPath = path.join(this.options.dbDir, 'store-manifest.json');
117
135
  }
118
136
 
137
+ /** Normalized retrieval strategy: an unset option means the historical
138
+ * 'vector' (hybrid) behavior, not bm25. Every mode check goes through this. */
139
+ private get retrieval(): 'vector' | 'bm25' {
140
+ return this.options.retrieval ?? 'vector';
141
+ }
142
+
143
+ /** The embedding backend. Only callable on the vector (hybrid) path — the
144
+ * single choke point that makes a null embedder in bm25 mode fail loud here
145
+ * instead of surfacing as a mysterious TypeError deep in an embed call. */
146
+ private get embedder(): IEmbedder {
147
+ if (this.retrieval === 'bm25' || !this.options.embedder) {
148
+ throw new Error('[store] embedder accessed in bm25 (lexical) mode — internal invariant violated');
149
+ }
150
+ return this.options.embedder;
151
+ }
152
+
119
153
  private getScopeFilter(): string {
120
154
  const ws = this.getWorkspace().replace(/'/g, "''");
121
155
 
@@ -145,10 +179,17 @@ export class KnowledgeStore implements IKnowledgeStore {
145
179
  if (fs.existsSync(this.manifestPath)) {
146
180
  const content = await fsPromises.readFile(this.manifestPath, 'utf-8');
147
181
  const manifest = JSON.parse(content);
148
- if (manifest.activeTableName) {
149
- this.tableName = manifest.activeTableName;
182
+ const savedVectorTable = typeof manifest.activeTableName === 'string' ? manifest.activeTableName : null;
183
+ const savedBm25Table = typeof manifest.activeBm25TableName === 'string' ? manifest.activeBm25TableName : null;
184
+ this.otherModeTableName = this.retrieval === 'bm25' ? savedVectorTable : savedBm25Table;
185
+ const savedOwnTable = this.retrieval === 'bm25' ? savedBm25Table : savedVectorTable;
186
+ if (savedOwnTable) {
187
+ this.tableName = savedOwnTable;
150
188
  logger.debug(`[store] Loaded active table name from manifest: ${this.tableName}`);
151
189
  }
190
+ // Migration history is vector-only (bm25 has no model to migrate); the
191
+ // values are loaded regardless and preserved verbatim by saveManifest so
192
+ // a bm25 open never erases the vector mode's alternation detector.
152
193
  if (manifest.lastMigration && typeof manifest.lastMigration.model === 'string') {
153
194
  this.lastMigration = { model: manifest.lastMigration.model, ts: Number(manifest.lastMigration.ts) || 0 };
154
195
  }
@@ -166,7 +207,11 @@ export class KnowledgeStore implements IKnowledgeStore {
166
207
  const tempPath = `${this.manifestPath}.tmp`;
167
208
  try {
168
209
  const content = JSON.stringify({
169
- activeTableName: this.tableName,
210
+ activeTableName: this.retrieval === 'vector' ? this.tableName : this.otherModeTableName,
211
+ // bm25 keeps its OWN manifest key so a lexical store can share the dbDir
212
+ // with a vector store — neither mode's table renames (migrations,
213
+ // backups) can strand the other's manifest pointer.
214
+ activeBm25TableName: this.retrieval === 'bm25' ? this.tableName : this.otherModeTableName,
170
215
  // Record of the two most recent model/schema migrations. Read back on open
171
216
  // to detect ALTERNATION (two configurations sharing this dbDir each
172
217
  // migrating the table back and forth) — previously that thrash was
@@ -189,6 +234,13 @@ export class KnowledgeStore implements IKnowledgeStore {
189
234
 
190
235
  async open(): Promise<void> {
191
236
  if (this.db) return;
237
+ // Load the manifest BEFORE anything reads this.tableName / otherModeTableName.
238
+ // The service path reaches here via initialize() (which already loaded it —
239
+ // this idempotent re-read is cheap), but direct open() callers previously
240
+ // skipped the manifest entirely: with per-mode manifest keys, saving without
241
+ // loading would overwrite the OTHER mode's table pointer with null and strand
242
+ // its store. Loading here makes both entry points safe.
243
+ await this.loadManifest();
192
244
  // close() is irreversible in this class (isClosing latches the operation
193
245
  // no-op path), so an open() after it must not "succeed" and hand back a
194
246
  // zombie whose every operation silently no-ops — the service layer always
@@ -232,7 +284,17 @@ export class KnowledgeStore implements IKnowledgeStore {
232
284
  this.db = await (await getLancedb()).connect(this.options.dbDir);
233
285
 
234
286
  const tableNames = await this.db.tableNames();
235
- if (tableNames.includes(this.tableName)) {
287
+ if (this.retrieval === 'bm25') {
288
+ // Lexical mode: there is no embedding model here — no model/schema
289
+ // migration machinery, no dimension seeding, no embedder warm-up. The
290
+ // table's schema carries no vector column at all (see store-schema.ts);
291
+ // lexical ranking comes entirely from the FTS indexes.
292
+ if (tableNames.includes(this.tableName)) {
293
+ this.table = await this.db.openTable(this.tableName);
294
+ } else {
295
+ this.table = await this.createTable();
296
+ }
297
+ } else if (tableNames.includes(this.tableName)) {
236
298
  this.table = await this.db.openTable(this.tableName);
237
299
 
238
300
  // Tracks whether we've moved past schema-reading into migration. A failure
@@ -271,8 +333,8 @@ export class KnowledgeStore implements IKnowledgeStore {
271
333
  const vectorField = schema.fields.find(f => f.name === 'vector');
272
334
  if (vectorField && (vectorField.type as any).constructor.name === 'FixedSizeList') {
273
335
  const dim = (vectorField.type as any).listSize;
274
- if (this.options.embedder.getDimension() === null) {
275
- this.options.embedder.setDimension(dim);
336
+ if (this.embedder.getDimension() === null) {
337
+ this.embedder.setDimension(dim);
276
338
  logger.debug(`[store] Extracted dimension ${dim} from existing table schema`);
277
339
  }
278
340
  }
@@ -291,7 +353,7 @@ export class KnowledgeStore implements IKnowledgeStore {
291
353
  // (local Embedder, EmbeddingServer, EmbeddingClient) and makes
292
354
  // getDimension() report the correct new width. (re-embed needs the warm
293
355
  // embedder regardless.)
294
- if (this.options.embedder.getDimension() === null) {
356
+ if (this.embedder.getDimension() === null) {
295
357
  // Reconnect-wrapped: after a service re-scope this warm-up can be
296
358
  // the FIRST embed against a stale cached instance (a stepped-down
297
359
  // leader fast-fails with ECONNREFUSED); without the wrapper that
@@ -368,7 +430,7 @@ export class KnowledgeStore implements IKnowledgeStore {
368
430
  // that when the embedding factory handed back its model-mismatch local
369
431
  // fallback, which is constructed but never initialize()d: every one of
370
432
  // the caller's init retries burned against the same cached instance.
371
- if (this.options.embedder.getDimension() === null) {
433
+ if (this.embedder.getDimension() === null) {
372
434
  await this.withEmbedderReconnect(embedder => embedder.embedMany([' ']));
373
435
  }
374
436
  this.table = await this.createTable();
@@ -525,7 +587,7 @@ export class KnowledgeStore implements IKnowledgeStore {
525
587
  const vectors = await this.withEmbedderReconnect(embedder => embedder.embedMany(texts));
526
588
  // Same last-gate integrity check as addDocumentsToStore: this path
527
589
  // writes vectors through its own table handle and previously had none.
528
- assertValidEmbeddingVectors(vectors, batchDocs.length, this.options.embedder.getDimension?.());
590
+ assertValidEmbeddingVectors(vectors, batchDocs.length, this.embedder.getDimension?.());
529
591
 
530
592
  const records = batchDocs.map((doc, idx) => ({
531
593
  vector: Array.from(vectors[idx]!),
@@ -689,12 +751,18 @@ export class KnowledgeStore implements IKnowledgeStore {
689
751
 
690
752
  private async createTable(name: string = this.tableName): Promise<lancedb.Table> {
691
753
  if (!this.db) throw new Error('Database not connected');
692
- const dim = this.options.embedder.getDimension();
754
+ if (this.retrieval === 'bm25') {
755
+ return createBm25StoreTable(this.db, name);
756
+ }
757
+ const dim = this.embedder.getDimension();
693
758
  if (dim === null) throw new Error('[store] Cannot create table: embedder dimension unknown (not yet initialized)');
694
759
  return createStoreTable(this.db, name, dim, this.options.modelName);
695
760
  }
696
761
 
697
762
  private async withEmbedderReconnect<T>(fn: (embedder: IEmbedder) => Promise<T>): Promise<T> {
763
+ if (this.retrieval === 'bm25' || !this.options.embedder) {
764
+ throw new Error('[store] withEmbedderReconnect called without an embedder (bm25 mode?) — internal invariant violated');
765
+ }
698
766
  try {
699
767
  return await fn(this.options.embedder);
700
768
  } catch (err) {
@@ -788,7 +856,10 @@ export class KnowledgeStore implements IKnowledgeStore {
788
856
  let table = await this.getFreshTable();
789
857
  const workspace = this.getWorkspace();
790
858
 
791
- const outcome = await this.withEmbedderReconnect<AddDocumentsOutcome>(async (embedder) => {
859
+ // The write attempt (with its Lance commit-conflict retry loop) is shared
860
+ // by both retrieval modes; only the embedding step differs. bm25 mode runs
861
+ // it WITHOUT the embedder-reconnect wrapper — there is no embedder there.
862
+ const runWriteAttempt = async (): Promise<AddDocumentsOutcome> => {
792
863
  let retryCount = 0;
793
864
  const MAX_RETRIES = 5;
794
865
  const BASE_DELAY = 100;
@@ -796,10 +867,19 @@ export class KnowledgeStore implements IKnowledgeStore {
796
867
  while (retryCount <= MAX_RETRIES) {
797
868
  try {
798
869
  const isGlobal = this.options.knowledgeMode === 'global';
870
+ if (this.retrieval === 'bm25') {
871
+ return await addDocumentsBm25ToStore(
872
+ table,
873
+ docs,
874
+ () => this.isClosing,
875
+ workspace,
876
+ isGlobal
877
+ );
878
+ }
799
879
  return await addDocumentsToStore(
800
- table,
801
- docs,
802
- embedder,
880
+ table,
881
+ docs,
882
+ this.embedder,
803
883
  () => this.isClosing,
804
884
  workspace,
805
885
  isGlobal
@@ -829,7 +909,11 @@ export class KnowledgeStore implements IKnowledgeStore {
829
909
  // Unreachable: the loop only exits via return or throw. Present so the
830
910
  // outcome type stays non-optional for the accounting below.
831
911
  throw new Error('[store] addDocuments retry loop exited without result');
832
- });
912
+ };
913
+
914
+ const outcome = this.retrieval === 'bm25'
915
+ ? await runWriteAttempt()
916
+ : await this.withEmbedderReconnect<AddDocumentsOutcome>(runWriteAttempt);
833
917
 
834
918
  // Exactly one outcome per logical addDocuments call. Attempts that failed and
835
919
  // were then recovered by the reconnect/conflict retries are not counted here
@@ -864,6 +948,11 @@ export class KnowledgeStore implements IKnowledgeStore {
864
948
  return this.trackOperation('search', [] as StoreDocument[], async () => {
865
949
  const table = await this.getFreshTable();
866
950
  const scopeFilter = this.getScopeFilter();
951
+ if (this.retrieval === 'bm25') {
952
+ // Pure lexical: BM25 over the FTS index — no embed, no RRF fusion, no
953
+ // embedder reconnect (there is no embedding signal to fuse with).
954
+ return this.circuitBreaker.execute(() => searchStoreBm25(table, query, options.limit ?? 5, scopeFilter));
955
+ }
867
956
  return this.circuitBreaker.execute(() =>
868
957
  this.withEmbedderReconnect(embedder =>
869
958
  searchStore(table, embedder, query, this.getReranker.bind(this), options.limit ?? 5, scopeFilter)
@@ -1060,8 +1149,11 @@ export class KnowledgeStore implements IKnowledgeStore {
1060
1149
  url: r.url as string,
1061
1150
  // The text is the summary/description
1062
1151
  text: r.text as string,
1063
- // The vector is the model-dimension embedding
1064
- v: Array.from(r.vector as Float32Array),
1152
+ // The vector is the model-dimension embedding — vector/hybrid mode only;
1153
+ // a bm25 store has no vector column, so the export omits it entirely.
1154
+ ...(this.retrieval === 'vector'
1155
+ ? { v: Array.from(r.vector as Float32Array) }
1156
+ : {}),
1065
1157
  // Minimal metadata needed for the UI
1066
1158
  m: {
1067
1159
  d: metadata['description'] || '',
@@ -1194,6 +1286,11 @@ export class KnowledgeStore implements IKnowledgeStore {
1194
1286
  return this.trackOperation('findRelevantUrls', [] as { url: string; description: string; provenance?: string }[], async () => {
1195
1287
  const table = await this.getFreshTable();
1196
1288
  const scopeFilter = this.getScopeFilter();
1289
+ if (this.retrieval === 'bm25') {
1290
+ // Pure lexical: BM25 over the FTS index — no embed, no RRF fusion, no
1291
+ // embedder reconnect (there is no embedding signal to fuse with).
1292
+ return this.circuitBreaker.execute(() => findRelevantUrlsBm25(table, query, options.limit ?? 20, scopeFilter));
1293
+ }
1197
1294
  return this.circuitBreaker.execute(() =>
1198
1295
  this.withEmbedderReconnect(embedder =>
1199
1296
  findRelevantUrls(table, embedder, query, this.getReranker.bind(this), options.limit ?? 20, scopeFilter)
@@ -71,6 +71,7 @@ import * as path from 'node:path';
71
71
  const ENV_KEY_BY_SETTING_ID: Record<string, string> = {
72
72
  DEFAULT_RESEARCH_DEPTH: 'PI_RESEARCH_DEFAULT_RESEARCH_DEPTH',
73
73
  KNOWLEDGE_STORE_MODE: 'PI_RESEARCH_KNOWLEDGE_STORE_MODE',
74
+ KNOWLEDGE_STORE_RETRIEVAL: 'PI_RESEARCH_KNOWLEDGE_STORE_RETRIEVAL',
74
75
  RESEARCHER_TIMEOUT_MS: 'PI_RESEARCH_TIMEOUT_MS',
75
76
  MAX_CONCURRENT_RESEARCHERS: 'PI_RESEARCH_MAX_RESEARCHERS',
76
77
  MAX_SCRAPE_BATCHES: 'PI_RESEARCH_MAX_SCRAPE_BATCHES',
@@ -171,8 +172,9 @@ async function showInteractiveMenu(ctx: ExtensionContext, pi: ExtensionAPI): Pro
171
172
  // (optional embedding dep skipped at install, broken lancedb). Surface the
172
173
  // package-level OFF right in the setting, so the menu never advertises a
173
174
  // scope the store cannot honor and the repair path is named where the
174
- // user is looking.
175
- const availability = probeKnowledgeStoreAvailability();
175
+ // user is looking. The probe is retrieval-aware: bm25 does not need the
176
+ // optional embedding package at all.
177
+ const availability = probeKnowledgeStoreAvailability(config.KNOWLEDGE_STORE_RETRIEVAL);
176
178
  if (!availability.available) {
177
179
  return `${base}\n\n⚠ ${describeKnowledgeStoreUnavailability(availability)}\nThe store stays off — regardless of this setting — until that package is installed.`;
178
180
  }
@@ -181,6 +183,25 @@ async function showInteractiveMenu(ctx: ExtensionContext, pi: ExtensionAPI): Pro
181
183
  currentValue: config.KNOWLEDGE_STORE_MODE,
182
184
  values: ['none', 'project', 'global'],
183
185
  },
186
+ ...(anyKnowledgeStore ? [
187
+ {
188
+ id: 'KNOWLEDGE_STORE_RETRIEVAL',
189
+ label: 'Knowledge Retrieval [project]',
190
+ description: (() => {
191
+ const base = "Retrieval strategy for the knowledge store — 'vector' (hybrid: embedding similarity + BM25 fused, richest matching, needs the optional embedding model) or 'bm25' (pure lexical BM25 — no embedding model, nothing downloaded, lighter install).\n[project] means configured independently per directory.";
192
+ // Same honesty rule as Knowledge Mode: in vector mode, a missing
193
+ // optional embedding package means the configured strategy cannot run;
194
+ // point at the bm25 switch as the repair.
195
+ const availability = probeKnowledgeStoreAvailability(config.KNOWLEDGE_STORE_RETRIEVAL);
196
+ if (!availability.available && config.KNOWLEDGE_STORE_RETRIEVAL === 'vector') {
197
+ return `${base}\n\n⚠ ${describeKnowledgeStoreUnavailability(availability)}\nSwitching to 'bm25' runs the store without the embedding package.`;
198
+ }
199
+ return base;
200
+ })(),
201
+ currentValue: config.KNOWLEDGE_STORE_RETRIEVAL,
202
+ values: ['vector', 'bm25'],
203
+ },
204
+ ] as SettingItem[] : []),
184
205
  {
185
206
  id: 'RESEARCHER_TIMEOUT_MS',
186
207
  label: 'Researcher Timeout',
@@ -213,20 +234,24 @@ async function showInteractiveMenu(ctx: ExtensionContext, pi: ExtensionAPI): Pro
213
234
  // environment-only setting (PI_RESEARCH_WORKER_THREADS, default 4) so that
214
235
  // CPU/RAM-sensitive concurrency is not casually changed from the menu.
215
236
  ...(anyKnowledgeStore ? [
216
- {
217
- id: 'EMBEDDING_MODEL',
218
- label: 'Embedding Model',
219
- description: 'Vector model for semantic search. Changing it clears the current knowledge store and starts fresh.',
220
- currentValue: config.EMBEDDING_MODEL.split('/').pop()!,
221
- values: SUPPORTED_MODELS.map(m => m.id.split('/').pop()!),
222
- },
223
- {
224
- id: 'EMBEDDING_DEVICE',
225
- label: 'Embedding Device',
226
- description: 'Hardware backend for the embedding model. GPU probes whether WebGPU actually runs on this machine and automatically falls back to CPU if it does not (safe everywhere). CPU forces CPU-only inference. (Raw forced-GPU is available via the PI_RESEARCH_EMBEDDING_DEVICE=webgpu env var for benchmarking.)',
227
- currentValue: config.EMBEDDING_DEVICE === 'cpu' ? 'CPU' : 'GPU',
228
- values: ['GPU', 'CPU'],
229
- },
237
+ // Embedding knobs are only meaningful in 'vector' (hybrid) retrieval mode —
238
+ // bm25 runs with NO embedding model, so there is nothing to select here.
239
+ ...(config.KNOWLEDGE_STORE_RETRIEVAL !== 'bm25' ? [
240
+ {
241
+ id: 'EMBEDDING_MODEL',
242
+ label: 'Embedding Model',
243
+ description: 'Vector model for semantic search. Changing it clears the current knowledge store and starts fresh.',
244
+ currentValue: config.EMBEDDING_MODEL.split('/').pop()!,
245
+ values: SUPPORTED_MODELS.map(m => m.id.split('/').pop()!),
246
+ },
247
+ {
248
+ id: 'EMBEDDING_DEVICE',
249
+ label: 'Embedding Device',
250
+ description: 'Hardware backend for the embedding model. GPU probes whether WebGPU actually runs on this machine and automatically falls back to CPU if it does not (safe everywhere). CPU forces CPU-only inference. (Raw forced-GPU is available via the PI_RESEARCH_EMBEDDING_DEVICE=webgpu env var for benchmarking.)',
251
+ currentValue: config.EMBEDDING_DEVICE === 'cpu' ? 'CPU' : 'GPU',
252
+ values: ['GPU', 'CPU'],
253
+ },
254
+ ] as SettingItem[] : []),
230
255
  {
231
256
  id: 'KNOWLEDGE_STORE_CACHE_TTL_DAYS',
232
257
  label: 'Cache Retention',
@@ -398,6 +423,8 @@ async function showInteractiveMenu(ctx: ExtensionContext, pi: ExtensionAPI): Pro
398
423
  scope = 'user';
399
424
  } else if (id === 'KNOWLEDGE_STORE_MODE') {
400
425
  config.KNOWLEDGE_STORE_MODE = newValue as 'none' | 'project' | 'global';
426
+ } else if (id === 'KNOWLEDGE_STORE_RETRIEVAL') {
427
+ config.KNOWLEDGE_STORE_RETRIEVAL = newValue as 'vector' | 'bm25';
401
428
  } else if (id === 'EMBEDDING_MODEL') {
402
429
  config.EMBEDDING_MODEL = SUPPORTED_MODELS.find(m => m.id.split('/').pop() === newValue)?.id ?? newValue;
403
430
  // DESTRUCTIVE + DEFERRED: changing the model clears the knowledge store, so it is
@@ -443,6 +470,16 @@ async function showInteractiveMenu(ctx: ExtensionContext, pi: ExtensionAPI): Pro
443
470
  .catch((e) => logger.debug('[research-config] knowledge-store warm-up failed:', e));
444
471
  }
445
472
 
473
+ // A retrieval switch re-scopes the store onto the other table (lexical
474
+ // vs vector schema) — same background warm-up as a mode change, so the
475
+ // first search after the switch doesn't pay the init cost.
476
+ if (id === 'KNOWLEDGE_STORE_RETRIEVAL') {
477
+ const kmContainer = tryGetServiceContainerFromCtx(ctx);
478
+ void getService<IKnowledgeStoreService>(ServiceNames.KNOWLEDGE_STORE, ctx, kmContainer)
479
+ .then((s) => s.getStore())
480
+ .catch((e) => logger.debug('[research-config] retrieval-switch warm-up failed:', e));
481
+ }
482
+
446
483
  // 2. Handle Actions
447
484
  if (id === 'ACTION_HEALTH') {
448
485
  wrappedDone({ type: 'action', action: 'health' });
@@ -825,10 +862,13 @@ async function showKnowledgeStatusAction(ctx: ExtensionContext, pi: ExtensionAPI
825
862
  }
826
863
  const counts = await store.countScoped(cwd);
827
864
  const dbDir = getDbDir(config, cwd);
828
-
865
+ const retrievalLines = config.KNOWLEDGE_STORE_RETRIEVAL === 'bm25'
866
+ ? `- Retrieval: bm25 (lexical — no embedding model)`
867
+ : `- Retrieval: vector (hybrid embedding + BM25)\n- Model: ${config.EMBEDDING_MODEL}\n- Device: ${config.EMBEDDING_DEVICE}`;
868
+
829
869
  pi.sendMessage({
830
870
  customType: 'knowledge-status',
831
- content: `## Knowledge Store\n\n- Status: Operational\n- Project Entries: ${counts.local}\n- User Entries: ${counts.global}\n- Total Projects: ${counts.projects}\n- Model: ${config.EMBEDDING_MODEL}\n- Device: ${config.EMBEDDING_DEVICE}\n- Path: \`${dbDir}\``,
871
+ content: `## Knowledge Store\n\n- Status: Operational\n- Project Entries: ${counts.local}\n- User Entries: ${counts.global}\n- Total Projects: ${counts.projects}\n${retrievalLines}\n- Path: \`${dbDir}\``,
832
872
  display: true,
833
873
  });
834
874
  } catch (error: unknown) {