memory-lancedb-pro 1.0.11 → 1.0.13
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/CHANGELOG.md +13 -0
- package/README.md +22 -0
- package/index.ts +57 -55
- package/openclaw.plugin.json +15 -2
- package/package.json +2 -2
- package/src/adaptive-retrieval.ts +15 -5
- package/src/embedder.ts +2 -0
- package/src/retriever.ts +82 -19
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.13
|
|
4
|
+
|
|
5
|
+
- Fix: Force `encoding_format: "float"` for OpenAI-compatible embedding requests to avoid base64/float ambiguity and dimension mismatch issues with some providers/gateways.
|
|
6
|
+
- Feat: Add Voyage AI (`voyage`) as a supported rerank provider, using `top_k` and `Authorization: Bearer` header.
|
|
7
|
+
- Refactor: Harden rerank response parser to accept both `results[]`/`data[]` payload shapes and `relevance_score`/`score` field names across all providers.
|
|
8
|
+
|
|
9
|
+
## 1.0.12
|
|
10
|
+
|
|
11
|
+
- Fix: ghost memories stuck in autoRecall after deletion (#15). BM25-only results from stale FTS index are now validated via `store.hasId()` before inclusion in fused results. Removed the BM25-only floor score of 0.5 that allowed deleted entries to survive `hardMinScore` filtering.
|
|
12
|
+
- Fix: HEARTBEAT pattern now matches anywhere in the prompt (not just at start), preventing autoRecall from triggering on prefixed HEARTBEAT messages.
|
|
13
|
+
- Add: `autoRecallMinLength` config option to set a custom minimum prompt length for autoRecall (default: 15 chars English, 6 CJK). Prompts shorter than this threshold are skipped.
|
|
14
|
+
- Add: `ping`, `pong`, `test`, `debug` added to skip patterns in adaptive retrieval.
|
|
15
|
+
|
|
3
16
|
## 1.0.11
|
|
4
17
|
|
|
5
18
|
- Change: set `autoRecall` default to `false` to avoid the model echoing injected `<relevant-memories>` blocks.
|
package/README.md
CHANGED
|
@@ -393,8 +393,13 @@ Cross-encoder reranking supports multiple providers via `rerankProvider`:
|
|
|
393
393
|
|----------|-----------------|----------|---------------|
|
|
394
394
|
| **Jina** (default) | `jina` | `https://api.jina.ai/v1/rerank` | `jina-reranker-v3` |
|
|
395
395
|
| **SiliconFlow** (free tier available) | `siliconflow` | `https://api.siliconflow.com/v1/rerank` | `BAAI/bge-reranker-v2-m3`, `Qwen/Qwen3-Reranker-8B` |
|
|
396
|
+
| **Voyage AI** | `voyage` | `https://api.voyageai.com/v1/rerank` | `rerank-2.5` |
|
|
396
397
|
| **Pinecone** | `pinecone` | `https://api.pinecone.io/rerank` | `bge-reranker-v2-m3` |
|
|
397
398
|
|
|
399
|
+
Notes:
|
|
400
|
+
- `voyage` sends `{ model, query, documents }` without `top_n`.
|
|
401
|
+
- Voyage responses are parsed from `data[].relevance_score`.
|
|
402
|
+
|
|
398
403
|
<details>
|
|
399
404
|
<summary><strong>SiliconFlow Example</strong></summary>
|
|
400
405
|
|
|
@@ -412,6 +417,23 @@ Cross-encoder reranking supports multiple providers via `rerankProvider`:
|
|
|
412
417
|
|
|
413
418
|
</details>
|
|
414
419
|
|
|
420
|
+
<details>
|
|
421
|
+
<summary><strong>Voyage Example</strong></summary>
|
|
422
|
+
|
|
423
|
+
```json
|
|
424
|
+
{
|
|
425
|
+
"retrieval": {
|
|
426
|
+
"rerank": "cross-encoder",
|
|
427
|
+
"rerankProvider": "voyage",
|
|
428
|
+
"rerankEndpoint": "https://api.voyageai.com/v1/rerank",
|
|
429
|
+
"rerankApiKey": "${VOYAGE_API_KEY}",
|
|
430
|
+
"rerankModel": "rerank-2.5"
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
</details>
|
|
436
|
+
|
|
415
437
|
<details>
|
|
416
438
|
<summary><strong>Pinecone Example</strong></summary>
|
|
417
439
|
|
package/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ interface PluginConfig {
|
|
|
37
37
|
dbPath?: string;
|
|
38
38
|
autoCapture?: boolean;
|
|
39
39
|
autoRecall?: boolean;
|
|
40
|
+
autoRecallMinLength?: number;
|
|
40
41
|
captureAssistant?: boolean;
|
|
41
42
|
retrieval?: {
|
|
42
43
|
mode?: "hybrid" | "vector";
|
|
@@ -48,7 +49,7 @@ interface PluginConfig {
|
|
|
48
49
|
rerankApiKey?: string;
|
|
49
50
|
rerankModel?: string;
|
|
50
51
|
rerankEndpoint?: string;
|
|
51
|
-
rerankProvider?: "jina" | "siliconflow" | "pinecone";
|
|
52
|
+
rerankProvider?: "jina" | "siliconflow" | "voyage" | "pinecone";
|
|
52
53
|
recencyHalfLifeDays?: number;
|
|
53
54
|
recencyWeight?: number;
|
|
54
55
|
filterNoise?: boolean;
|
|
@@ -200,7 +201,7 @@ async function readSessionMessages(filePath: string, messageCount: number): Prom
|
|
|
200
201
|
}
|
|
201
202
|
}
|
|
202
203
|
}
|
|
203
|
-
} catch {}
|
|
204
|
+
} catch { }
|
|
204
205
|
}
|
|
205
206
|
|
|
206
207
|
if (messages.length === 0) return null;
|
|
@@ -225,7 +226,7 @@ async function readSessionContentWithResetFallback(sessionFilePath: string, mess
|
|
|
225
226
|
const latestResetPath = join(dir, resetCandidates[resetCandidates.length - 1]);
|
|
226
227
|
return await readSessionMessages(latestResetPath, messageCount);
|
|
227
228
|
}
|
|
228
|
-
} catch {}
|
|
229
|
+
} catch { }
|
|
229
230
|
|
|
230
231
|
return primary;
|
|
231
232
|
}
|
|
@@ -264,7 +265,7 @@ async function findPreviousSessionFile(sessionsDir: string, currentSessionFile?:
|
|
|
264
265
|
.sort().reverse();
|
|
265
266
|
if (nonReset.length > 0) return join(sessionsDir, nonReset[0]);
|
|
266
267
|
}
|
|
267
|
-
} catch {}
|
|
268
|
+
} catch { }
|
|
268
269
|
}
|
|
269
270
|
|
|
270
271
|
// ============================================================================
|
|
@@ -367,7 +368,7 @@ const memoryLanceDBProPlugin = {
|
|
|
367
368
|
// Default is OFF to prevent the model from accidentally echoing injected context.
|
|
368
369
|
if (config.autoRecall === true) {
|
|
369
370
|
api.on("before_agent_start", async (event, ctx) => {
|
|
370
|
-
if (!event.prompt || shouldSkipRetrieval(event.prompt)) {
|
|
371
|
+
if (!event.prompt || shouldSkipRetrieval(event.prompt, config.autoRecallMinLength)) {
|
|
371
372
|
return;
|
|
372
373
|
}
|
|
373
374
|
|
|
@@ -623,7 +624,7 @@ const memoryLanceDBProPlugin = {
|
|
|
623
624
|
if (files.length > 7) {
|
|
624
625
|
const { unlink } = await import("node:fs/promises");
|
|
625
626
|
for (const old of files.slice(0, files.length - 7)) {
|
|
626
|
-
await unlink(join(backupDir, old)).catch(() => {});
|
|
627
|
+
await unlink(join(backupDir, old)).catch(() => { });
|
|
627
628
|
}
|
|
628
629
|
}
|
|
629
630
|
|
|
@@ -664,10 +665,10 @@ const memoryLanceDBProPlugin = {
|
|
|
664
665
|
|
|
665
666
|
api.logger.info(
|
|
666
667
|
`memory-lancedb-pro: initialized successfully ` +
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
668
|
+
`(embedding: ${embedTest.success ? "OK" : "FAIL"}, ` +
|
|
669
|
+
`retrieval: ${retrievalTest.success ? "OK" : "FAIL"}, ` +
|
|
670
|
+
`mode: ${retrievalTest.mode}, ` +
|
|
671
|
+
`FTS: ${retrievalTest.hasFtsSupport ? "enabled" : "disabled"})`
|
|
671
672
|
);
|
|
672
673
|
|
|
673
674
|
if (!embedTest.success) {
|
|
@@ -701,54 +702,55 @@ const memoryLanceDBProPlugin = {
|
|
|
701
702
|
};
|
|
702
703
|
|
|
703
704
|
function parsePluginConfig(value: unknown): PluginConfig {
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
705
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
706
|
+
throw new Error("memory-lancedb-pro config required");
|
|
707
|
+
}
|
|
708
|
+
const cfg = value as Record<string, unknown>;
|
|
708
709
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
710
|
+
const embedding = cfg.embedding as Record<string, unknown> | undefined;
|
|
711
|
+
if (!embedding) {
|
|
712
|
+
throw new Error("embedding config is required");
|
|
713
|
+
}
|
|
713
714
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
715
|
+
const apiKey = typeof embedding.apiKey === "string"
|
|
716
|
+
? embedding.apiKey
|
|
717
|
+
: process.env.OPENAI_API_KEY || "";
|
|
717
718
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
719
|
+
if (!apiKey) {
|
|
720
|
+
throw new Error("embedding.apiKey is required (set directly or via OPENAI_API_KEY env var)");
|
|
721
|
+
}
|
|
721
722
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
723
|
+
return {
|
|
724
|
+
embedding: {
|
|
725
|
+
provider: "openai-compatible",
|
|
726
|
+
apiKey,
|
|
727
|
+
model: typeof embedding.model === "string" ? embedding.model : "text-embedding-3-small",
|
|
728
|
+
baseURL: typeof embedding.baseURL === "string" ? resolveEnvVars(embedding.baseURL) : undefined,
|
|
729
|
+
// Accept number, numeric string, or env-var string (e.g. "${EMBED_DIM}").
|
|
730
|
+
// Also accept legacy top-level `dimensions` for convenience.
|
|
731
|
+
dimensions: parsePositiveInt(embedding.dimensions ?? cfg.dimensions),
|
|
732
|
+
taskQuery: typeof embedding.taskQuery === "string" ? embedding.taskQuery : undefined,
|
|
733
|
+
taskPassage: typeof embedding.taskPassage === "string" ? embedding.taskPassage : undefined,
|
|
734
|
+
normalized: typeof embedding.normalized === "boolean" ? embedding.normalized : undefined,
|
|
735
|
+
},
|
|
736
|
+
dbPath: typeof cfg.dbPath === "string" ? cfg.dbPath : undefined,
|
|
737
|
+
autoCapture: cfg.autoCapture !== false,
|
|
738
|
+
// Default OFF: only enable when explicitly set to true.
|
|
739
|
+
autoRecall: cfg.autoRecall === true,
|
|
740
|
+
autoRecallMinLength: parsePositiveInt(cfg.autoRecallMinLength),
|
|
741
|
+
captureAssistant: cfg.captureAssistant === true,
|
|
742
|
+
retrieval: typeof cfg.retrieval === "object" && cfg.retrieval !== null ? cfg.retrieval as any : undefined,
|
|
743
|
+
scopes: typeof cfg.scopes === "object" && cfg.scopes !== null ? cfg.scopes as any : undefined,
|
|
744
|
+
enableManagementTools: cfg.enableManagementTools === true,
|
|
745
|
+
sessionMemory: typeof cfg.sessionMemory === "object" && cfg.sessionMemory !== null
|
|
746
|
+
? {
|
|
747
|
+
enabled: (cfg.sessionMemory as Record<string, unknown>).enabled !== false,
|
|
748
|
+
messageCount: typeof (cfg.sessionMemory as Record<string, unknown>).messageCount === "number"
|
|
749
|
+
? (cfg.sessionMemory as Record<string, unknown>).messageCount as number
|
|
750
|
+
: undefined,
|
|
751
|
+
}
|
|
752
|
+
: undefined,
|
|
753
|
+
};
|
|
752
754
|
}
|
|
753
755
|
|
|
754
|
-
export default memoryLanceDBProPlugin;
|
|
756
|
+
export default memoryLanceDBProPlugin;
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "memory-lancedb-pro",
|
|
3
3
|
"name": "Memory (LanceDB Pro)",
|
|
4
4
|
"description": "Enhanced LanceDB-backed long-term memory with hybrid retrieval, multi-scope isolation, and management CLI",
|
|
5
|
-
"version": "1.0.
|
|
5
|
+
"version": "1.0.13",
|
|
6
6
|
"kind": "memory",
|
|
7
7
|
"configSchema": {
|
|
8
8
|
"type": "object",
|
|
@@ -61,6 +61,13 @@
|
|
|
61
61
|
"type": "boolean",
|
|
62
62
|
"default": false
|
|
63
63
|
},
|
|
64
|
+
"autoRecallMinLength": {
|
|
65
|
+
"type": "integer",
|
|
66
|
+
"minimum": 1,
|
|
67
|
+
"maximum": 200,
|
|
68
|
+
"default": 15,
|
|
69
|
+
"description": "Minimum prompt length (in characters) to trigger auto-recall. Prompts shorter than this are skipped. Default: 15 for English, 6 for CJK."
|
|
70
|
+
},
|
|
64
71
|
"captureAssistant": {
|
|
65
72
|
"type": "boolean"
|
|
66
73
|
},
|
|
@@ -122,6 +129,7 @@
|
|
|
122
129
|
"enum": [
|
|
123
130
|
"jina",
|
|
124
131
|
"siliconflow",
|
|
132
|
+
"voyage",
|
|
125
133
|
"pinecone"
|
|
126
134
|
],
|
|
127
135
|
"default": "jina",
|
|
@@ -266,6 +274,11 @@
|
|
|
266
274
|
"label": "Auto-Recall",
|
|
267
275
|
"help": "Automatically inject relevant memories into context"
|
|
268
276
|
},
|
|
277
|
+
"autoRecallMinLength": {
|
|
278
|
+
"label": "Auto-Recall Min Length",
|
|
279
|
+
"help": "Minimum prompt length to trigger auto-recall (shorter prompts are skipped). Default: 15 chars for English, 6 for CJK.",
|
|
280
|
+
"advanced": true
|
|
281
|
+
},
|
|
269
282
|
"captureAssistant": {
|
|
270
283
|
"label": "Capture Assistant Messages",
|
|
271
284
|
"help": "Also auto-capture assistant messages (default false to reduce memory pollution)",
|
|
@@ -317,7 +330,7 @@
|
|
|
317
330
|
},
|
|
318
331
|
"retrieval.rerankProvider": {
|
|
319
332
|
"label": "Reranker Provider",
|
|
320
|
-
"help": "Provider format: jina (default), siliconflow, or pinecone",
|
|
333
|
+
"help": "Provider format: jina (default), siliconflow, voyage, or pinecone",
|
|
321
334
|
"advanced": true
|
|
322
335
|
},
|
|
323
336
|
"retrieval.candidatePoolSize": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memory-lancedb-pro",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.13",
|
|
4
4
|
"description": "OpenClaw enhanced LanceDB memory plugin with hybrid retrieval (Vector + BM25), cross-encoder rerank, multi-scope isolation, and management CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -40,4 +40,4 @@
|
|
|
40
40
|
"jiti": "^2.6.0",
|
|
41
41
|
"typescript": "^5.9.3"
|
|
42
42
|
}
|
|
43
|
-
}
|
|
43
|
+
}
|
|
@@ -18,9 +18,11 @@ const SKIP_PATTERNS = [
|
|
|
18
18
|
/^(go ahead|continue|proceed|do it|start|begin|next|实施|开始|继续|好的|可以|行)\s*[.!]?$/i,
|
|
19
19
|
// Pure emoji
|
|
20
20
|
/^[\p{Emoji}\s]+$/u,
|
|
21
|
-
// Heartbeat/system
|
|
22
|
-
|
|
21
|
+
// Heartbeat/system (match anywhere, not just at start, to handle prefixed formats)
|
|
22
|
+
/HEARTBEAT/i,
|
|
23
23
|
/^\[System/i,
|
|
24
|
+
// Single-word utility pings
|
|
25
|
+
/^(ping|pong|test|debug)\s*[.!?]?$/i,
|
|
24
26
|
];
|
|
25
27
|
|
|
26
28
|
// Queries that SHOULD trigger retrieval even if short
|
|
@@ -61,8 +63,10 @@ function normalizeQuery(query: string): string {
|
|
|
61
63
|
/**
|
|
62
64
|
* Determine if a query should skip memory retrieval.
|
|
63
65
|
* Returns true if retrieval should be skipped.
|
|
66
|
+
* @param query The raw prompt text
|
|
67
|
+
* @param minLength Optional minimum length override (if set, overrides built-in thresholds)
|
|
64
68
|
*/
|
|
65
|
-
export function shouldSkipRetrieval(query: string): boolean {
|
|
69
|
+
export function shouldSkipRetrieval(query: string, minLength?: number): boolean {
|
|
66
70
|
const trimmed = normalizeQuery(query);
|
|
67
71
|
|
|
68
72
|
// Force retrieve if query has memory-related intent (checked FIRST,
|
|
@@ -75,11 +79,17 @@ export function shouldSkipRetrieval(query: string): boolean {
|
|
|
75
79
|
// Skip if matches any skip pattern
|
|
76
80
|
if (SKIP_PATTERNS.some(p => p.test(trimmed))) return true;
|
|
77
81
|
|
|
82
|
+
// If caller provides a custom minimum length, use it
|
|
83
|
+
if (minLength !== undefined && minLength > 0) {
|
|
84
|
+
if (trimmed.length < minLength && !trimmed.includes('?') && !trimmed.includes('?')) return true;
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
78
88
|
// Skip very short non-question messages (likely commands or affirmations)
|
|
79
89
|
// CJK characters carry more meaning per character, so use a lower threshold
|
|
80
90
|
const hasCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(trimmed);
|
|
81
|
-
const
|
|
82
|
-
if (trimmed.length <
|
|
91
|
+
const defaultMinLength = hasCJK ? 6 : 15;
|
|
92
|
+
if (trimmed.length < defaultMinLength && !trimmed.includes('?') && !trimmed.includes('?')) return true;
|
|
83
93
|
|
|
84
94
|
// Default: do retrieve
|
|
85
95
|
return false;
|
package/src/embedder.ts
CHANGED
package/src/retriever.ts
CHANGED
|
@@ -33,8 +33,9 @@ export interface RetrievalConfig {
|
|
|
33
33
|
/** Reranker provider format. Determines request/response shape and auth header.
|
|
34
34
|
* - "jina" (default): Authorization: Bearer, string[] documents, results[].relevance_score
|
|
35
35
|
* - "siliconflow": same format as jina (alias, for clarity)
|
|
36
|
+
* - "voyage": Authorization: Bearer, string[] documents, data[].relevance_score
|
|
36
37
|
* - "pinecone": Api-Key header, {text}[] documents, data[].score */
|
|
37
|
-
rerankProvider?: "jina" | "siliconflow" | "pinecone";
|
|
38
|
+
rerankProvider?: "jina" | "siliconflow" | "voyage" | "pinecone";
|
|
38
39
|
/**
|
|
39
40
|
* Length normalization: penalize long entries that dominate via sheer keyword
|
|
40
41
|
* density. Formula: score *= 1 / (1 + log2(charLen / anchor)).
|
|
@@ -115,7 +116,7 @@ function clamp01(value: number, fallback: number): number {
|
|
|
115
116
|
// Rerank Provider Adapters
|
|
116
117
|
// ============================================================================
|
|
117
118
|
|
|
118
|
-
type RerankProvider = "jina" | "siliconflow" | "pinecone";
|
|
119
|
+
type RerankProvider = "jina" | "siliconflow" | "voyage" | "pinecone";
|
|
119
120
|
|
|
120
121
|
interface RerankItem { index: number; score: number }
|
|
121
122
|
|
|
@@ -144,6 +145,20 @@ function buildRerankRequest(
|
|
|
144
145
|
rank_fields: ["text"],
|
|
145
146
|
},
|
|
146
147
|
};
|
|
148
|
+
case "voyage":
|
|
149
|
+
return {
|
|
150
|
+
headers: {
|
|
151
|
+
"Content-Type": "application/json",
|
|
152
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
153
|
+
},
|
|
154
|
+
body: {
|
|
155
|
+
model,
|
|
156
|
+
query,
|
|
157
|
+
documents,
|
|
158
|
+
// Voyage uses top_k (not top_n) to limit reranked outputs.
|
|
159
|
+
top_k: topN,
|
|
160
|
+
},
|
|
161
|
+
};
|
|
147
162
|
case "siliconflow":
|
|
148
163
|
case "jina":
|
|
149
164
|
default:
|
|
@@ -167,20 +182,56 @@ function parseRerankResponse(
|
|
|
167
182
|
provider: RerankProvider,
|
|
168
183
|
data: Record<string, unknown>,
|
|
169
184
|
): RerankItem[] | null {
|
|
185
|
+
const parseItems = (
|
|
186
|
+
items: unknown,
|
|
187
|
+
scoreKeys: Array<"score" | "relevance_score">,
|
|
188
|
+
): RerankItem[] | null => {
|
|
189
|
+
if (!Array.isArray(items)) return null;
|
|
190
|
+
const parsed: RerankItem[] = [];
|
|
191
|
+
for (const raw of items as Array<Record<string, unknown>>) {
|
|
192
|
+
const index = typeof raw?.index === "number" ? raw.index : Number(raw?.index);
|
|
193
|
+
if (!Number.isFinite(index)) continue;
|
|
194
|
+
let score: number | null = null;
|
|
195
|
+
for (const key of scoreKeys) {
|
|
196
|
+
const value = raw?.[key];
|
|
197
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
198
|
+
if (Number.isFinite(n)) {
|
|
199
|
+
score = n;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (score === null) continue;
|
|
204
|
+
parsed.push({ index, score });
|
|
205
|
+
}
|
|
206
|
+
return parsed.length > 0 ? parsed : null;
|
|
207
|
+
};
|
|
208
|
+
|
|
170
209
|
switch (provider) {
|
|
171
210
|
case "pinecone": {
|
|
172
|
-
// Pinecone: { data: [{ index, score,
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
211
|
+
// Pinecone: usually { data: [{ index, score, ... }] }
|
|
212
|
+
// Also tolerate results[] with score/relevance_score for robustness.
|
|
213
|
+
return (
|
|
214
|
+
parseItems(data.data, ["score", "relevance_score"]) ??
|
|
215
|
+
parseItems(data.results, ["score", "relevance_score"])
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
case "voyage": {
|
|
219
|
+
// Voyage: usually { data: [{ index, relevance_score }] }
|
|
220
|
+
// Also tolerate results[] for compatibility across gateways.
|
|
221
|
+
return (
|
|
222
|
+
parseItems(data.data, ["relevance_score", "score"]) ??
|
|
223
|
+
parseItems(data.results, ["relevance_score", "score"])
|
|
224
|
+
);
|
|
176
225
|
}
|
|
177
226
|
case "siliconflow":
|
|
178
227
|
case "jina":
|
|
179
228
|
default: {
|
|
180
|
-
// Jina / SiliconFlow: { results: [{ index, relevance_score }] }
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
229
|
+
// Jina / SiliconFlow: usually { results: [{ index, relevance_score }] }
|
|
230
|
+
// Also tolerate data[] for compatibility across gateways.
|
|
231
|
+
return (
|
|
232
|
+
parseItems(data.results, ["relevance_score", "score"]) ??
|
|
233
|
+
parseItems(data.data, ["relevance_score", "score"])
|
|
234
|
+
);
|
|
184
235
|
}
|
|
185
236
|
}
|
|
186
237
|
}
|
|
@@ -282,8 +333,8 @@ export class MemoryRetriever {
|
|
|
282
333
|
this.runBM25Search(query, candidatePoolSize, scopeFilter, category),
|
|
283
334
|
]);
|
|
284
335
|
|
|
285
|
-
// Fuse results using RRF
|
|
286
|
-
const fusedResults = this.fuseResults(vectorResults, bm25Results);
|
|
336
|
+
// Fuse results using RRF (async: validates BM25-only entries exist in store)
|
|
337
|
+
const fusedResults = await this.fuseResults(vectorResults, bm25Results);
|
|
287
338
|
|
|
288
339
|
// Apply minimum score threshold
|
|
289
340
|
const filtered = fusedResults.filter(r => r.score >= this.config.minScore);
|
|
@@ -357,10 +408,10 @@ export class MemoryRetriever {
|
|
|
357
408
|
}));
|
|
358
409
|
}
|
|
359
410
|
|
|
360
|
-
private fuseResults(
|
|
411
|
+
private async fuseResults(
|
|
361
412
|
vectorResults: Array<MemorySearchResult & { rank: number }>,
|
|
362
413
|
bm25Results: Array<MemorySearchResult & { rank: number }>
|
|
363
|
-
): RetrievalResult[] {
|
|
414
|
+
): Promise<RetrievalResult[]> {
|
|
364
415
|
// Create maps for quick lookup
|
|
365
416
|
const vectorMap = new Map<string, MemorySearchResult & { rank: number }>();
|
|
366
417
|
const bm25Map = new Map<string, MemorySearchResult & { rank: number }>();
|
|
@@ -383,6 +434,18 @@ export class MemoryRetriever {
|
|
|
383
434
|
const vectorResult = vectorMap.get(id);
|
|
384
435
|
const bm25Result = bm25Map.get(id);
|
|
385
436
|
|
|
437
|
+
// FIX(#15): BM25-only results may be "ghost" entries whose vector data was
|
|
438
|
+
// deleted but whose FTS index entry lingers until the next index rebuild.
|
|
439
|
+
// Validate that the entry actually exists in the store before including it.
|
|
440
|
+
if (!vectorResult && bm25Result) {
|
|
441
|
+
try {
|
|
442
|
+
const exists = await this.store.hasId(id);
|
|
443
|
+
if (!exists) continue; // Skip ghost entry
|
|
444
|
+
} catch {
|
|
445
|
+
// If hasId fails, keep the result (fail-open)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
386
449
|
// Use the result with more complete data (prefer vector result if both exist)
|
|
387
450
|
const baseResult = vectorResult || bm25Result!;
|
|
388
451
|
|
|
@@ -392,12 +455,12 @@ export class MemoryRetriever {
|
|
|
392
455
|
const bm25Hit = bm25Result ? 1 : 0;
|
|
393
456
|
|
|
394
457
|
// Base = vector score; BM25 hit boosts by up to 15%
|
|
395
|
-
// BM25-only results use their
|
|
396
|
-
//
|
|
397
|
-
//
|
|
458
|
+
// BM25-only results use their raw BM25 score so exact keyword matches
|
|
459
|
+
// (e.g. searching "JINA_API_KEY") still surface. The previous floor of 0.5
|
|
460
|
+
// was too generous and allowed ghost entries to survive hardMinScore (0.35).
|
|
398
461
|
const fusedScore = vectorResult
|
|
399
462
|
? clamp01(vectorScore + (bm25Hit * 0.15 * vectorScore), 0.1)
|
|
400
|
-
: clamp01(
|
|
463
|
+
: clamp01(bm25Result!.score, 0.1);
|
|
401
464
|
|
|
402
465
|
fusedResults.push({
|
|
403
466
|
entry: baseResult.entry,
|
|
@@ -719,4 +782,4 @@ export function createRetriever(
|
|
|
719
782
|
): MemoryRetriever {
|
|
720
783
|
const fullConfig = { ...DEFAULT_RETRIEVAL_CONFIG, ...config };
|
|
721
784
|
return new MemoryRetriever(store, embedder, fullConfig);
|
|
722
|
-
}
|
|
785
|
+
}
|