@hanhnd/agent-kit 1.0.37 → 1.0.38
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/dist/cli.js +44 -21
- package/dist/server.js +44 -21
- package/dist/services/memory/constants.d.ts +3 -0
- package/dist/services/memory/constants.js +6 -0
- package/dist/services/memory/indexer.js +48 -14
- package/dist/services/memory/indexer.test.js +359 -4
- package/dist/services/memory/store.js +6 -6
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -15036,6 +15036,9 @@ var DEFAULT_MEMORY_CONFIG = {
|
|
|
15036
15036
|
var LOCK_RETRY_MS = 50;
|
|
15037
15037
|
var LOCK_TIMEOUT_MS = 500;
|
|
15038
15038
|
var RRF_K = 60;
|
|
15039
|
+
var FETCH_MULTIPLIER = 4;
|
|
15040
|
+
var RECENCY_WEIGHT = 0.5;
|
|
15041
|
+
var DENSE_SCORE_FLOOR = 0.2;
|
|
15039
15042
|
|
|
15040
15043
|
// src/core/config/index.ts
|
|
15041
15044
|
function resolveMemoryConfig(settings, workspaceRoot) {
|
|
@@ -15456,6 +15459,7 @@ var MemoryStore = class {
|
|
|
15456
15459
|
return 0;
|
|
15457
15460
|
}
|
|
15458
15461
|
upsert(chunks, embeddings) {
|
|
15462
|
+
const deleteChunk = this.db.prepare(`DELETE FROM memory_chunks WHERE id = ?`);
|
|
15459
15463
|
const insertChunk = this.db.prepare(`
|
|
15460
15464
|
INSERT OR REPLACE INTO memory_chunks
|
|
15461
15465
|
(id, source, source_type, heading, heading_level, content, line_start, line_end, indexed_at, file_mtime_at, embedding)
|
|
@@ -15466,7 +15470,7 @@ var MemoryStore = class {
|
|
|
15466
15470
|
const chunk = chunks[i];
|
|
15467
15471
|
try {
|
|
15468
15472
|
const vectorBinding = this.toVectorBinding(embeddings[i]);
|
|
15469
|
-
|
|
15473
|
+
deleteChunk.run(chunk.id);
|
|
15470
15474
|
insertChunk.run(
|
|
15471
15475
|
chunk.id,
|
|
15472
15476
|
chunk.source,
|
|
@@ -15495,11 +15499,10 @@ var MemoryStore = class {
|
|
|
15495
15499
|
if (!embedding) throw new Error("missing embedding");
|
|
15496
15500
|
if (!(embedding instanceof Float32Array)) throw new Error("missing embedding");
|
|
15497
15501
|
if (embedding.length !== this.config.vectorDimension) throw new Error("embedding dimension mismatch");
|
|
15498
|
-
|
|
15499
|
-
if (!Number.isFinite(
|
|
15500
|
-
|
|
15501
|
-
|
|
15502
|
-
return JSON.stringify(values);
|
|
15502
|
+
for (let i = 0; i < embedding.length; i++) {
|
|
15503
|
+
if (!Number.isFinite(embedding[i])) throw new Error("embedding contains non-finite value");
|
|
15504
|
+
}
|
|
15505
|
+
return Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength);
|
|
15503
15506
|
}
|
|
15504
15507
|
normalizeVectorDistance(distance) {
|
|
15505
15508
|
if (distance <= 0) return 1;
|
|
@@ -15855,7 +15858,7 @@ var MemoryIndexer = class {
|
|
|
15855
15858
|
return totals;
|
|
15856
15859
|
}
|
|
15857
15860
|
async search(query, topK) {
|
|
15858
|
-
const fetchLimit = topK *
|
|
15861
|
+
const fetchLimit = topK * FETCH_MULTIPLIER;
|
|
15859
15862
|
let denseResults = [];
|
|
15860
15863
|
if (this.store.vecAvailable) {
|
|
15861
15864
|
try {
|
|
@@ -15866,30 +15869,50 @@ var MemoryIndexer = class {
|
|
|
15866
15869
|
}
|
|
15867
15870
|
}
|
|
15868
15871
|
const bm25Results = this.store.searchBm25(query, fetchLimit);
|
|
15869
|
-
const
|
|
15870
|
-
|
|
15871
|
-
|
|
15872
|
-
|
|
15872
|
+
const denseIds = new Set(denseResults.map((r) => r.id));
|
|
15873
|
+
const bm25Ids = new Set(bm25Results.map((r) => r.id));
|
|
15874
|
+
const unionIds = /* @__PURE__ */ new Set([...denseIds, ...bm25Ids]);
|
|
15875
|
+
const denseRetrievalScore = new Map(denseResults.map((r) => [r.id, r.score]));
|
|
15876
|
+
const unionArray = [...unionIds];
|
|
15877
|
+
const chunks = this.store.getChunksByIds(unionArray);
|
|
15878
|
+
const chunkMap = new Map(chunks.map((c) => [c.id, c]));
|
|
15879
|
+
const unionIndexOf = new Map(unionArray.map((id, idx) => [id, idx]));
|
|
15880
|
+
const recencyOrdered = [...unionArray].sort((a, b) => {
|
|
15881
|
+
const mtimeA = chunkMap.get(a)?.fileMtimeAt ?? 0;
|
|
15882
|
+
const mtimeB = chunkMap.get(b)?.fileMtimeAt ?? 0;
|
|
15883
|
+
if (mtimeB !== mtimeA) return mtimeB - mtimeA;
|
|
15884
|
+
return (unionIndexOf.get(a) ?? 0) - (unionIndexOf.get(b) ?? 0);
|
|
15885
|
+
});
|
|
15873
15886
|
const scoreMap = /* @__PURE__ */ new Map();
|
|
15874
15887
|
denseResults.forEach((r, rank) => {
|
|
15875
|
-
scoreMap.set(r.id, { dense: 1 / (RRF_K + rank + 1), bm25: 0 });
|
|
15888
|
+
scoreMap.set(r.id, { dense: 1 / (RRF_K + rank + 1), bm25: 0, recency: 0 });
|
|
15876
15889
|
});
|
|
15877
15890
|
bm25Results.forEach((r, rank) => {
|
|
15878
|
-
const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0 };
|
|
15891
|
+
const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0, recency: 0 };
|
|
15879
15892
|
entry.bm25 = 1 / (RRF_K + rank + 1);
|
|
15880
15893
|
scoreMap.set(r.id, entry);
|
|
15881
15894
|
});
|
|
15882
|
-
|
|
15883
|
-
|
|
15884
|
-
|
|
15895
|
+
recencyOrdered.forEach((id, rank) => {
|
|
15896
|
+
const entry = scoreMap.get(id);
|
|
15897
|
+
if (!entry) return;
|
|
15898
|
+
entry.recency = RECENCY_WEIGHT * (1 / (RRF_K + rank + 1));
|
|
15899
|
+
});
|
|
15900
|
+
const denseActive = denseResults.length > 0;
|
|
15901
|
+
const bm25Active = bm25Results.length > 0;
|
|
15902
|
+
const recencyActive = unionIds.size > 0;
|
|
15903
|
+
const maxScore = ((denseActive ? 1 : 0) + (bm25Active ? 1 : 0) + (recencyActive ? RECENCY_WEIGHT : 0)) / (RRF_K + 1);
|
|
15885
15904
|
const ranked = [...scoreMap.entries()].map(([id, scores]) => ({
|
|
15886
15905
|
id,
|
|
15887
|
-
totalScore: scores.dense + scores.bm25,
|
|
15906
|
+
totalScore: scores.dense + scores.bm25 + scores.recency,
|
|
15888
15907
|
hasDense: scores.dense > 0,
|
|
15889
|
-
hasBm25: scores.bm25 > 0
|
|
15890
|
-
|
|
15891
|
-
|
|
15892
|
-
|
|
15908
|
+
hasBm25: scores.bm25 > 0,
|
|
15909
|
+
isDenseOnly: denseIds.has(id) && !bm25Ids.has(id)
|
|
15910
|
+
})).filter((candidate) => {
|
|
15911
|
+
if (!candidate.isDenseOnly) return true;
|
|
15912
|
+
const retrieval = denseRetrievalScore.get(candidate.id);
|
|
15913
|
+
if (retrieval === void 0) return true;
|
|
15914
|
+
return retrieval >= DENSE_SCORE_FLOOR;
|
|
15915
|
+
}).sort((a, b) => b.totalScore - a.totalScore);
|
|
15893
15916
|
const results = [];
|
|
15894
15917
|
const seenSources = /* @__PURE__ */ new Set();
|
|
15895
15918
|
for (const r of ranked) {
|
package/dist/server.js
CHANGED
|
@@ -35920,6 +35920,9 @@ var DEFAULT_MEMORY_CONFIG = {
|
|
|
35920
35920
|
var LOCK_RETRY_MS = 50;
|
|
35921
35921
|
var LOCK_TIMEOUT_MS = 500;
|
|
35922
35922
|
var RRF_K = 60;
|
|
35923
|
+
var FETCH_MULTIPLIER = 4;
|
|
35924
|
+
var RECENCY_WEIGHT = 0.5;
|
|
35925
|
+
var DENSE_SCORE_FLOOR = 0.2;
|
|
35923
35926
|
|
|
35924
35927
|
// src/utils/files.ts
|
|
35925
35928
|
import * as fs from "node:fs";
|
|
@@ -37093,7 +37096,7 @@ var MemoryIndexer = class {
|
|
|
37093
37096
|
return totals;
|
|
37094
37097
|
}
|
|
37095
37098
|
async search(query, topK) {
|
|
37096
|
-
const fetchLimit = topK *
|
|
37099
|
+
const fetchLimit = topK * FETCH_MULTIPLIER;
|
|
37097
37100
|
let denseResults = [];
|
|
37098
37101
|
if (this.store.vecAvailable) {
|
|
37099
37102
|
try {
|
|
@@ -37104,30 +37107,50 @@ var MemoryIndexer = class {
|
|
|
37104
37107
|
}
|
|
37105
37108
|
}
|
|
37106
37109
|
const bm25Results = this.store.searchBm25(query, fetchLimit);
|
|
37107
|
-
const
|
|
37108
|
-
|
|
37109
|
-
|
|
37110
|
-
|
|
37110
|
+
const denseIds = new Set(denseResults.map((r) => r.id));
|
|
37111
|
+
const bm25Ids = new Set(bm25Results.map((r) => r.id));
|
|
37112
|
+
const unionIds = /* @__PURE__ */ new Set([...denseIds, ...bm25Ids]);
|
|
37113
|
+
const denseRetrievalScore = new Map(denseResults.map((r) => [r.id, r.score]));
|
|
37114
|
+
const unionArray = [...unionIds];
|
|
37115
|
+
const chunks = this.store.getChunksByIds(unionArray);
|
|
37116
|
+
const chunkMap = new Map(chunks.map((c) => [c.id, c]));
|
|
37117
|
+
const unionIndexOf = new Map(unionArray.map((id, idx) => [id, idx]));
|
|
37118
|
+
const recencyOrdered = [...unionArray].sort((a, b) => {
|
|
37119
|
+
const mtimeA = chunkMap.get(a)?.fileMtimeAt ?? 0;
|
|
37120
|
+
const mtimeB = chunkMap.get(b)?.fileMtimeAt ?? 0;
|
|
37121
|
+
if (mtimeB !== mtimeA) return mtimeB - mtimeA;
|
|
37122
|
+
return (unionIndexOf.get(a) ?? 0) - (unionIndexOf.get(b) ?? 0);
|
|
37123
|
+
});
|
|
37111
37124
|
const scoreMap = /* @__PURE__ */ new Map();
|
|
37112
37125
|
denseResults.forEach((r, rank) => {
|
|
37113
|
-
scoreMap.set(r.id, { dense: 1 / (RRF_K + rank + 1), bm25: 0 });
|
|
37126
|
+
scoreMap.set(r.id, { dense: 1 / (RRF_K + rank + 1), bm25: 0, recency: 0 });
|
|
37114
37127
|
});
|
|
37115
37128
|
bm25Results.forEach((r, rank) => {
|
|
37116
|
-
const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0 };
|
|
37129
|
+
const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0, recency: 0 };
|
|
37117
37130
|
entry.bm25 = 1 / (RRF_K + rank + 1);
|
|
37118
37131
|
scoreMap.set(r.id, entry);
|
|
37119
37132
|
});
|
|
37120
|
-
|
|
37121
|
-
|
|
37122
|
-
|
|
37133
|
+
recencyOrdered.forEach((id, rank) => {
|
|
37134
|
+
const entry = scoreMap.get(id);
|
|
37135
|
+
if (!entry) return;
|
|
37136
|
+
entry.recency = RECENCY_WEIGHT * (1 / (RRF_K + rank + 1));
|
|
37137
|
+
});
|
|
37138
|
+
const denseActive = denseResults.length > 0;
|
|
37139
|
+
const bm25Active = bm25Results.length > 0;
|
|
37140
|
+
const recencyActive = unionIds.size > 0;
|
|
37141
|
+
const maxScore = ((denseActive ? 1 : 0) + (bm25Active ? 1 : 0) + (recencyActive ? RECENCY_WEIGHT : 0)) / (RRF_K + 1);
|
|
37123
37142
|
const ranked = [...scoreMap.entries()].map(([id, scores]) => ({
|
|
37124
37143
|
id,
|
|
37125
|
-
totalScore: scores.dense + scores.bm25,
|
|
37144
|
+
totalScore: scores.dense + scores.bm25 + scores.recency,
|
|
37126
37145
|
hasDense: scores.dense > 0,
|
|
37127
|
-
hasBm25: scores.bm25 > 0
|
|
37128
|
-
|
|
37129
|
-
|
|
37130
|
-
|
|
37146
|
+
hasBm25: scores.bm25 > 0,
|
|
37147
|
+
isDenseOnly: denseIds.has(id) && !bm25Ids.has(id)
|
|
37148
|
+
})).filter((candidate) => {
|
|
37149
|
+
if (!candidate.isDenseOnly) return true;
|
|
37150
|
+
const retrieval = denseRetrievalScore.get(candidate.id);
|
|
37151
|
+
if (retrieval === void 0) return true;
|
|
37152
|
+
return retrieval >= DENSE_SCORE_FLOOR;
|
|
37153
|
+
}).sort((a, b) => b.totalScore - a.totalScore);
|
|
37131
37154
|
const results = [];
|
|
37132
37155
|
const seenSources = /* @__PURE__ */ new Set();
|
|
37133
37156
|
for (const r of ranked) {
|
|
@@ -37321,6 +37344,7 @@ var MemoryStore = class {
|
|
|
37321
37344
|
return 0;
|
|
37322
37345
|
}
|
|
37323
37346
|
upsert(chunks, embeddings) {
|
|
37347
|
+
const deleteChunk = this.db.prepare(`DELETE FROM memory_chunks WHERE id = ?`);
|
|
37324
37348
|
const insertChunk = this.db.prepare(`
|
|
37325
37349
|
INSERT OR REPLACE INTO memory_chunks
|
|
37326
37350
|
(id, source, source_type, heading, heading_level, content, line_start, line_end, indexed_at, file_mtime_at, embedding)
|
|
@@ -37331,7 +37355,7 @@ var MemoryStore = class {
|
|
|
37331
37355
|
const chunk = chunks[i];
|
|
37332
37356
|
try {
|
|
37333
37357
|
const vectorBinding = this.toVectorBinding(embeddings[i]);
|
|
37334
|
-
|
|
37358
|
+
deleteChunk.run(chunk.id);
|
|
37335
37359
|
insertChunk.run(
|
|
37336
37360
|
chunk.id,
|
|
37337
37361
|
chunk.source,
|
|
@@ -37360,11 +37384,10 @@ var MemoryStore = class {
|
|
|
37360
37384
|
if (!embedding) throw new Error("missing embedding");
|
|
37361
37385
|
if (!(embedding instanceof Float32Array)) throw new Error("missing embedding");
|
|
37362
37386
|
if (embedding.length !== this.config.vectorDimension) throw new Error("embedding dimension mismatch");
|
|
37363
|
-
|
|
37364
|
-
if (!Number.isFinite(
|
|
37365
|
-
|
|
37366
|
-
|
|
37367
|
-
return JSON.stringify(values);
|
|
37387
|
+
for (let i = 0; i < embedding.length; i++) {
|
|
37388
|
+
if (!Number.isFinite(embedding[i])) throw new Error("embedding contains non-finite value");
|
|
37389
|
+
}
|
|
37390
|
+
return Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength);
|
|
37368
37391
|
}
|
|
37369
37392
|
normalizeVectorDistance(distance) {
|
|
37370
37393
|
if (distance <= 0) return 1;
|
|
@@ -4,3 +4,6 @@ export declare const DEFAULT_MEMORY_CONFIG: MemoryConfig;
|
|
|
4
4
|
export declare const LOCK_RETRY_MS = 50;
|
|
5
5
|
export declare const LOCK_TIMEOUT_MS = 500;
|
|
6
6
|
export declare const RRF_K = 60;
|
|
7
|
+
export declare const FETCH_MULTIPLIER = 4;
|
|
8
|
+
export declare const RECENCY_WEIGHT = 0.5;
|
|
9
|
+
export declare const DENSE_SCORE_FLOOR = 0.2;
|
|
@@ -16,3 +16,9 @@ export const DEFAULT_MEMORY_CONFIG = {
|
|
|
16
16
|
export const LOCK_RETRY_MS = 50;
|
|
17
17
|
export const LOCK_TIMEOUT_MS = 500;
|
|
18
18
|
export const RRF_K = 60;
|
|
19
|
+
// Tunable starting default: fetchLimit = topK * FETCH_MULTIPLIER (was hardcoded topK * 2)
|
|
20
|
+
export const FETCH_MULTIPLIER = 4;
|
|
21
|
+
// Tunable starting default: weight on the recency RRF channel; < 1 so recency cannot outrank a dual-channel durable hit
|
|
22
|
+
export const RECENCY_WEIGHT = 0.5;
|
|
23
|
+
// Tunable starting default: min dense retrieval score [0,1] for a dense-only candidate to survive (permissive start)
|
|
24
|
+
export const DENSE_SCORE_FLOOR = 0.2;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import { chunkMarkdown } from './chunker.js';
|
|
4
|
-
import { LOCK_RETRY_MS, LOCK_TIMEOUT_MS, RRF_K } from './constants.js';
|
|
4
|
+
import { DENSE_SCORE_FLOOR, FETCH_MULTIPLIER, LOCK_RETRY_MS, LOCK_TIMEOUT_MS, RECENCY_WEIGHT, RRF_K, } from './constants.js';
|
|
5
5
|
import { acquireLock, releaseLock } from '../../utils/files.js';
|
|
6
6
|
export function deriveSourceType(source) {
|
|
7
7
|
if (source.startsWith('compiled/provisional/conversation-digests/'))
|
|
@@ -118,7 +118,8 @@ export class MemoryIndexer {
|
|
|
118
118
|
return totals;
|
|
119
119
|
}
|
|
120
120
|
async search(query, topK) {
|
|
121
|
-
|
|
121
|
+
// Task 2.2: overfetch to supply enough distinct sources after per-source dedup
|
|
122
|
+
const fetchLimit = topK * FETCH_MULTIPLIER;
|
|
122
123
|
let denseResults = [];
|
|
123
124
|
if (this.store.vecAvailable) {
|
|
124
125
|
try {
|
|
@@ -130,32 +131,65 @@ export class MemoryIndexer {
|
|
|
130
131
|
}
|
|
131
132
|
}
|
|
132
133
|
const bm25Results = this.store.searchBm25(query, fetchLimit);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
// Task 2.1: candidate set = dense ∪ bm25 — no intersection filter
|
|
135
|
+
const denseIds = new Set(denseResults.map((r) => r.id));
|
|
136
|
+
const bm25Ids = new Set(bm25Results.map((r) => r.id));
|
|
137
|
+
const unionIds = new Set([...denseIds, ...bm25Ids]);
|
|
138
|
+
// Task 2.5: preserve raw dense retrieval scores [0,1] for the precision floor check
|
|
139
|
+
const denseRetrievalScore = new Map(denseResults.map((r) => [r.id, r.score]));
|
|
140
|
+
// Task 2.3: fetch all union candidates once — supplies recency mtime AND final content (no second DB call)
|
|
141
|
+
const unionArray = [...unionIds];
|
|
142
|
+
const chunks = this.store.getChunksByIds(unionArray);
|
|
143
|
+
const chunkMap = new Map(chunks.map((c) => [c.id, c]));
|
|
144
|
+
// Task 2.3: recency channel — rank union by fileMtimeAt DESC; stable tiebreak on union insertion order
|
|
145
|
+
const unionIndexOf = new Map(unionArray.map((id, idx) => [id, idx]));
|
|
146
|
+
const recencyOrdered = [...unionArray].sort((a, b) => {
|
|
147
|
+
const mtimeA = chunkMap.get(a)?.fileMtimeAt ?? 0;
|
|
148
|
+
const mtimeB = chunkMap.get(b)?.fileMtimeAt ?? 0;
|
|
149
|
+
if (mtimeB !== mtimeA)
|
|
150
|
+
return mtimeB - mtimeA;
|
|
151
|
+
return (unionIndexOf.get(a) ?? 0) - (unionIndexOf.get(b) ?? 0);
|
|
152
|
+
});
|
|
153
|
+
// RRF score accumulation: dense + bm25 + weighted recency channels
|
|
137
154
|
const scoreMap = new Map();
|
|
138
155
|
denseResults.forEach((r, rank) => {
|
|
139
|
-
scoreMap.set(r.id, { dense: 1 / (RRF_K + rank + 1), bm25: 0 });
|
|
156
|
+
scoreMap.set(r.id, { dense: 1 / (RRF_K + rank + 1), bm25: 0, recency: 0 });
|
|
140
157
|
});
|
|
141
158
|
bm25Results.forEach((r, rank) => {
|
|
142
|
-
const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0 };
|
|
159
|
+
const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0, recency: 0 };
|
|
143
160
|
entry.bm25 = 1 / (RRF_K + rank + 1);
|
|
144
161
|
scoreMap.set(r.id, entry);
|
|
145
162
|
});
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
163
|
+
recencyOrdered.forEach((id, rank) => {
|
|
164
|
+
const entry = scoreMap.get(id);
|
|
165
|
+
if (!entry)
|
|
166
|
+
return;
|
|
167
|
+
entry.recency = RECENCY_WEIGHT * (1 / (RRF_K + rank + 1));
|
|
168
|
+
});
|
|
169
|
+
// Task 2.4: active-channel normalization — maxScore sums weights of channels that produced ≥1 result
|
|
170
|
+
const denseActive = denseResults.length > 0;
|
|
171
|
+
const bm25Active = bm25Results.length > 0;
|
|
172
|
+
const recencyActive = unionIds.size > 0;
|
|
173
|
+
const maxScore = ((denseActive ? 1 : 0) + (bm25Active ? 1 : 0) + (recencyActive ? RECENCY_WEIGHT : 0)) / (RRF_K + 1);
|
|
174
|
+
// Task 2.5: build ranked list, dropping dense-only candidates below the precision floor
|
|
149
175
|
const ranked = [...scoreMap.entries()]
|
|
150
176
|
.map(([id, scores]) => ({
|
|
151
177
|
id,
|
|
152
|
-
totalScore: scores.dense + scores.bm25,
|
|
178
|
+
totalScore: scores.dense + scores.bm25 + scores.recency,
|
|
153
179
|
hasDense: scores.dense > 0,
|
|
154
180
|
hasBm25: scores.bm25 > 0,
|
|
181
|
+
isDenseOnly: denseIds.has(id) && !bm25Ids.has(id),
|
|
155
182
|
}))
|
|
183
|
+
.filter((candidate) => {
|
|
184
|
+
if (!candidate.isDenseOnly)
|
|
185
|
+
return true;
|
|
186
|
+
const retrieval = denseRetrievalScore.get(candidate.id);
|
|
187
|
+
// Only drop when a dense retrieval score exists and is below the floor
|
|
188
|
+
if (retrieval === undefined)
|
|
189
|
+
return true;
|
|
190
|
+
return retrieval >= DENSE_SCORE_FLOOR;
|
|
191
|
+
})
|
|
156
192
|
.sort((a, b) => b.totalScore - a.totalScore);
|
|
157
|
-
const chunks = this.store.getChunksByIds(ranked.map((r) => r.id));
|
|
158
|
-
const chunkMap = new Map(chunks.map((c) => [c.id, c]));
|
|
159
193
|
const results = [];
|
|
160
194
|
const seenSources = new Set();
|
|
161
195
|
for (const r of ranked) {
|
|
@@ -266,8 +266,10 @@ describe('MemoryIndexer', () => {
|
|
|
266
266
|
fs.rmSync(testDir, { recursive: true, force: true });
|
|
267
267
|
}
|
|
268
268
|
});
|
|
269
|
-
|
|
270
|
-
|
|
269
|
+
// Task 3.1: Rewrite — asserts union behavior (previously asserted the removed intersection filter).
|
|
270
|
+
// BC1 + BC2: dense-only high-score candidate surfaces; dual-channel hit ranks first.
|
|
271
|
+
test('search includes high-score dense-only results alongside BM25 matches', async () => {
|
|
272
|
+
const testCfg = makeConfig('/tmp/search-union-behavior');
|
|
271
273
|
const chunks = [
|
|
272
274
|
{
|
|
273
275
|
id: 'preference-1',
|
|
@@ -291,7 +293,7 @@ describe('MemoryIndexer', () => {
|
|
|
291
293
|
const fakeStore = {
|
|
292
294
|
vecAvailable: true,
|
|
293
295
|
searchDense: () => [
|
|
294
|
-
{ id: 'dense-only-1', score: 0.99 },
|
|
296
|
+
{ id: 'dense-only-1', score: 0.99 }, // score >= DENSE_SCORE_FLOOR (0.2) → survives floor
|
|
295
297
|
{ id: 'preference-1', score: 0.98 },
|
|
296
298
|
],
|
|
297
299
|
searchBm25: () => [{ id: 'preference-1', score: 1 }],
|
|
@@ -299,8 +301,361 @@ describe('MemoryIndexer', () => {
|
|
|
299
301
|
};
|
|
300
302
|
const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
|
|
301
303
|
const results = await testIndexer.search('personal likes and preferences of the user', 5);
|
|
302
|
-
|
|
304
|
+
const resultIds = results.map((r) => r.chunk.id);
|
|
305
|
+
assert.ok(resultIds.includes('preference-1'), `Expected preference-1 in results, got: ${resultIds.join(', ')}`);
|
|
306
|
+
assert.ok(resultIds.includes('dense-only-1'), `Expected dense-only-1 in results, got: ${resultIds.join(', ')}`);
|
|
307
|
+
assert.equal(results[0].chunk.id, 'preference-1', 'dual-channel hit must rank first');
|
|
303
308
|
assert.equal(results[0].retriever, 'both');
|
|
309
|
+
const denseOnlyResult = results.find((r) => r.chunk.id === 'dense-only-1');
|
|
310
|
+
assert.ok(denseOnlyResult, 'dense-only-1 must be present in results');
|
|
311
|
+
assert.equal(denseOnlyResult.retriever, 'dense');
|
|
312
|
+
});
|
|
313
|
+
// Task 3.2 — Regression tests BC1–BC9
|
|
314
|
+
// BC3: durable dual-channel memory ranks above a newer single-channel chunk despite lower recency score
|
|
315
|
+
test('BC3: durable dual-channel chunk ranks above newer single-channel chunk', async () => {
|
|
316
|
+
const testCfg = makeConfig('/tmp/bc3-durable-vs-recent');
|
|
317
|
+
const chunks = [
|
|
318
|
+
{
|
|
319
|
+
id: 'durable-1',
|
|
320
|
+
source: 'compiled/preferences.md',
|
|
321
|
+
heading: '',
|
|
322
|
+
headingLevel: 0,
|
|
323
|
+
content: 'I prefer dark mode',
|
|
324
|
+
lineStart: 1,
|
|
325
|
+
lineEnd: 1,
|
|
326
|
+
fileMtimeAt: 100, // old mtime
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
id: 'recent-1',
|
|
330
|
+
source: 'compiled/entities/meeting.md',
|
|
331
|
+
heading: 'Meeting',
|
|
332
|
+
headingLevel: 1,
|
|
333
|
+
content: 'Meeting notes from today',
|
|
334
|
+
lineStart: 1,
|
|
335
|
+
lineEnd: 2,
|
|
336
|
+
fileMtimeAt: 9_999_999_999_999, // very new mtime
|
|
337
|
+
},
|
|
338
|
+
];
|
|
339
|
+
const fakeStore = {
|
|
340
|
+
vecAvailable: true,
|
|
341
|
+
searchDense: () => [{ id: 'durable-1', score: 0.9 }],
|
|
342
|
+
searchBm25: () => [
|
|
343
|
+
{ id: 'durable-1', score: 1 }, // rank 0 in bm25
|
|
344
|
+
{ id: 'recent-1', score: 0.8 }, // rank 1 in bm25 — newer mtime but single bm25 channel
|
|
345
|
+
],
|
|
346
|
+
getChunksByIds: (ids) => chunks.filter((c) => ids.includes(c.id)),
|
|
347
|
+
};
|
|
348
|
+
const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
|
|
349
|
+
const results = await testIndexer.search('preferences', 5);
|
|
350
|
+
assert.ok(results.length >= 2, `Expected at least 2 results, got ${results.length}`);
|
|
351
|
+
assert.equal(results[0].chunk.id, 'durable-1', 'dual-channel durable memory must rank above newer single-channel chunk');
|
|
352
|
+
assert.equal(results[0].retriever, 'both');
|
|
353
|
+
const recentResult = results.find((r) => r.chunk.id === 'recent-1');
|
|
354
|
+
assert.ok(recentResult, 'recent-1 must still surface');
|
|
355
|
+
});
|
|
356
|
+
// BC4: search returns dense results when BM25 is empty, with recency channel applied
|
|
357
|
+
test('BC4: search returns dense results and applies recency when BM25 has no matches', async () => {
|
|
358
|
+
const testCfg = makeConfig('/tmp/bc4-dense-only-recency');
|
|
359
|
+
const chunks = [
|
|
360
|
+
{
|
|
361
|
+
id: 'semantic-old',
|
|
362
|
+
source: 'compiled/concepts/topic.md',
|
|
363
|
+
heading: 'Topic',
|
|
364
|
+
headingLevel: 1,
|
|
365
|
+
content: 'Concept about topic',
|
|
366
|
+
lineStart: 1,
|
|
367
|
+
lineEnd: 2,
|
|
368
|
+
fileMtimeAt: 1, // very old
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
id: 'semantic-new',
|
|
372
|
+
source: 'compiled/entities/recent-entity.md',
|
|
373
|
+
heading: 'Recent',
|
|
374
|
+
headingLevel: 1,
|
|
375
|
+
content: 'Recent entity content',
|
|
376
|
+
lineStart: 1,
|
|
377
|
+
lineEnd: 2,
|
|
378
|
+
fileMtimeAt: 9_999_999_999_999, // very new
|
|
379
|
+
},
|
|
380
|
+
];
|
|
381
|
+
const fakeStore = {
|
|
382
|
+
vecAvailable: true,
|
|
383
|
+
searchDense: () => [
|
|
384
|
+
{ id: 'semantic-old', score: 0.9 }, // dense rank 0
|
|
385
|
+
{ id: 'semantic-new', score: 0.85 }, // dense rank 1
|
|
386
|
+
],
|
|
387
|
+
searchBm25: () => [],
|
|
388
|
+
getChunksByIds: (ids) => chunks.filter((c) => ids.includes(c.id)),
|
|
389
|
+
};
|
|
390
|
+
const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
|
|
391
|
+
const results = await testIndexer.search('topic', 5);
|
|
392
|
+
assert.ok(results.length >= 2, `Expected at least 2 dense results, got ${results.length}`);
|
|
393
|
+
const ids = results.map((r) => r.chunk.id);
|
|
394
|
+
assert.ok(ids.includes('semantic-old'), 'semantic-old must be present');
|
|
395
|
+
assert.ok(ids.includes('semantic-new'), 'semantic-new must be present');
|
|
396
|
+
for (const result of results) {
|
|
397
|
+
assert.equal(result.retriever, 'dense', `Expected retriever 'dense', got '${result.retriever}'`);
|
|
398
|
+
assert.ok(result.score > 0, 'Normalized score must be positive');
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
// BC5: dense channel unavailable — bm25 results returned with finite normalized score, no throw
|
|
402
|
+
test('BC5: bm25 results are returned with normalized score when dense channel is unavailable', async () => {
|
|
403
|
+
const testCfg = makeConfig('/tmp/bc5-dense-unavailable');
|
|
404
|
+
const chunks = [
|
|
405
|
+
{
|
|
406
|
+
id: 'keyword-1',
|
|
407
|
+
source: 'compiled/concepts/keyword.md',
|
|
408
|
+
heading: 'Keyword',
|
|
409
|
+
headingLevel: 1,
|
|
410
|
+
content: 'Keyword concept content',
|
|
411
|
+
lineStart: 1,
|
|
412
|
+
lineEnd: 2,
|
|
413
|
+
},
|
|
414
|
+
];
|
|
415
|
+
const fakeStore = {
|
|
416
|
+
vecAvailable: false,
|
|
417
|
+
searchBm25: () => [{ id: 'keyword-1', score: 1 }],
|
|
418
|
+
getChunksByIds: (ids) => chunks.filter((c) => ids.includes(c.id)),
|
|
419
|
+
};
|
|
420
|
+
const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
|
|
421
|
+
const results = await testIndexer.search('keyword', 5);
|
|
422
|
+
assert.equal(results.length, 1, 'Must return bm25 result when dense is unavailable');
|
|
423
|
+
assert.equal(results[0].chunk.id, 'keyword-1');
|
|
424
|
+
assert.equal(results[0].retriever, 'bm25');
|
|
425
|
+
assert.ok(Number.isFinite(results[0].score), 'Score must be finite');
|
|
426
|
+
assert.ok(results[0].score >= 0 && results[0].score <= 1, `Score must be in [0,1], got ${results[0].score}`);
|
|
427
|
+
});
|
|
428
|
+
// BC6: both channels empty → search returns []
|
|
429
|
+
test('BC6: search returns empty array when both dense and bm25 channels are empty', async () => {
|
|
430
|
+
const testCfg = makeConfig('/tmp/bc6-both-empty');
|
|
431
|
+
const fakeStore = {
|
|
432
|
+
vecAvailable: false,
|
|
433
|
+
searchBm25: () => [],
|
|
434
|
+
getChunksByIds: () => [],
|
|
435
|
+
};
|
|
436
|
+
const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
|
|
437
|
+
const results = await testIndexer.search('anything', 5);
|
|
438
|
+
assert.deepEqual(results, []);
|
|
439
|
+
});
|
|
440
|
+
// BC7: per-source dedup yields up to topK distinct sources even when one source dominates the pool
|
|
441
|
+
test('BC7: per-source dedup yields topK distinct sources when one source dominates the pool', async () => {
|
|
442
|
+
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bc7-overfetch-'));
|
|
443
|
+
const testCfg = makeConfig(path.join(testDir, 'wiki'));
|
|
444
|
+
const allChunks = [
|
|
445
|
+
{
|
|
446
|
+
id: 'dom-1',
|
|
447
|
+
source: 'compiled/entities/dominant.md',
|
|
448
|
+
heading: '',
|
|
449
|
+
headingLevel: 0,
|
|
450
|
+
content: 'dom 1',
|
|
451
|
+
lineStart: 1,
|
|
452
|
+
lineEnd: 1,
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
id: 'dom-2',
|
|
456
|
+
source: 'compiled/entities/dominant.md',
|
|
457
|
+
heading: '',
|
|
458
|
+
headingLevel: 0,
|
|
459
|
+
content: 'dom 2',
|
|
460
|
+
lineStart: 2,
|
|
461
|
+
lineEnd: 2,
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
id: 'dom-3',
|
|
465
|
+
source: 'compiled/entities/dominant.md',
|
|
466
|
+
heading: '',
|
|
467
|
+
headingLevel: 0,
|
|
468
|
+
content: 'dom 3',
|
|
469
|
+
lineStart: 3,
|
|
470
|
+
lineEnd: 3,
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
id: 'dom-4',
|
|
474
|
+
source: 'compiled/entities/dominant.md',
|
|
475
|
+
heading: '',
|
|
476
|
+
headingLevel: 0,
|
|
477
|
+
content: 'dom 4',
|
|
478
|
+
lineStart: 4,
|
|
479
|
+
lineEnd: 4,
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
id: 'dom-5',
|
|
483
|
+
source: 'compiled/entities/dominant.md',
|
|
484
|
+
heading: '',
|
|
485
|
+
headingLevel: 0,
|
|
486
|
+
content: 'dom 5',
|
|
487
|
+
lineStart: 5,
|
|
488
|
+
lineEnd: 5,
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
id: 'src-b-1',
|
|
492
|
+
source: 'compiled/entities/source-b.md',
|
|
493
|
+
heading: '',
|
|
494
|
+
headingLevel: 0,
|
|
495
|
+
content: 'source b',
|
|
496
|
+
lineStart: 1,
|
|
497
|
+
lineEnd: 1,
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
id: 'src-c-1',
|
|
501
|
+
source: 'compiled/entities/source-c.md',
|
|
502
|
+
heading: '',
|
|
503
|
+
headingLevel: 0,
|
|
504
|
+
content: 'source c',
|
|
505
|
+
lineStart: 1,
|
|
506
|
+
lineEnd: 1,
|
|
507
|
+
},
|
|
508
|
+
];
|
|
509
|
+
const fakeStore = {
|
|
510
|
+
vecAvailable: false,
|
|
511
|
+
searchBm25: () => [
|
|
512
|
+
{ id: 'dom-1', score: 5 },
|
|
513
|
+
{ id: 'dom-2', score: 4.9 },
|
|
514
|
+
{ id: 'dom-3', score: 4.8 },
|
|
515
|
+
{ id: 'dom-4', score: 4.7 },
|
|
516
|
+
{ id: 'dom-5', score: 4.6 },
|
|
517
|
+
{ id: 'src-b-1', score: 3 },
|
|
518
|
+
{ id: 'src-c-1', score: 2 },
|
|
519
|
+
],
|
|
520
|
+
getChunksByIds: (ids) => allChunks.filter((c) => ids.includes(c.id)),
|
|
521
|
+
};
|
|
522
|
+
const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
|
|
523
|
+
try {
|
|
524
|
+
const entitiesDir = path.join(testCfg.wikiDir, 'compiled', 'entities');
|
|
525
|
+
fs.mkdirSync(entitiesDir, { recursive: true });
|
|
526
|
+
fs.writeFileSync(path.join(entitiesDir, 'dominant.md'), 'dominant content', 'utf8');
|
|
527
|
+
fs.writeFileSync(path.join(entitiesDir, 'source-b.md'), 'source b content', 'utf8');
|
|
528
|
+
fs.writeFileSync(path.join(entitiesDir, 'source-c.md'), 'source c content', 'utf8');
|
|
529
|
+
const results = await testIndexer.search('query', 3);
|
|
530
|
+
assert.equal(results.length, 3, `Expected 3 results, got ${results.length}`);
|
|
531
|
+
const sources = results.map((r) => r.chunk.source);
|
|
532
|
+
const uniqueSources = new Set(sources);
|
|
533
|
+
assert.equal(uniqueSources.size, 3, 'Each result must be from a distinct source');
|
|
534
|
+
assert.ok(sources.includes('compiled/entities/dominant.md'), 'dominant source must be present');
|
|
535
|
+
assert.ok(sources.includes('compiled/entities/source-b.md'), 'source-b must be present');
|
|
536
|
+
assert.ok(sources.includes('compiled/entities/source-c.md'), 'source-c must be present');
|
|
537
|
+
}
|
|
538
|
+
finally {
|
|
539
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
// BC8: dense-only candidate below DENSE_SCORE_FLOOR (0.2) is dropped; at/above floor is kept; bm25-only never dropped
|
|
543
|
+
test('BC8: dense-only candidate below DENSE_SCORE_FLOOR is dropped; bm25-only and above-floor are kept', async () => {
|
|
544
|
+
const testCfg = makeConfig('/tmp/bc8-floor');
|
|
545
|
+
const chunks = [
|
|
546
|
+
{
|
|
547
|
+
id: 'below-floor',
|
|
548
|
+
source: 'compiled/entities/below.md',
|
|
549
|
+
heading: '',
|
|
550
|
+
headingLevel: 0,
|
|
551
|
+
content: 'below floor dense',
|
|
552
|
+
lineStart: 1,
|
|
553
|
+
lineEnd: 1,
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
id: 'above-floor',
|
|
557
|
+
source: 'compiled/entities/above.md',
|
|
558
|
+
heading: '',
|
|
559
|
+
headingLevel: 0,
|
|
560
|
+
content: 'above floor dense',
|
|
561
|
+
lineStart: 1,
|
|
562
|
+
lineEnd: 1,
|
|
563
|
+
},
|
|
564
|
+
{
|
|
565
|
+
id: 'bm25-overlap',
|
|
566
|
+
source: 'compiled/concepts/overlap.md',
|
|
567
|
+
heading: '',
|
|
568
|
+
headingLevel: 0,
|
|
569
|
+
content: 'overlap content',
|
|
570
|
+
lineStart: 1,
|
|
571
|
+
lineEnd: 1,
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
id: 'bm25-only',
|
|
575
|
+
source: 'compiled/concepts/keyword-only.md',
|
|
576
|
+
heading: '',
|
|
577
|
+
headingLevel: 0,
|
|
578
|
+
content: 'keyword only',
|
|
579
|
+
lineStart: 1,
|
|
580
|
+
lineEnd: 1,
|
|
581
|
+
},
|
|
582
|
+
];
|
|
583
|
+
const fakeStore = {
|
|
584
|
+
vecAvailable: true,
|
|
585
|
+
// below-floor: dense-only, score 0.1 < DENSE_SCORE_FLOOR (0.2) → must be dropped
|
|
586
|
+
// above-floor: dense-only, score 0.9 >= DENSE_SCORE_FLOOR (0.2) → must be kept
|
|
587
|
+
// bm25-overlap: in both channels → never dropped regardless of dense score
|
|
588
|
+
searchDense: () => [
|
|
589
|
+
{ id: 'below-floor', score: 0.1 },
|
|
590
|
+
{ id: 'above-floor', score: 0.9 },
|
|
591
|
+
{ id: 'bm25-overlap', score: 0.3 },
|
|
592
|
+
],
|
|
593
|
+
searchBm25: () => [
|
|
594
|
+
{ id: 'bm25-overlap', score: 1 },
|
|
595
|
+
{ id: 'bm25-only', score: 0.8 },
|
|
596
|
+
],
|
|
597
|
+
getChunksByIds: (ids) => chunks.filter((c) => ids.includes(c.id)),
|
|
598
|
+
};
|
|
599
|
+
const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
|
|
600
|
+
const results = await testIndexer.search('query', 10);
|
|
601
|
+
const resultIds = results.map((r) => r.chunk.id);
|
|
602
|
+
assert.ok(!resultIds.includes('below-floor'), `below-floor (score 0.1 < 0.2) must be dropped; got: ${resultIds.join(', ')}`);
|
|
603
|
+
assert.ok(resultIds.includes('above-floor'), `above-floor (score 0.9 >= 0.2) must be kept; got: ${resultIds.join(', ')}`);
|
|
604
|
+
assert.ok(resultIds.includes('bm25-overlap'), `bm25-overlap (dual-channel) must always be kept`);
|
|
605
|
+
assert.ok(resultIds.includes('bm25-only'), `bm25-only must never be dropped by the floor`);
|
|
606
|
+
});
|
|
607
|
+
// BC9: recency tiebreak is stable — repeated calls with tied/absent fileMtimeAt return identical order
|
|
608
|
+
test('BC9: repeated search calls with equal-mtime candidates return deterministic order', async () => {
|
|
609
|
+
const testCfg = makeConfig('/tmp/bc9-determinism');
|
|
610
|
+
const chunks = [
|
|
611
|
+
{
|
|
612
|
+
id: 'c1',
|
|
613
|
+
source: 'compiled/entities/c1.md',
|
|
614
|
+
heading: '',
|
|
615
|
+
headingLevel: 0,
|
|
616
|
+
content: 'c1 content',
|
|
617
|
+
lineStart: 1,
|
|
618
|
+
lineEnd: 1,
|
|
619
|
+
fileMtimeAt: 0,
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
id: 'c2',
|
|
623
|
+
source: 'compiled/entities/c2.md',
|
|
624
|
+
heading: '',
|
|
625
|
+
headingLevel: 0,
|
|
626
|
+
content: 'c2 content',
|
|
627
|
+
lineStart: 1,
|
|
628
|
+
lineEnd: 1,
|
|
629
|
+
fileMtimeAt: 0,
|
|
630
|
+
},
|
|
631
|
+
{
|
|
632
|
+
id: 'c3',
|
|
633
|
+
source: 'compiled/entities/c3.md',
|
|
634
|
+
heading: '',
|
|
635
|
+
headingLevel: 0,
|
|
636
|
+
content: 'c3 content',
|
|
637
|
+
lineStart: 1,
|
|
638
|
+
lineEnd: 1,
|
|
639
|
+
fileMtimeAt: 0,
|
|
640
|
+
},
|
|
641
|
+
];
|
|
642
|
+
const fakeStore = {
|
|
643
|
+
vecAvailable: true,
|
|
644
|
+
searchDense: () => [
|
|
645
|
+
{ id: 'c1', score: 0.9 },
|
|
646
|
+
{ id: 'c2', score: 0.85 },
|
|
647
|
+
{ id: 'c3', score: 0.8 },
|
|
648
|
+
],
|
|
649
|
+
searchBm25: () => [],
|
|
650
|
+
getChunksByIds: (ids) => chunks.filter((c) => ids.includes(c.id)),
|
|
651
|
+
};
|
|
652
|
+
const testIndexer = new MemoryIndexer(fakeStore, new StubEmbedder(), testCfg);
|
|
653
|
+
const firstCall = await testIndexer.search('query', 5);
|
|
654
|
+
const secondCall = await testIndexer.search('query', 5);
|
|
655
|
+
assert.equal(firstCall.length, secondCall.length, 'Result count must be identical across calls');
|
|
656
|
+
for (let idx = 0; idx < firstCall.length; idx++) {
|
|
657
|
+
assert.equal(firstCall[idx].chunk.id, secondCall[idx].chunk.id, `Position ${idx} must be deterministic: got '${firstCall[idx].chunk.id}' vs '${secondCall[idx].chunk.id}'`);
|
|
658
|
+
}
|
|
304
659
|
});
|
|
305
660
|
test('search still returns dense-only results when BM25 has no matches', async () => {
|
|
306
661
|
const testCfg = makeConfig('/tmp/search-dense-fallback');
|
|
@@ -146,6 +146,7 @@ export class MemoryStore {
|
|
|
146
146
|
return 0;
|
|
147
147
|
}
|
|
148
148
|
upsert(chunks, embeddings) {
|
|
149
|
+
const deleteChunk = this.db.prepare(`DELETE FROM memory_chunks WHERE id = ?`);
|
|
149
150
|
const insertChunk = this.db.prepare(`
|
|
150
151
|
INSERT OR REPLACE INTO memory_chunks
|
|
151
152
|
(id, source, source_type, heading, heading_level, content, line_start, line_end, indexed_at, file_mtime_at, embedding)
|
|
@@ -157,7 +158,7 @@ export class MemoryStore {
|
|
|
157
158
|
try {
|
|
158
159
|
const vectorBinding = this.toVectorBinding(embeddings[i]);
|
|
159
160
|
// Delete existing row first to get correct rowid
|
|
160
|
-
|
|
161
|
+
deleteChunk.run(chunk.id);
|
|
161
162
|
insertChunk.run(chunk.id, chunk.source, chunk.sourceType, chunk.heading, chunk.headingLevel, chunk.content, chunk.lineStart, chunk.lineEnd, Date.now(), chunk.fileMtimeAt, vectorBinding);
|
|
162
163
|
}
|
|
163
164
|
catch (err) {
|
|
@@ -179,12 +180,11 @@ export class MemoryStore {
|
|
|
179
180
|
throw new Error('missing embedding');
|
|
180
181
|
if (embedding.length !== this.config.vectorDimension)
|
|
181
182
|
throw new Error('embedding dimension mismatch');
|
|
182
|
-
|
|
183
|
-
if (!Number.isFinite(
|
|
183
|
+
for (let i = 0; i < embedding.length; i++) {
|
|
184
|
+
if (!Number.isFinite(embedding[i]))
|
|
184
185
|
throw new Error('embedding contains non-finite value');
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
return JSON.stringify(values);
|
|
186
|
+
}
|
|
187
|
+
return Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength);
|
|
188
188
|
}
|
|
189
189
|
normalizeVectorDistance(distance) {
|
|
190
190
|
if (distance <= 0)
|