@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/README.md +3 -4
- package/agent-skill/pi-research/README.md +1 -1
- package/agent-skill/pi-research/SKILL.md +1 -1
- package/dist/cli.mjs +343 -55
- package/dist/thread-worker.mjs +7 -0
- package/docs/AGENT-SKILL.md +1 -1
- package/docs/CHANGELOG.md +50 -5
- package/docs/CONFIGURATION.md +3 -1
- package/docs/KNOWLEDGE-STORE.md +38 -4
- package/docs/SDK.md +1 -1
- package/package.json +12 -11
- package/scripts/setup.cjs +6 -3
- package/src/cli.ts +60 -7
- package/src/config.ts +35 -15
- package/src/core/pi-version.ts +12 -3
- package/src/healthcheck/index.ts +18 -0
- package/src/index.ts +5 -3
- package/src/infrastructure/browser/thread-worker.mjs +7 -0
- package/src/infrastructure/knowledge-store-service.ts +29 -4
- package/src/knowledge/availability.ts +33 -10
- package/src/knowledge/index.ts +17 -6
- package/src/knowledge/store-operations.ts +177 -0
- package/src/knowledge/store-schema.ts +58 -0
- package/src/knowledge/store.ts +118 -21
- package/src/research-config.ts +58 -18
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.
|
|
95
|
-
// The floor
|
|
96
|
-
// and only 0.84.0+ hardcodes the equivalent guard internally (see pi-version.ts)
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
/**
|
package/src/knowledge/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|