@hanhnd/agent-kit 1.0.36 → 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 +76 -24
- package/dist/core/config/index.d.ts +1 -1
- package/dist/core/config/index.js +15 -1
- package/dist/server.js +76 -24
- package/dist/services/memory/constants.d.ts +5 -1
- package/dist/services/memory/constants.js +12 -1
- package/dist/services/memory/indexer.js +48 -14
- package/dist/services/memory/indexer.test.js +359 -4
- package/dist/services/memory/memory.test.js +10 -0
- package/dist/services/memory/store.d.ts +2 -1
- package/dist/services/memory/store.js +30 -8
- package/dist/services/memory/store.test.js +43 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -15019,6 +15019,11 @@ import * as os from "os";
|
|
|
15019
15019
|
import * as path3 from "path";
|
|
15020
15020
|
|
|
15021
15021
|
// src/services/memory/constants.ts
|
|
15022
|
+
var EMBEDDING_MODEL_DIMENSIONS = {
|
|
15023
|
+
["tiny" /* TINY */]: 384,
|
|
15024
|
+
["base" /* BASE */]: 384,
|
|
15025
|
+
["large" /* LARGE */]: 768
|
|
15026
|
+
};
|
|
15022
15027
|
var DEFAULT_MEMORY_CONFIG = {
|
|
15023
15028
|
enabled: false,
|
|
15024
15029
|
wikiDir: "",
|
|
@@ -15026,22 +15031,32 @@ var DEFAULT_MEMORY_CONFIG = {
|
|
|
15026
15031
|
chunkSize: 1500,
|
|
15027
15032
|
overlapLines: 2,
|
|
15028
15033
|
embeddingModel: "base" /* BASE */,
|
|
15029
|
-
vectorDimension:
|
|
15034
|
+
vectorDimension: EMBEDDING_MODEL_DIMENSIONS["base" /* BASE */]
|
|
15030
15035
|
};
|
|
15031
15036
|
var LOCK_RETRY_MS = 50;
|
|
15032
15037
|
var LOCK_TIMEOUT_MS = 500;
|
|
15033
15038
|
var RRF_K = 60;
|
|
15039
|
+
var FETCH_MULTIPLIER = 4;
|
|
15040
|
+
var RECENCY_WEIGHT = 0.5;
|
|
15041
|
+
var DENSE_SCORE_FLOOR = 0.2;
|
|
15034
15042
|
|
|
15035
15043
|
// src/core/config/index.ts
|
|
15036
15044
|
function resolveMemoryConfig(settings, workspaceRoot) {
|
|
15037
15045
|
const override = settings.memory ?? {};
|
|
15046
|
+
const embeddingModel = isEmbeddingModelName(override.embeddingModel) ? override.embeddingModel : DEFAULT_MEMORY_CONFIG.embeddingModel;
|
|
15047
|
+
const vectorDimension = typeof override.vectorDimension === "number" && Number.isFinite(override.vectorDimension) && override.vectorDimension > 0 ? override.vectorDimension : EMBEDDING_MODEL_DIMENSIONS[embeddingModel];
|
|
15038
15048
|
return {
|
|
15039
15049
|
...DEFAULT_MEMORY_CONFIG,
|
|
15040
15050
|
...override,
|
|
15041
15051
|
enabled: override.enabled ?? DEFAULT_MEMORY_CONFIG.enabled,
|
|
15052
|
+
embeddingModel,
|
|
15053
|
+
vectorDimension,
|
|
15042
15054
|
wikiDir: typeof override.wikiDir === "string" && override.wikiDir.length > 0 ? override.wikiDir : path3.join(workspaceRoot, ".agent-kit", "wiki")
|
|
15043
15055
|
};
|
|
15044
15056
|
}
|
|
15057
|
+
function isEmbeddingModelName(value) {
|
|
15058
|
+
return value === "tiny" /* TINY */ || value === "base" /* BASE */ || value === "large" /* LARGE */;
|
|
15059
|
+
}
|
|
15045
15060
|
function getGlobalSettingsFile() {
|
|
15046
15061
|
return path3.join(os.homedir(), ".agent-kit", "settings.json");
|
|
15047
15062
|
}
|
|
@@ -15313,7 +15328,7 @@ var import_stopword = __toESM(require_stopword_cjs(), 1);
|
|
|
15313
15328
|
import Database from "libsql";
|
|
15314
15329
|
import * as fs5 from "fs";
|
|
15315
15330
|
import * as path7 from "path";
|
|
15316
|
-
var SCHEMA_VERSION =
|
|
15331
|
+
var SCHEMA_VERSION = 4;
|
|
15317
15332
|
var LibsqlDatabase = Database;
|
|
15318
15333
|
var StoreError = class extends Error {
|
|
15319
15334
|
constructor(message, cause) {
|
|
@@ -15356,7 +15371,8 @@ var MemoryStore = class {
|
|
|
15356
15371
|
createSchema(db) {
|
|
15357
15372
|
try {
|
|
15358
15373
|
const current = this.readUserVersion(db);
|
|
15359
|
-
|
|
15374
|
+
const currentVectorDimension = current >= SCHEMA_VERSION ? this.readEmbeddingColumnDimension(db) : void 0;
|
|
15375
|
+
if (current < SCHEMA_VERSION || currentVectorDimension !== void 0 && currentVectorDimension !== this.config.vectorDimension) {
|
|
15360
15376
|
db.exec(`
|
|
15361
15377
|
DROP TRIGGER IF EXISTS memory_chunks_ai;
|
|
15362
15378
|
DROP TRIGGER IF EXISTS memory_chunks_ad;
|
|
@@ -15413,6 +15429,22 @@ var MemoryStore = class {
|
|
|
15413
15429
|
throw new StoreError("schema migration failed", err);
|
|
15414
15430
|
}
|
|
15415
15431
|
}
|
|
15432
|
+
readEmbeddingColumnDimension(db) {
|
|
15433
|
+
try {
|
|
15434
|
+
const rows = db.pragma("table_info(memory_chunks)");
|
|
15435
|
+
if (!Array.isArray(rows)) return void 0;
|
|
15436
|
+
const embeddingColumn = rows.find((row) => {
|
|
15437
|
+
if (!row || typeof row !== "object") return false;
|
|
15438
|
+
const candidate = row;
|
|
15439
|
+
return candidate.name === "embedding" && typeof candidate.type === "string";
|
|
15440
|
+
});
|
|
15441
|
+
const match = /^F32_BLOB\((\d+)\)$/i.exec(embeddingColumn?.type.trim() ?? "");
|
|
15442
|
+
if (!match) return void 0;
|
|
15443
|
+
return Number.parseInt(match[1], 10);
|
|
15444
|
+
} catch {
|
|
15445
|
+
return void 0;
|
|
15446
|
+
}
|
|
15447
|
+
}
|
|
15416
15448
|
readUserVersion(db) {
|
|
15417
15449
|
const value = db.pragma("user_version", { simple: true });
|
|
15418
15450
|
if (typeof value === "number") return value;
|
|
@@ -15427,6 +15459,7 @@ var MemoryStore = class {
|
|
|
15427
15459
|
return 0;
|
|
15428
15460
|
}
|
|
15429
15461
|
upsert(chunks, embeddings) {
|
|
15462
|
+
const deleteChunk = this.db.prepare(`DELETE FROM memory_chunks WHERE id = ?`);
|
|
15430
15463
|
const insertChunk = this.db.prepare(`
|
|
15431
15464
|
INSERT OR REPLACE INTO memory_chunks
|
|
15432
15465
|
(id, source, source_type, heading, heading_level, content, line_start, line_end, indexed_at, file_mtime_at, embedding)
|
|
@@ -15437,7 +15470,7 @@ var MemoryStore = class {
|
|
|
15437
15470
|
const chunk = chunks[i];
|
|
15438
15471
|
try {
|
|
15439
15472
|
const vectorBinding = this.toVectorBinding(embeddings[i]);
|
|
15440
|
-
|
|
15473
|
+
deleteChunk.run(chunk.id);
|
|
15441
15474
|
insertChunk.run(
|
|
15442
15475
|
chunk.id,
|
|
15443
15476
|
chunk.source,
|
|
@@ -15466,11 +15499,10 @@ var MemoryStore = class {
|
|
|
15466
15499
|
if (!embedding) throw new Error("missing embedding");
|
|
15467
15500
|
if (!(embedding instanceof Float32Array)) throw new Error("missing embedding");
|
|
15468
15501
|
if (embedding.length !== this.config.vectorDimension) throw new Error("embedding dimension mismatch");
|
|
15469
|
-
|
|
15470
|
-
if (!Number.isFinite(
|
|
15471
|
-
|
|
15472
|
-
|
|
15473
|
-
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);
|
|
15474
15506
|
}
|
|
15475
15507
|
normalizeVectorDistance(distance) {
|
|
15476
15508
|
if (distance <= 0) return 1;
|
|
@@ -15826,7 +15858,7 @@ var MemoryIndexer = class {
|
|
|
15826
15858
|
return totals;
|
|
15827
15859
|
}
|
|
15828
15860
|
async search(query, topK) {
|
|
15829
|
-
const fetchLimit = topK *
|
|
15861
|
+
const fetchLimit = topK * FETCH_MULTIPLIER;
|
|
15830
15862
|
let denseResults = [];
|
|
15831
15863
|
if (this.store.vecAvailable) {
|
|
15832
15864
|
try {
|
|
@@ -15837,30 +15869,50 @@ var MemoryIndexer = class {
|
|
|
15837
15869
|
}
|
|
15838
15870
|
}
|
|
15839
15871
|
const bm25Results = this.store.searchBm25(query, fetchLimit);
|
|
15840
|
-
const
|
|
15841
|
-
|
|
15842
|
-
|
|
15843
|
-
|
|
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
|
+
});
|
|
15844
15886
|
const scoreMap = /* @__PURE__ */ new Map();
|
|
15845
15887
|
denseResults.forEach((r, rank) => {
|
|
15846
|
-
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 });
|
|
15847
15889
|
});
|
|
15848
15890
|
bm25Results.forEach((r, rank) => {
|
|
15849
|
-
const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0 };
|
|
15891
|
+
const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0, recency: 0 };
|
|
15850
15892
|
entry.bm25 = 1 / (RRF_K + rank + 1);
|
|
15851
15893
|
scoreMap.set(r.id, entry);
|
|
15852
15894
|
});
|
|
15853
|
-
|
|
15854
|
-
|
|
15855
|
-
|
|
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);
|
|
15856
15904
|
const ranked = [...scoreMap.entries()].map(([id, scores]) => ({
|
|
15857
15905
|
id,
|
|
15858
|
-
totalScore: scores.dense + scores.bm25,
|
|
15906
|
+
totalScore: scores.dense + scores.bm25 + scores.recency,
|
|
15859
15907
|
hasDense: scores.dense > 0,
|
|
15860
|
-
hasBm25: scores.bm25 > 0
|
|
15861
|
-
|
|
15862
|
-
|
|
15863
|
-
|
|
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);
|
|
15864
15916
|
const results = [];
|
|
15865
15917
|
const seenSources = /* @__PURE__ */ new Set();
|
|
15866
15918
|
for (const r of ranked) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Configuration utilities
|
|
3
3
|
* Shared configuration constants and loaders
|
|
4
4
|
*/
|
|
5
|
-
import type
|
|
5
|
+
import { type MemoryConfig } from '../../services/memory/types.js';
|
|
6
6
|
import type { ConversationDigestSettings } from '../../services/digest/types.js';
|
|
7
7
|
/**
|
|
8
8
|
* Default file extensions - expanded to support more languages
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import * as fs from 'fs';
|
|
6
6
|
import * as os from 'os';
|
|
7
7
|
import * as path from 'path';
|
|
8
|
-
import { DEFAULT_MEMORY_CONFIG } from '../../services/memory/constants.js';
|
|
8
|
+
import { DEFAULT_MEMORY_CONFIG, EMBEDDING_MODEL_DIMENSIONS } from '../../services/memory/constants.js';
|
|
9
|
+
import { EmbeddingModelName } from '../../services/memory/types.js';
|
|
9
10
|
import { atomicWriteJsonFile } from '../../utils/files.js';
|
|
10
11
|
/**
|
|
11
12
|
* Default file extensions - expanded to support more languages
|
|
@@ -40,15 +41,28 @@ export const DEFAULT_EXTENSIONS = [
|
|
|
40
41
|
*/
|
|
41
42
|
export function resolveMemoryConfig(settings, workspaceRoot) {
|
|
42
43
|
const override = settings.memory ?? {};
|
|
44
|
+
const embeddingModel = isEmbeddingModelName(override.embeddingModel)
|
|
45
|
+
? override.embeddingModel
|
|
46
|
+
: DEFAULT_MEMORY_CONFIG.embeddingModel;
|
|
47
|
+
const vectorDimension = typeof override.vectorDimension === 'number' &&
|
|
48
|
+
Number.isFinite(override.vectorDimension) &&
|
|
49
|
+
override.vectorDimension > 0
|
|
50
|
+
? override.vectorDimension
|
|
51
|
+
: EMBEDDING_MODEL_DIMENSIONS[embeddingModel];
|
|
43
52
|
return {
|
|
44
53
|
...DEFAULT_MEMORY_CONFIG,
|
|
45
54
|
...override,
|
|
46
55
|
enabled: override.enabled ?? DEFAULT_MEMORY_CONFIG.enabled,
|
|
56
|
+
embeddingModel,
|
|
57
|
+
vectorDimension,
|
|
47
58
|
wikiDir: typeof override.wikiDir === 'string' && override.wikiDir.length > 0
|
|
48
59
|
? override.wikiDir
|
|
49
60
|
: path.join(workspaceRoot, '.agent-kit', 'wiki'),
|
|
50
61
|
};
|
|
51
62
|
}
|
|
63
|
+
function isEmbeddingModelName(value) {
|
|
64
|
+
return value === EmbeddingModelName.TINY || value === EmbeddingModelName.BASE || value === EmbeddingModelName.LARGE;
|
|
65
|
+
}
|
|
52
66
|
export function loadProjectSettings(projectDir) {
|
|
53
67
|
const settingsPath = path.join(projectDir, '.agent-kit', 'settings.json');
|
|
54
68
|
if (fs.existsSync(settingsPath)) {
|
package/dist/server.js
CHANGED
|
@@ -35903,6 +35903,11 @@ import * as path2 from "path";
|
|
|
35903
35903
|
var SOURCE_TYPES = ["digest", "concept", "entity", "preference", "wiki"];
|
|
35904
35904
|
|
|
35905
35905
|
// src/services/memory/constants.ts
|
|
35906
|
+
var EMBEDDING_MODEL_DIMENSIONS = {
|
|
35907
|
+
["tiny" /* TINY */]: 384,
|
|
35908
|
+
["base" /* BASE */]: 384,
|
|
35909
|
+
["large" /* LARGE */]: 768
|
|
35910
|
+
};
|
|
35906
35911
|
var DEFAULT_MEMORY_CONFIG = {
|
|
35907
35912
|
enabled: false,
|
|
35908
35913
|
wikiDir: "",
|
|
@@ -35910,11 +35915,14 @@ var DEFAULT_MEMORY_CONFIG = {
|
|
|
35910
35915
|
chunkSize: 1500,
|
|
35911
35916
|
overlapLines: 2,
|
|
35912
35917
|
embeddingModel: "base" /* BASE */,
|
|
35913
|
-
vectorDimension:
|
|
35918
|
+
vectorDimension: EMBEDDING_MODEL_DIMENSIONS["base" /* BASE */]
|
|
35914
35919
|
};
|
|
35915
35920
|
var LOCK_RETRY_MS = 50;
|
|
35916
35921
|
var LOCK_TIMEOUT_MS = 500;
|
|
35917
35922
|
var RRF_K = 60;
|
|
35923
|
+
var FETCH_MULTIPLIER = 4;
|
|
35924
|
+
var RECENCY_WEIGHT = 0.5;
|
|
35925
|
+
var DENSE_SCORE_FLOOR = 0.2;
|
|
35918
35926
|
|
|
35919
35927
|
// src/utils/files.ts
|
|
35920
35928
|
import * as fs from "node:fs";
|
|
@@ -35985,13 +35993,20 @@ function tryAcquireProcessLock(lockPath) {
|
|
|
35985
35993
|
// src/core/config/index.ts
|
|
35986
35994
|
function resolveMemoryConfig(settings, workspaceRoot2) {
|
|
35987
35995
|
const override = settings.memory ?? {};
|
|
35996
|
+
const embeddingModel = isEmbeddingModelName(override.embeddingModel) ? override.embeddingModel : DEFAULT_MEMORY_CONFIG.embeddingModel;
|
|
35997
|
+
const vectorDimension = typeof override.vectorDimension === "number" && Number.isFinite(override.vectorDimension) && override.vectorDimension > 0 ? override.vectorDimension : EMBEDDING_MODEL_DIMENSIONS[embeddingModel];
|
|
35988
35998
|
return {
|
|
35989
35999
|
...DEFAULT_MEMORY_CONFIG,
|
|
35990
36000
|
...override,
|
|
35991
36001
|
enabled: override.enabled ?? DEFAULT_MEMORY_CONFIG.enabled,
|
|
36002
|
+
embeddingModel,
|
|
36003
|
+
vectorDimension,
|
|
35992
36004
|
wikiDir: typeof override.wikiDir === "string" && override.wikiDir.length > 0 ? override.wikiDir : path2.join(workspaceRoot2, ".agent-kit", "wiki")
|
|
35993
36005
|
};
|
|
35994
36006
|
}
|
|
36007
|
+
function isEmbeddingModelName(value) {
|
|
36008
|
+
return value === "tiny" /* TINY */ || value === "base" /* BASE */ || value === "large" /* LARGE */;
|
|
36009
|
+
}
|
|
35995
36010
|
function getGlobalSettingsFile() {
|
|
35996
36011
|
return path2.join(os.homedir(), ".agent-kit", "settings.json");
|
|
35997
36012
|
}
|
|
@@ -37081,7 +37096,7 @@ var MemoryIndexer = class {
|
|
|
37081
37096
|
return totals;
|
|
37082
37097
|
}
|
|
37083
37098
|
async search(query, topK) {
|
|
37084
|
-
const fetchLimit = topK *
|
|
37099
|
+
const fetchLimit = topK * FETCH_MULTIPLIER;
|
|
37085
37100
|
let denseResults = [];
|
|
37086
37101
|
if (this.store.vecAvailable) {
|
|
37087
37102
|
try {
|
|
@@ -37092,30 +37107,50 @@ var MemoryIndexer = class {
|
|
|
37092
37107
|
}
|
|
37093
37108
|
}
|
|
37094
37109
|
const bm25Results = this.store.searchBm25(query, fetchLimit);
|
|
37095
|
-
const
|
|
37096
|
-
|
|
37097
|
-
|
|
37098
|
-
|
|
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
|
+
});
|
|
37099
37124
|
const scoreMap = /* @__PURE__ */ new Map();
|
|
37100
37125
|
denseResults.forEach((r, rank) => {
|
|
37101
|
-
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 });
|
|
37102
37127
|
});
|
|
37103
37128
|
bm25Results.forEach((r, rank) => {
|
|
37104
|
-
const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0 };
|
|
37129
|
+
const entry = scoreMap.get(r.id) ?? { dense: 0, bm25: 0, recency: 0 };
|
|
37105
37130
|
entry.bm25 = 1 / (RRF_K + rank + 1);
|
|
37106
37131
|
scoreMap.set(r.id, entry);
|
|
37107
37132
|
});
|
|
37108
|
-
|
|
37109
|
-
|
|
37110
|
-
|
|
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);
|
|
37111
37142
|
const ranked = [...scoreMap.entries()].map(([id, scores]) => ({
|
|
37112
37143
|
id,
|
|
37113
|
-
totalScore: scores.dense + scores.bm25,
|
|
37144
|
+
totalScore: scores.dense + scores.bm25 + scores.recency,
|
|
37114
37145
|
hasDense: scores.dense > 0,
|
|
37115
|
-
hasBm25: scores.bm25 > 0
|
|
37116
|
-
|
|
37117
|
-
|
|
37118
|
-
|
|
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);
|
|
37119
37154
|
const results = [];
|
|
37120
37155
|
const seenSources = /* @__PURE__ */ new Set();
|
|
37121
37156
|
for (const r of ranked) {
|
|
@@ -37178,7 +37213,7 @@ var import_stopword = __toESM(require_stopword_cjs(), 1);
|
|
|
37178
37213
|
import Database from "libsql";
|
|
37179
37214
|
import * as fs7 from "fs";
|
|
37180
37215
|
import * as path9 from "path";
|
|
37181
|
-
var SCHEMA_VERSION =
|
|
37216
|
+
var SCHEMA_VERSION = 4;
|
|
37182
37217
|
var LibsqlDatabase = Database;
|
|
37183
37218
|
var StoreError = class extends Error {
|
|
37184
37219
|
constructor(message, cause) {
|
|
@@ -37221,7 +37256,8 @@ var MemoryStore = class {
|
|
|
37221
37256
|
createSchema(db) {
|
|
37222
37257
|
try {
|
|
37223
37258
|
const current = this.readUserVersion(db);
|
|
37224
|
-
|
|
37259
|
+
const currentVectorDimension = current >= SCHEMA_VERSION ? this.readEmbeddingColumnDimension(db) : void 0;
|
|
37260
|
+
if (current < SCHEMA_VERSION || currentVectorDimension !== void 0 && currentVectorDimension !== this.config.vectorDimension) {
|
|
37225
37261
|
db.exec(`
|
|
37226
37262
|
DROP TRIGGER IF EXISTS memory_chunks_ai;
|
|
37227
37263
|
DROP TRIGGER IF EXISTS memory_chunks_ad;
|
|
@@ -37278,6 +37314,22 @@ var MemoryStore = class {
|
|
|
37278
37314
|
throw new StoreError("schema migration failed", err);
|
|
37279
37315
|
}
|
|
37280
37316
|
}
|
|
37317
|
+
readEmbeddingColumnDimension(db) {
|
|
37318
|
+
try {
|
|
37319
|
+
const rows = db.pragma("table_info(memory_chunks)");
|
|
37320
|
+
if (!Array.isArray(rows)) return void 0;
|
|
37321
|
+
const embeddingColumn = rows.find((row) => {
|
|
37322
|
+
if (!row || typeof row !== "object") return false;
|
|
37323
|
+
const candidate = row;
|
|
37324
|
+
return candidate.name === "embedding" && typeof candidate.type === "string";
|
|
37325
|
+
});
|
|
37326
|
+
const match = /^F32_BLOB\((\d+)\)$/i.exec(embeddingColumn?.type.trim() ?? "");
|
|
37327
|
+
if (!match) return void 0;
|
|
37328
|
+
return Number.parseInt(match[1], 10);
|
|
37329
|
+
} catch {
|
|
37330
|
+
return void 0;
|
|
37331
|
+
}
|
|
37332
|
+
}
|
|
37281
37333
|
readUserVersion(db) {
|
|
37282
37334
|
const value = db.pragma("user_version", { simple: true });
|
|
37283
37335
|
if (typeof value === "number") return value;
|
|
@@ -37292,6 +37344,7 @@ var MemoryStore = class {
|
|
|
37292
37344
|
return 0;
|
|
37293
37345
|
}
|
|
37294
37346
|
upsert(chunks, embeddings) {
|
|
37347
|
+
const deleteChunk = this.db.prepare(`DELETE FROM memory_chunks WHERE id = ?`);
|
|
37295
37348
|
const insertChunk = this.db.prepare(`
|
|
37296
37349
|
INSERT OR REPLACE INTO memory_chunks
|
|
37297
37350
|
(id, source, source_type, heading, heading_level, content, line_start, line_end, indexed_at, file_mtime_at, embedding)
|
|
@@ -37302,7 +37355,7 @@ var MemoryStore = class {
|
|
|
37302
37355
|
const chunk = chunks[i];
|
|
37303
37356
|
try {
|
|
37304
37357
|
const vectorBinding = this.toVectorBinding(embeddings[i]);
|
|
37305
|
-
|
|
37358
|
+
deleteChunk.run(chunk.id);
|
|
37306
37359
|
insertChunk.run(
|
|
37307
37360
|
chunk.id,
|
|
37308
37361
|
chunk.source,
|
|
@@ -37331,11 +37384,10 @@ var MemoryStore = class {
|
|
|
37331
37384
|
if (!embedding) throw new Error("missing embedding");
|
|
37332
37385
|
if (!(embedding instanceof Float32Array)) throw new Error("missing embedding");
|
|
37333
37386
|
if (embedding.length !== this.config.vectorDimension) throw new Error("embedding dimension mismatch");
|
|
37334
|
-
|
|
37335
|
-
if (!Number.isFinite(
|
|
37336
|
-
|
|
37337
|
-
|
|
37338
|
-
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);
|
|
37339
37391
|
}
|
|
37340
37392
|
normalizeVectorDistance(distance) {
|
|
37341
37393
|
if (distance <= 0) return 1;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import { type MemoryConfig } from './types.js';
|
|
1
|
+
import { type MemoryConfig, EmbeddingModelName } from './types.js';
|
|
2
|
+
export declare const EMBEDDING_MODEL_DIMENSIONS: Record<EmbeddingModelName, number>;
|
|
2
3
|
export declare const DEFAULT_MEMORY_CONFIG: MemoryConfig;
|
|
3
4
|
export declare const LOCK_RETRY_MS = 50;
|
|
4
5
|
export declare const LOCK_TIMEOUT_MS = 500;
|
|
5
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;
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { EmbeddingModelName } from './types.js';
|
|
2
|
+
export const EMBEDDING_MODEL_DIMENSIONS = {
|
|
3
|
+
[EmbeddingModelName.TINY]: 384,
|
|
4
|
+
[EmbeddingModelName.BASE]: 384,
|
|
5
|
+
[EmbeddingModelName.LARGE]: 768,
|
|
6
|
+
};
|
|
2
7
|
export const DEFAULT_MEMORY_CONFIG = {
|
|
3
8
|
enabled: false,
|
|
4
9
|
wikiDir: '',
|
|
@@ -6,8 +11,14 @@ export const DEFAULT_MEMORY_CONFIG = {
|
|
|
6
11
|
chunkSize: 1500,
|
|
7
12
|
overlapLines: 2,
|
|
8
13
|
embeddingModel: EmbeddingModelName.BASE,
|
|
9
|
-
vectorDimension:
|
|
14
|
+
vectorDimension: EMBEDDING_MODEL_DIMENSIONS[EmbeddingModelName.BASE],
|
|
10
15
|
};
|
|
11
16
|
export const LOCK_RETRY_MS = 50;
|
|
12
17
|
export const LOCK_TIMEOUT_MS = 500;
|
|
13
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');
|
|
@@ -402,4 +402,14 @@ describe('resolveMemoryConfig', () => {
|
|
|
402
402
|
const config = resolveMemoryConfig({ memory: { wikiDir: '/custom/wiki' } }, '/repo');
|
|
403
403
|
assert.equal(config.wikiDir, '/custom/wiki');
|
|
404
404
|
});
|
|
405
|
+
test('derives vector dimension from embedding model', () => {
|
|
406
|
+
const config = resolveMemoryConfig({ memory: { embeddingModel: EmbeddingModelName.LARGE } }, '/repo');
|
|
407
|
+
assert.equal(config.embeddingModel, EmbeddingModelName.LARGE);
|
|
408
|
+
assert.equal(config.vectorDimension, 768);
|
|
409
|
+
});
|
|
410
|
+
test('preserves explicit vector dimension override', () => {
|
|
411
|
+
const config = resolveMemoryConfig({ memory: { embeddingModel: EmbeddingModelName.LARGE, vectorDimension: 1024 } }, '/repo');
|
|
412
|
+
assert.equal(config.embeddingModel, EmbeddingModelName.LARGE);
|
|
413
|
+
assert.equal(config.vectorDimension, 1024);
|
|
414
|
+
});
|
|
405
415
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { MemoryChunk, MemoryConfig, RecentSource, SourceType } from './types.js';
|
|
2
|
-
export declare const SCHEMA_VERSION =
|
|
2
|
+
export declare const SCHEMA_VERSION = 4;
|
|
3
3
|
export declare class StoreError extends Error {
|
|
4
4
|
constructor(message: string, cause?: unknown);
|
|
5
5
|
}
|
|
@@ -9,6 +9,7 @@ export declare class MemoryStore {
|
|
|
9
9
|
constructor(dbPath: string, config: MemoryConfig);
|
|
10
10
|
private openDatabase;
|
|
11
11
|
private createSchema;
|
|
12
|
+
private readEmbeddingColumnDimension;
|
|
12
13
|
private readUserVersion;
|
|
13
14
|
upsert(chunks: MemoryChunk[], embeddings: Float32Array[]): void;
|
|
14
15
|
private toVectorBinding;
|
|
@@ -2,7 +2,7 @@ import Database from 'libsql';
|
|
|
2
2
|
import * as fs from 'fs';
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import { eng, removeStopwords } from 'stopword';
|
|
5
|
-
export const SCHEMA_VERSION =
|
|
5
|
+
export const SCHEMA_VERSION = 4;
|
|
6
6
|
const LibsqlDatabase = Database;
|
|
7
7
|
export class StoreError extends Error {
|
|
8
8
|
constructor(message, cause) {
|
|
@@ -51,7 +51,9 @@ export class MemoryStore {
|
|
|
51
51
|
createSchema(db) {
|
|
52
52
|
try {
|
|
53
53
|
const current = this.readUserVersion(db);
|
|
54
|
-
|
|
54
|
+
const currentVectorDimension = current >= SCHEMA_VERSION ? this.readEmbeddingColumnDimension(db) : undefined;
|
|
55
|
+
if (current < SCHEMA_VERSION ||
|
|
56
|
+
(currentVectorDimension !== undefined && currentVectorDimension !== this.config.vectorDimension)) {
|
|
55
57
|
db.exec(`
|
|
56
58
|
DROP TRIGGER IF EXISTS memory_chunks_ai;
|
|
57
59
|
DROP TRIGGER IF EXISTS memory_chunks_ad;
|
|
@@ -109,6 +111,26 @@ export class MemoryStore {
|
|
|
109
111
|
throw new StoreError('schema migration failed', err);
|
|
110
112
|
}
|
|
111
113
|
}
|
|
114
|
+
readEmbeddingColumnDimension(db) {
|
|
115
|
+
try {
|
|
116
|
+
const rows = db.pragma('table_info(memory_chunks)');
|
|
117
|
+
if (!Array.isArray(rows))
|
|
118
|
+
return undefined;
|
|
119
|
+
const embeddingColumn = rows.find((row) => {
|
|
120
|
+
if (!row || typeof row !== 'object')
|
|
121
|
+
return false;
|
|
122
|
+
const candidate = row;
|
|
123
|
+
return candidate.name === 'embedding' && typeof candidate.type === 'string';
|
|
124
|
+
});
|
|
125
|
+
const match = /^F32_BLOB\((\d+)\)$/i.exec(embeddingColumn?.type.trim() ?? '');
|
|
126
|
+
if (!match)
|
|
127
|
+
return undefined;
|
|
128
|
+
return Number.parseInt(match[1], 10);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
112
134
|
readUserVersion(db) {
|
|
113
135
|
const value = db.pragma('user_version', { simple: true });
|
|
114
136
|
if (typeof value === 'number')
|
|
@@ -124,6 +146,7 @@ export class MemoryStore {
|
|
|
124
146
|
return 0;
|
|
125
147
|
}
|
|
126
148
|
upsert(chunks, embeddings) {
|
|
149
|
+
const deleteChunk = this.db.prepare(`DELETE FROM memory_chunks WHERE id = ?`);
|
|
127
150
|
const insertChunk = this.db.prepare(`
|
|
128
151
|
INSERT OR REPLACE INTO memory_chunks
|
|
129
152
|
(id, source, source_type, heading, heading_level, content, line_start, line_end, indexed_at, file_mtime_at, embedding)
|
|
@@ -135,7 +158,7 @@ export class MemoryStore {
|
|
|
135
158
|
try {
|
|
136
159
|
const vectorBinding = this.toVectorBinding(embeddings[i]);
|
|
137
160
|
// Delete existing row first to get correct rowid
|
|
138
|
-
|
|
161
|
+
deleteChunk.run(chunk.id);
|
|
139
162
|
insertChunk.run(chunk.id, chunk.source, chunk.sourceType, chunk.heading, chunk.headingLevel, chunk.content, chunk.lineStart, chunk.lineEnd, Date.now(), chunk.fileMtimeAt, vectorBinding);
|
|
140
163
|
}
|
|
141
164
|
catch (err) {
|
|
@@ -157,12 +180,11 @@ export class MemoryStore {
|
|
|
157
180
|
throw new Error('missing embedding');
|
|
158
181
|
if (embedding.length !== this.config.vectorDimension)
|
|
159
182
|
throw new Error('embedding dimension mismatch');
|
|
160
|
-
|
|
161
|
-
if (!Number.isFinite(
|
|
183
|
+
for (let i = 0; i < embedding.length; i++) {
|
|
184
|
+
if (!Number.isFinite(embedding[i]))
|
|
162
185
|
throw new Error('embedding contains non-finite value');
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
return JSON.stringify(values);
|
|
186
|
+
}
|
|
187
|
+
return Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength);
|
|
166
188
|
}
|
|
167
189
|
normalizeVectorDistance(distance) {
|
|
168
190
|
if (distance <= 0)
|
|
@@ -387,4 +387,47 @@ describe('MemoryStore', () => {
|
|
|
387
387
|
fs.rmSync(migrationDir, { recursive: true, force: true });
|
|
388
388
|
}
|
|
389
389
|
});
|
|
390
|
+
test('recreates schema when existing vector dimension differs from config', () => {
|
|
391
|
+
const dimensionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memory-dimension-migration-'));
|
|
392
|
+
const dimensionDbPath = path.join(dimensionDir, 'index.db');
|
|
393
|
+
const baseStore = new MemoryStore(dimensionDbPath, { ...TEST_CONFIG, wikiDir: dimensionDir });
|
|
394
|
+
try {
|
|
395
|
+
baseStore.upsert([makeChunk({ id: 'old-dimension-row', source: 'dimension.md' })], [makeEmbedding(17)]);
|
|
396
|
+
}
|
|
397
|
+
finally {
|
|
398
|
+
baseStore.close();
|
|
399
|
+
}
|
|
400
|
+
const largeConfig = {
|
|
401
|
+
...TEST_CONFIG,
|
|
402
|
+
wikiDir: dimensionDir,
|
|
403
|
+
embeddingModel: EmbeddingModelName.LARGE,
|
|
404
|
+
vectorDimension: 768,
|
|
405
|
+
};
|
|
406
|
+
const largeStore = new MemoryStore(dimensionDbPath, largeConfig);
|
|
407
|
+
try {
|
|
408
|
+
const verifyDb = new Database(dimensionDbPath, { readonly: true });
|
|
409
|
+
try {
|
|
410
|
+
const columns = verifyDb.prepare(`PRAGMA table_info(memory_chunks)`).all();
|
|
411
|
+
const embeddingColumn = columns.find((column) => column.name === 'embedding');
|
|
412
|
+
assert.equal(embeddingColumn?.type, 'F32_BLOB(768)');
|
|
413
|
+
const rowCount = verifyDb.prepare(`SELECT COUNT(*) AS count FROM memory_chunks`).get();
|
|
414
|
+
assert.equal(rowCount.count, 0);
|
|
415
|
+
}
|
|
416
|
+
finally {
|
|
417
|
+
verifyDb.close();
|
|
418
|
+
}
|
|
419
|
+
const chunk = makeChunk({
|
|
420
|
+
id: 'new-dimension-row',
|
|
421
|
+
source: 'dimension.md',
|
|
422
|
+
content: 'large dimension vector can be indexed',
|
|
423
|
+
});
|
|
424
|
+
largeStore.upsert([chunk], [makeEmbedding(18, 768)]);
|
|
425
|
+
const results = largeStore.searchBm25('large dimension vector', 5);
|
|
426
|
+
assert.ok(results.some((result) => result.id === 'new-dimension-row'));
|
|
427
|
+
}
|
|
428
|
+
finally {
|
|
429
|
+
largeStore.close();
|
|
430
|
+
fs.rmSync(dimensionDir, { recursive: true, force: true });
|
|
431
|
+
}
|
|
432
|
+
});
|
|
390
433
|
});
|