md-semantic-search 1.0.3 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +132 -16
- package/RESEARCH.md +207 -3
- package/bin/cli.mjs +449 -13
- package/dist/binary-format.js +9 -0
- package/dist/completions.js +11 -1
- package/dist/core.d.ts +42 -4
- package/dist/core.js +208 -14
- package/dist/federation.d.ts +16 -1
- package/dist/federation.js +33 -16
- package/dist/filter.d.ts +7 -0
- package/dist/filter.js +13 -0
- package/dist/hnsw.d.ts +96 -0
- package/dist/hnsw.js +326 -0
- package/dist/index-format.js +8 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/indexer.d.ts +18 -1
- package/dist/indexer.js +92 -18
- package/dist/ivf.d.ts +14 -7
- package/dist/ivf.js +126 -18
- package/dist/lexical.d.ts +10 -0
- package/dist/lexical.js +98 -15
- package/dist/markdown-parser.d.ts +62 -0
- package/dist/markdown-parser.js +227 -30
- package/dist/mcp.d.ts +229 -7
- package/dist/mcp.js +743 -44
- package/dist/models.d.ts +8 -2
- package/dist/models.js +84 -32
- package/dist/providers.d.ts +56 -0
- package/dist/providers.js +305 -0
- package/dist/quantization.d.ts +12 -0
- package/dist/quantization.js +54 -0
- package/dist/rag.d.ts +132 -7
- package/dist/rag.js +314 -38
- package/dist/repair.d.ts +1 -1
- package/dist/repair.js +53 -2
- package/dist/rerank.d.ts +51 -6
- package/dist/rerank.js +69 -10
- package/dist/search.d.ts +49 -3
- package/dist/search.js +387 -78
- package/dist/serve.d.ts +35 -0
- package/dist/serve.js +574 -58
- package/dist/tui.d.ts +14 -0
- package/dist/tui.js +132 -15
- package/dist/watcher.js +23 -12
- package/dist/webui.d.ts +3 -3
- package/dist/webui.js +225 -5
- package/dist/wikilinks.d.ts +31 -0
- package/dist/wikilinks.js +185 -1
- package/package.json +3 -3
- package/src/binary-format.ts +13 -0
- package/src/completions.ts +11 -1
- package/src/core.ts +249 -13
- package/src/federation.ts +51 -17
- package/src/filter.ts +14 -0
- package/src/hnsw.ts +414 -0
- package/src/index-format.ts +7 -1
- package/src/index.ts +7 -2
- package/src/indexer.ts +112 -19
- package/src/ivf.ts +157 -20
- package/src/lexical.ts +91 -13
- package/src/markdown-parser.ts +279 -37
- package/src/mcp.ts +820 -47
- package/src/models.ts +97 -35
- package/src/providers.ts +345 -0
- package/src/quantization.ts +61 -0
- package/src/rag.ts +451 -34
- package/src/repair.ts +51 -2
- package/src/rerank.ts +121 -14
- package/src/search.ts +424 -82
- package/src/serve.ts +589 -66
- package/src/tui.ts +142 -16
- package/src/watcher.ts +21 -10
- package/src/webui.ts +225 -5
- package/src/wikilinks.ts +220 -1
package/dist/hnsw.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure JS/TS HNSW approximate nearest neighbor index (issue #201).
|
|
3
|
+
*
|
|
4
|
+
* Hierarchical Navigable Small World graphs (Malkov & Yashunin 2016): no
|
|
5
|
+
* training phase, incremental inserts, ~99% recall with sublinear search —
|
|
6
|
+
* the counterpart of the k-means IVF in `ivf.ts`, sharing its contract:
|
|
7
|
+
* build → serialize → persist → search as a candidate pre-filter in front
|
|
8
|
+
* of the exact sweep. Cosine distance (1 − dot on normalized vectors) is
|
|
9
|
+
* computed through an injected distance function so int8 zero-copy vectors
|
|
10
|
+
* work unchanged. Deleted nodes are tombstones — searches skip them, which
|
|
11
|
+
* is what lets the incremental watch rebuild reuse a persisted graph.
|
|
12
|
+
*/
|
|
13
|
+
export interface HnswIndex {
|
|
14
|
+
dim: number;
|
|
15
|
+
/** Max links per node on layers > 0. */
|
|
16
|
+
M: number;
|
|
17
|
+
/** Random level seed — rebuilds from the same insertion order are identical. */
|
|
18
|
+
seed: number;
|
|
19
|
+
/** Node index of the graph entry (top layer). */
|
|
20
|
+
entryPoint: number;
|
|
21
|
+
/** Highest layer in the graph. */
|
|
22
|
+
maxLevel: number;
|
|
23
|
+
/** levels[i] = top layer of node i. */
|
|
24
|
+
levels: number[];
|
|
25
|
+
/** neighbors[i][l] = link list of node i on layer l (l ≤ levels[i]). */
|
|
26
|
+
neighbors: number[][][];
|
|
27
|
+
/** Tombstoned node indices — skipped by search, kept for graph connectivity. */
|
|
28
|
+
deleted: Set<number>;
|
|
29
|
+
}
|
|
30
|
+
export interface SerializedHnsw {
|
|
31
|
+
format: 'hnsw-v1';
|
|
32
|
+
dim: number;
|
|
33
|
+
M: number;
|
|
34
|
+
seed: number;
|
|
35
|
+
entryPoint: number;
|
|
36
|
+
maxLevel: number;
|
|
37
|
+
count: number;
|
|
38
|
+
levels: number[];
|
|
39
|
+
neighbors: number[][][];
|
|
40
|
+
deleted: number[];
|
|
41
|
+
}
|
|
42
|
+
export interface BuildHnswOptions {
|
|
43
|
+
M?: number;
|
|
44
|
+
seed?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Distance between the query/node vector at `a` and the stored node `b` —
|
|
47
|
+
* LOWER is closer (cosine distance: `1 − dot(a, vectors[b])`). Injected so
|
|
48
|
+
* int8 zero-copy and norm-cached sweeps work unchanged.
|
|
49
|
+
*/
|
|
50
|
+
distance: (a: Float32Array | Int8Array | number[], b: number) => number;
|
|
51
|
+
vectors: Array<Float32Array | Int8Array | number[]>;
|
|
52
|
+
}
|
|
53
|
+
export declare const DEFAULT_HNSW_M = 16;
|
|
54
|
+
export declare const DEFAULT_HNSW_EF = 64;
|
|
55
|
+
export declare function buildHnsw(vectors: Array<Float32Array | Int8Array | number[]>, options: BuildHnswOptions): HnswIndex;
|
|
56
|
+
/**
|
|
57
|
+
* Insert node `idx` (vector index) into the graph. Incremental — new indices
|
|
58
|
+
* attach to the existing structure without a rebuild; vectors for nodes NOT
|
|
59
|
+
* yet inserted are read from `vectors` when needed, so the caller can insert
|
|
60
|
+
* lazily. Tombstoned indices can be re-inserted (undeletes).
|
|
61
|
+
*/
|
|
62
|
+
export declare function insertHnsw(hnsw: HnswIndex, idx: number, distance: (a: Float32Array | Int8Array | number[], b: number) => number, vectors: Array<Float32Array | Int8Array | number[]>): void;
|
|
63
|
+
/** Tombstone a node — searches skip it; the graph keeps it for connectivity. */
|
|
64
|
+
export declare function deleteHnsw(hnsw: HnswIndex, idx: number): void;
|
|
65
|
+
/**
|
|
66
|
+
* Algorithm 4 of the HNSW paper (Malkov & Yashunin 2016): candidates sorted
|
|
67
|
+
* by distance to the query enter the result in order; a candidate is skipped
|
|
68
|
+
* when it is closer to some already-selected neighbor than to the query
|
|
69
|
+
* (redundant direction). `pairwise(a, b)` measures node-to-node distance.
|
|
70
|
+
* If fewer than `m` survive (disconnected candidate set), the closest
|
|
71
|
+
* leftovers backfill so the link count stays useful.
|
|
72
|
+
*/
|
|
73
|
+
export declare function selectNeighborsHeuristic(candidates: Array<{
|
|
74
|
+
node: number;
|
|
75
|
+
dist: number;
|
|
76
|
+
}>, m: number, pairwise: (a: number, b: number) => number): Array<{
|
|
77
|
+
node: number;
|
|
78
|
+
dist: number;
|
|
79
|
+
}>;
|
|
80
|
+
/**
|
|
81
|
+
* ANN candidate pre-filter: ef-search from the entry point, returning LIVE
|
|
82
|
+
* candidate chunk indices (tombstones excluded). Mirrors
|
|
83
|
+
* `searchIVFCandidates` — the result feeds the same probed-candidate set.
|
|
84
|
+
*/
|
|
85
|
+
export declare function searchHnswCandidates(queryVec: Float32Array | Int8Array | number[], hnsw: HnswIndex, vectors: Array<Float32Array | Int8Array | number[]>, options?: {
|
|
86
|
+
ef?: number;
|
|
87
|
+
/** Lower-is-closer distance; defaults to cosine distance via `cosine`. */
|
|
88
|
+
distance?: (a: Float32Array | Int8Array | number[], b: number) => number;
|
|
89
|
+
}): number[];
|
|
90
|
+
export declare function serializeHnsw(hnsw: HnswIndex): SerializedHnsw;
|
|
91
|
+
/**
|
|
92
|
+
* Hardened deserialization — every structural invariant is checked with the
|
|
93
|
+
* same discipline as `deserializeIVF`; any violation throws (the loader
|
|
94
|
+
* falls back to brute force).
|
|
95
|
+
*/
|
|
96
|
+
export declare function deserializeHnsw(raw: unknown, expectedChunksCount?: number): HnswIndex;
|
package/dist/hnsw.js
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure JS/TS HNSW approximate nearest neighbor index (issue #201).
|
|
3
|
+
*
|
|
4
|
+
* Hierarchical Navigable Small World graphs (Malkov & Yashunin 2016): no
|
|
5
|
+
* training phase, incremental inserts, ~99% recall with sublinear search —
|
|
6
|
+
* the counterpart of the k-means IVF in `ivf.ts`, sharing its contract:
|
|
7
|
+
* build → serialize → persist → search as a candidate pre-filter in front
|
|
8
|
+
* of the exact sweep. Cosine distance (1 − dot on normalized vectors) is
|
|
9
|
+
* computed through an injected distance function so int8 zero-copy vectors
|
|
10
|
+
* work unchanged. Deleted nodes are tombstones — searches skip them, which
|
|
11
|
+
* is what lets the incremental watch rebuild reuse a persisted graph.
|
|
12
|
+
*/
|
|
13
|
+
/** Deterministic PRNG for level assignment (same family as ivf.ts). */
|
|
14
|
+
function mulberry32(seed) {
|
|
15
|
+
let s = seed >>> 0;
|
|
16
|
+
return () => {
|
|
17
|
+
s = (s + 0x6d2b79f5) >>> 0;
|
|
18
|
+
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
|
19
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
20
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export const DEFAULT_HNSW_M = 16;
|
|
24
|
+
export const DEFAULT_HNSW_EF = 64;
|
|
25
|
+
export function buildHnsw(vectors, options) {
|
|
26
|
+
const hnsw = {
|
|
27
|
+
dim: vectors[0]?.length ?? 0,
|
|
28
|
+
M: options.M ?? DEFAULT_HNSW_M,
|
|
29
|
+
seed: options.seed ?? 42,
|
|
30
|
+
entryPoint: -1,
|
|
31
|
+
maxLevel: -1,
|
|
32
|
+
levels: [],
|
|
33
|
+
neighbors: [],
|
|
34
|
+
deleted: new Set(),
|
|
35
|
+
};
|
|
36
|
+
for (let i = 0; i < vectors.length; i++)
|
|
37
|
+
insertHnsw(hnsw, i, options.distance, options.vectors);
|
|
38
|
+
return hnsw;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Insert node `idx` (vector index) into the graph. Incremental — new indices
|
|
42
|
+
* attach to the existing structure without a rebuild; vectors for nodes NOT
|
|
43
|
+
* yet inserted are read from `vectors` when needed, so the caller can insert
|
|
44
|
+
* lazily. Tombstoned indices can be re-inserted (undeletes).
|
|
45
|
+
*/
|
|
46
|
+
export function insertHnsw(hnsw, idx, distance, vectors) {
|
|
47
|
+
const vec = vectors[idx];
|
|
48
|
+
if (!vec)
|
|
49
|
+
throw new Error(`hnsw insert: no vector at index ${idx}`);
|
|
50
|
+
if (hnsw.levels[idx] !== undefined && !hnsw.deleted.has(idx)) {
|
|
51
|
+
throw new Error(`hnsw insert: node ${idx} already present`);
|
|
52
|
+
}
|
|
53
|
+
hnsw.deleted.delete(idx);
|
|
54
|
+
const M = hnsw.M;
|
|
55
|
+
const M0 = M * 2;
|
|
56
|
+
const mL = 1 / Math.log(M);
|
|
57
|
+
const rng = mulberry32(hnsw.seed + idx * 2654435761);
|
|
58
|
+
const u = Math.max(rng(), Number.MIN_VALUE);
|
|
59
|
+
const level = Math.min(Math.floor(-Math.log(u) * mL), 64);
|
|
60
|
+
hnsw.levels[idx] = level;
|
|
61
|
+
hnsw.neighbors[idx] = Array.from({ length: level + 1 }, () => []);
|
|
62
|
+
if (hnsw.entryPoint < 0) {
|
|
63
|
+
hnsw.entryPoint = idx;
|
|
64
|
+
hnsw.maxLevel = level;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const qDist = (node) => distance(vec, node);
|
|
68
|
+
// 1. Greedy descent through layers above the node's level
|
|
69
|
+
let ep = hnsw.entryPoint;
|
|
70
|
+
let epDist = qDist(ep);
|
|
71
|
+
for (let l = hnsw.maxLevel; l > level; l--) {
|
|
72
|
+
let changed = true;
|
|
73
|
+
while (changed) {
|
|
74
|
+
changed = false;
|
|
75
|
+
for (const nb of hnsw.neighbors[ep][l] ?? []) {
|
|
76
|
+
const d = qDist(nb);
|
|
77
|
+
if (d < epDist) {
|
|
78
|
+
epDist = d;
|
|
79
|
+
ep = nb;
|
|
80
|
+
changed = true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// 2. Insert on each layer from min(level, maxLevel) down to 0
|
|
86
|
+
const pairwise = (a, b) => distance(vectors[a], b);
|
|
87
|
+
for (let l = Math.min(level, hnsw.maxLevel); l >= 0; l--) {
|
|
88
|
+
const efC = Math.max(M0, DEFAULT_HNSW_EF);
|
|
89
|
+
const candidates = searchLayer(hnsw, qDist, ep, epDist, l, efC);
|
|
90
|
+
const cap = l === 0 ? M0 : M;
|
|
91
|
+
// Algorithm 4: diversity-aware neighbor selection keeps the graph
|
|
92
|
+
// navigable — a candidate is skipped only when it is CLOSER to an
|
|
93
|
+
// already-selected neighbor than to the query
|
|
94
|
+
const selected = selectNeighborsHeuristic(candidates, cap, pairwise);
|
|
95
|
+
hnsw.neighbors[idx][l] = selected.map((c) => c.node);
|
|
96
|
+
for (const { node } of selected) {
|
|
97
|
+
const links = hnsw.neighbors[node][l];
|
|
98
|
+
if (!links.includes(idx))
|
|
99
|
+
links.push(idx);
|
|
100
|
+
if (links.length > cap) {
|
|
101
|
+
// re-select the neighbor's own links with the same heuristic — the
|
|
102
|
+
// diversity rule preserves connectivity instead of blind top-M,
|
|
103
|
+
// which used to sever the just-added edge and strand whole regions
|
|
104
|
+
const pruned = selectNeighborsHeuristic(links.map((n) => ({ node: n, dist: pairwise(node, n) })), cap, pairwise);
|
|
105
|
+
hnsw.neighbors[node][l] = pruned.map((c) => c.node);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (candidates.length > 0) {
|
|
109
|
+
ep = candidates[0].node;
|
|
110
|
+
epDist = candidates[0].dist;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (level > hnsw.maxLevel) {
|
|
114
|
+
hnsw.maxLevel = level;
|
|
115
|
+
hnsw.entryPoint = idx;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Tombstone a node — searches skip it; the graph keeps it for connectivity. */
|
|
119
|
+
export function deleteHnsw(hnsw, idx) {
|
|
120
|
+
if (hnsw.levels[idx] !== undefined)
|
|
121
|
+
hnsw.deleted.add(idx);
|
|
122
|
+
}
|
|
123
|
+
/** ef-search one layer, returning the closest `ef` live candidates (closest first). */
|
|
124
|
+
function searchLayer(hnsw, qDist, ep, epDist, layer, ef) {
|
|
125
|
+
const visited = new Set([ep]);
|
|
126
|
+
// max-heap by dist via simple sorted insertion (candidate lists are small)
|
|
127
|
+
const candidates = [{ node: ep, dist: epDist }];
|
|
128
|
+
const results = hnsw.deleted.has(ep) ? [] : [{ node: ep, dist: epDist }];
|
|
129
|
+
while (candidates.length > 0) {
|
|
130
|
+
const current = candidates.shift();
|
|
131
|
+
const worst = results.length >= ef ? results[results.length - 1].dist : Infinity;
|
|
132
|
+
if (current.dist > worst)
|
|
133
|
+
break;
|
|
134
|
+
for (const nb of hnsw.neighbors[current.node][layer] ?? []) {
|
|
135
|
+
if (visited.has(nb))
|
|
136
|
+
continue;
|
|
137
|
+
visited.add(nb);
|
|
138
|
+
const d = qDist(nb);
|
|
139
|
+
const worstNow = results.length >= ef ? results[results.length - 1].dist : Infinity;
|
|
140
|
+
if (d < worstNow || results.length < ef) {
|
|
141
|
+
candidates.push({ node: nb, dist: d });
|
|
142
|
+
candidates.sort((a, b) => a.dist - b.dist);
|
|
143
|
+
if (!hnsw.deleted.has(nb)) {
|
|
144
|
+
results.push({ node: nb, dist: d });
|
|
145
|
+
results.sort((a, b) => a.dist - b.dist);
|
|
146
|
+
if (results.length > ef)
|
|
147
|
+
results.pop();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return results;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Algorithm 4 of the HNSW paper (Malkov & Yashunin 2016): candidates sorted
|
|
156
|
+
* by distance to the query enter the result in order; a candidate is skipped
|
|
157
|
+
* when it is closer to some already-selected neighbor than to the query
|
|
158
|
+
* (redundant direction). `pairwise(a, b)` measures node-to-node distance.
|
|
159
|
+
* If fewer than `m` survive (disconnected candidate set), the closest
|
|
160
|
+
* leftovers backfill so the link count stays useful.
|
|
161
|
+
*/
|
|
162
|
+
export function selectNeighborsHeuristic(candidates, m, pairwise) {
|
|
163
|
+
const sorted = [...candidates].sort((a, b) => a.dist - b.dist);
|
|
164
|
+
const result = [];
|
|
165
|
+
const skipped = [];
|
|
166
|
+
for (const cand of sorted) {
|
|
167
|
+
if (result.length >= m)
|
|
168
|
+
break;
|
|
169
|
+
let dominated = false;
|
|
170
|
+
for (const chosen of result) {
|
|
171
|
+
if (pairwise(cand.node, chosen.node) < cand.dist) {
|
|
172
|
+
dominated = true;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (dominated)
|
|
177
|
+
skipped.push(cand);
|
|
178
|
+
else
|
|
179
|
+
result.push(cand);
|
|
180
|
+
}
|
|
181
|
+
for (const cand of skipped) {
|
|
182
|
+
if (result.length >= m)
|
|
183
|
+
break;
|
|
184
|
+
result.push(cand);
|
|
185
|
+
}
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* ANN candidate pre-filter: ef-search from the entry point, returning LIVE
|
|
190
|
+
* candidate chunk indices (tombstones excluded). Mirrors
|
|
191
|
+
* `searchIVFCandidates` — the result feeds the same probed-candidate set.
|
|
192
|
+
*/
|
|
193
|
+
export function searchHnswCandidates(queryVec, hnsw, vectors, options = {}) {
|
|
194
|
+
if (hnsw.entryPoint < 0 || hnsw.levels.length === 0)
|
|
195
|
+
return [];
|
|
196
|
+
const distance = options.distance ??
|
|
197
|
+
((a, b) => 1 - dotSimilarity(a, vectors[b]));
|
|
198
|
+
const qDist = (node) => distance(queryVec, node);
|
|
199
|
+
let ep = hnsw.entryPoint;
|
|
200
|
+
let epDist = qDist(ep);
|
|
201
|
+
for (let l = hnsw.maxLevel; l > 0; l--) {
|
|
202
|
+
let changed = true;
|
|
203
|
+
while (changed) {
|
|
204
|
+
changed = false;
|
|
205
|
+
for (const nb of hnsw.neighbors[ep][l] ?? []) {
|
|
206
|
+
const d = qDist(nb);
|
|
207
|
+
if (d < epDist) {
|
|
208
|
+
epDist = d;
|
|
209
|
+
ep = nb;
|
|
210
|
+
changed = true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const ef = Math.max(options.ef ?? DEFAULT_HNSW_EF, 1);
|
|
216
|
+
const found = searchLayer(hnsw, qDist, ep, epDist, 0, ef);
|
|
217
|
+
return found.map((f) => f.node);
|
|
218
|
+
}
|
|
219
|
+
/** Dot product that tolerates int8 storage (norm handling mirrors the exact sweep). */
|
|
220
|
+
function dotSimilarity(a, b) {
|
|
221
|
+
let s = 0;
|
|
222
|
+
const n = Math.min(a.length, b.length);
|
|
223
|
+
for (let i = 0; i < n; i++)
|
|
224
|
+
s += a[i] * b[i];
|
|
225
|
+
return s;
|
|
226
|
+
}
|
|
227
|
+
export function serializeHnsw(hnsw) {
|
|
228
|
+
return {
|
|
229
|
+
format: 'hnsw-v1',
|
|
230
|
+
dim: hnsw.dim,
|
|
231
|
+
M: hnsw.M,
|
|
232
|
+
seed: hnsw.seed,
|
|
233
|
+
entryPoint: hnsw.entryPoint,
|
|
234
|
+
maxLevel: hnsw.maxLevel,
|
|
235
|
+
count: hnsw.levels.length,
|
|
236
|
+
levels: hnsw.levels,
|
|
237
|
+
neighbors: hnsw.neighbors,
|
|
238
|
+
deleted: [...hnsw.deleted].sort((a, b) => a - b),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Hardened deserialization — every structural invariant is checked with the
|
|
243
|
+
* same discipline as `deserializeIVF`; any violation throws (the loader
|
|
244
|
+
* falls back to brute force).
|
|
245
|
+
*/
|
|
246
|
+
export function deserializeHnsw(raw, expectedChunksCount) {
|
|
247
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
248
|
+
throw new Error('invalid HNSW payload: expected JSON object — run `mdss index` to rebuild');
|
|
249
|
+
}
|
|
250
|
+
const obj = raw;
|
|
251
|
+
if (obj.format !== 'hnsw-v1') {
|
|
252
|
+
throw new Error(`invalid HNSW payload: format must be "hnsw-v1", got ${JSON.stringify(obj.format)} — run \`mdss index\` to rebuild`);
|
|
253
|
+
}
|
|
254
|
+
const { dim, M, seed, entryPoint, maxLevel, count } = obj;
|
|
255
|
+
if (typeof dim !== 'number' || !Number.isSafeInteger(dim) || dim <= 0 ||
|
|
256
|
+
typeof M !== 'number' || !Number.isSafeInteger(M) || M <= 0 || M > 128 ||
|
|
257
|
+
typeof seed !== 'number' || !Number.isSafeInteger(seed) ||
|
|
258
|
+
typeof entryPoint !== 'number' || !Number.isSafeInteger(entryPoint) || entryPoint < 0 ||
|
|
259
|
+
typeof maxLevel !== 'number' || !Number.isSafeInteger(maxLevel) || maxLevel < 0 ||
|
|
260
|
+
typeof count !== 'number' || !Number.isSafeInteger(count) || count <= 0) {
|
|
261
|
+
throw new Error('invalid HNSW payload: invalid header — run `mdss index` to rebuild');
|
|
262
|
+
}
|
|
263
|
+
if (!Array.isArray(obj.levels) || obj.levels.length !== count) {
|
|
264
|
+
throw new Error(`invalid HNSW payload: levels must be an array of ${count} — run \`mdss index\` to rebuild`);
|
|
265
|
+
}
|
|
266
|
+
const levels = [];
|
|
267
|
+
for (let i = 0; i < count; i++) {
|
|
268
|
+
const l = obj.levels[i];
|
|
269
|
+
if (typeof l !== 'number' || !Number.isSafeInteger(l) || l < 0 || l > maxLevel) {
|
|
270
|
+
throw new Error(`invalid HNSW payload: level of node ${i} must be an integer in [0, ${maxLevel}] — run \`mdss index\` to rebuild`);
|
|
271
|
+
}
|
|
272
|
+
levels.push(l);
|
|
273
|
+
}
|
|
274
|
+
if (levels[entryPoint] !== maxLevel) {
|
|
275
|
+
throw new Error(`invalid HNSW payload: entry point ${entryPoint} must sit on the top layer ${maxLevel} — run \`mdss index\` to rebuild`);
|
|
276
|
+
}
|
|
277
|
+
if (!Array.isArray(obj.neighbors) || obj.neighbors.length !== count) {
|
|
278
|
+
throw new Error(`invalid HNSW payload: neighbors must be an array of ${count} — run \`mdss index\` to rebuild`);
|
|
279
|
+
}
|
|
280
|
+
const neighbors = new Array(count);
|
|
281
|
+
const m0 = M * 2;
|
|
282
|
+
for (let i = 0; i < count; i++) {
|
|
283
|
+
const perLevel = obj.neighbors[i];
|
|
284
|
+
if (!Array.isArray(perLevel) || perLevel.length !== levels[i] + 1) {
|
|
285
|
+
throw new Error(`invalid HNSW payload: node ${i} must have ${levels[i] + 1} link lists — run \`mdss index\` to rebuild`);
|
|
286
|
+
}
|
|
287
|
+
const node = new Array(levels[i] + 1);
|
|
288
|
+
for (let l = 0; l <= levels[i]; l++) {
|
|
289
|
+
const list = perLevel[l];
|
|
290
|
+
if (!Array.isArray(list)) {
|
|
291
|
+
throw new Error(`invalid HNSW payload: node ${i} layer ${l} must be an array — run \`mdss index\` to rebuild`);
|
|
292
|
+
}
|
|
293
|
+
if (list.length > (l === 0 ? m0 : M)) {
|
|
294
|
+
throw new Error(`invalid HNSW payload: node ${i} layer ${l} exceeds the link cap — run \`mdss index\` to rebuild`);
|
|
295
|
+
}
|
|
296
|
+
const seen = new Set();
|
|
297
|
+
for (const nb of list) {
|
|
298
|
+
if (typeof nb !== 'number' || !Number.isSafeInteger(nb) || nb < 0 || nb >= count || nb === i) {
|
|
299
|
+
throw new Error(`invalid HNSW payload: node ${i} layer ${l} has invalid link ${JSON.stringify(nb)} — run \`mdss index\` to rebuild`);
|
|
300
|
+
}
|
|
301
|
+
if (seen.has(nb)) {
|
|
302
|
+
throw new Error(`invalid HNSW payload: duplicate link ${nb} on node ${i} layer ${l} — run \`mdss index\` to rebuild`);
|
|
303
|
+
}
|
|
304
|
+
seen.add(nb);
|
|
305
|
+
}
|
|
306
|
+
node[l] = list;
|
|
307
|
+
}
|
|
308
|
+
neighbors[i] = node;
|
|
309
|
+
}
|
|
310
|
+
let deleted = new Set();
|
|
311
|
+
if (obj.deleted !== undefined) {
|
|
312
|
+
if (!Array.isArray(obj.deleted)) {
|
|
313
|
+
throw new Error('invalid HNSW payload: deleted must be an array — run `mdss index` to rebuild');
|
|
314
|
+
}
|
|
315
|
+
for (const idx of obj.deleted) {
|
|
316
|
+
if (typeof idx !== 'number' || !Number.isSafeInteger(idx) || idx < 0 || idx >= count) {
|
|
317
|
+
throw new Error(`invalid HNSW payload: deleted index ${JSON.stringify(idx)} out of range — run \`mdss index\` to rebuild`);
|
|
318
|
+
}
|
|
319
|
+
deleted.add(idx);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (expectedChunksCount !== undefined && count > expectedChunksCount) {
|
|
323
|
+
throw new Error(`invalid HNSW payload: ${count} nodes exceed ${expectedChunksCount} chunks — run \`mdss index\` to rebuild`);
|
|
324
|
+
}
|
|
325
|
+
return { dim, M, seed, entryPoint, maxLevel, levels, neighbors, deleted };
|
|
326
|
+
}
|
package/dist/index-format.js
CHANGED
|
@@ -90,7 +90,14 @@ export function validateIndexEnvelope(value, location, options = {}) {
|
|
|
90
90
|
else if (modelName !== undefined && modelName !== null && typeof modelName !== 'string') {
|
|
91
91
|
throw new Error(`${location} model metadata must be a string${REBUILD}`);
|
|
92
92
|
}
|
|
93
|
-
|
|
93
|
+
let model;
|
|
94
|
+
try {
|
|
95
|
+
const candidateDim = typeof index.dim === 'number' && index.dim > 0 ? index.dim : undefined;
|
|
96
|
+
model = resolveModel(modelName, candidateDim);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
model = resolveModel(modelName);
|
|
100
|
+
}
|
|
94
101
|
const dim = resolveIndexDimension(index.dim, model.dim);
|
|
95
102
|
if (schema < 3) {
|
|
96
103
|
return { index, schema, model, dim };
|
package/dist/index.d.ts
CHANGED
|
@@ -23,16 +23,16 @@ export { buildIndex, chunkHash } from './indexer.js';
|
|
|
23
23
|
export { search, searchIndex, loadIndex, tokenize, keywordScores, rrf, QueryEmbeddingCache, } from './search.js';
|
|
24
24
|
export { resolveModel, MODELS, DEFAULT_MODEL, normalizeAdapter, embeddingAdapterFingerprint, legacyEmbeddingAdapterFingerprint, } from './models.js';
|
|
25
25
|
export { getReranker, rerankScores, RERANK_MODEL, } from './rerank.js';
|
|
26
|
-
export { embed, getExtractor, cosine, walkMarkdown, parseFile, chunkMarkdown, splitFrontmatter, extractTitle, globToRegExp, encodeVec, decodeVec, isBinaryIndex, } from './core.js';
|
|
27
|
-
export { MCP_TOOLS, handleMcpRequest, startMcpServer, } from './mcp.js';
|
|
26
|
+
export { embed, getExtractor, cosine, truncateAndNormalizeVector, walkMarkdown, parseFile, chunkMarkdown, splitFrontmatter, extractTitle, globToRegExp, encodeVec, decodeVec, isBinaryIndex, getDocLines, GetDocLinesOptions, GetDocLinesResult, } from './core.js';
|
|
27
|
+
export { MCP_TOOLS, MCP_RESOURCE_TEMPLATES, MCP_PROMPTS, handleMcpRequest, startMcpServer, } from './mcp.js';
|
|
28
28
|
export { trainIVF, searchIVFCandidates, serializeIVF, deserializeIVF, ANN_THRESHOLD, DEFAULT_NPROBE, } from './ivf.js';
|
|
29
|
-
export { extractLinks, resolveLinks, buildRelationshipGraph, getRelatedNotes, computePageRank, expandGraphNeighborhood, } from './wikilinks.js';
|
|
29
|
+
export { extractLinks, resolveLinks, buildRelationshipGraph, getRelatedNotes, computePageRank, expandGraphNeighborhood, findRelatedNotes, FindRelatedNotesOptions, RelatedNoteHit, RelatedNoteResult, } from './wikilinks.js';
|
|
30
30
|
export { parseFilter, tokenizeFilter, evaluateFilter, FilterNode, } from './filter.js';
|
|
31
31
|
export { serializeBinaryIndex, deserializeBinaryIndex, readBinaryHeader, BINARY_MAGIC, BINARY_HEADER_SIZE, } from './binary-format.js';
|
|
32
32
|
export { quantizeToInt8, dequantizeFromInt8, asymmetricCosineInt8, INT8_SCALE, INT8_INV_SCALE, } from './quantization.js';
|
|
33
33
|
export { searchFederated, FederatedSearchOptions, FederatedSearchResultHit, VaultConfig, } from './federation.js';
|
|
34
34
|
export { createFileWatcher, FileWatcher, WatcherOptions, } from './watcher.js';
|
|
35
|
-
export { askQuestion, synthesizeAnswer, extractAnswerFallback, AskOptions, SynthesisResult, Citation, DEFAULT_RAG_SYSTEM_PROMPT, } from './rag.js';
|
|
35
|
+
export { askQuestion, synthesizeAnswer, extractAnswerFallback, chatTurn, loadChatSession, saveChatSession, listChatSessions, getSessionsDir, AskOptions, SynthesisResult, Citation, DEFAULT_RAG_SYSTEM_PROMPT, ChatSession, ChatTurn, ChatSourceManifestItem, ChatTurnOptions, } from './rag.js';
|
|
36
36
|
export { extractKeyphraseTags, summarizePassage, analyzeNote, NoteAnalysis, } from './summarize.js';
|
|
37
37
|
export { runTui, TuiOptions } from './tui.js';
|
|
38
38
|
export { generateCompletion, FLAGS, COMMANDS, COMPLETION_SHELLS, } from './completions.js';
|
package/dist/index.js
CHANGED
|
@@ -23,16 +23,16 @@ export { buildIndex, chunkHash } from './indexer.js';
|
|
|
23
23
|
export { search, searchIndex, loadIndex, tokenize, keywordScores, rrf, QueryEmbeddingCache, } from './search.js';
|
|
24
24
|
export { resolveModel, MODELS, DEFAULT_MODEL, normalizeAdapter, embeddingAdapterFingerprint, legacyEmbeddingAdapterFingerprint, } from './models.js';
|
|
25
25
|
export { getReranker, rerankScores, RERANK_MODEL, } from './rerank.js';
|
|
26
|
-
export { embed, getExtractor, cosine, walkMarkdown, parseFile, chunkMarkdown, splitFrontmatter, extractTitle, globToRegExp, encodeVec, decodeVec, isBinaryIndex, } from './core.js';
|
|
27
|
-
export { MCP_TOOLS, handleMcpRequest, startMcpServer, } from './mcp.js';
|
|
26
|
+
export { embed, getExtractor, cosine, truncateAndNormalizeVector, walkMarkdown, parseFile, chunkMarkdown, splitFrontmatter, extractTitle, globToRegExp, encodeVec, decodeVec, isBinaryIndex, getDocLines, } from './core.js';
|
|
27
|
+
export { MCP_TOOLS, MCP_RESOURCE_TEMPLATES, MCP_PROMPTS, handleMcpRequest, startMcpServer, } from './mcp.js';
|
|
28
28
|
export { trainIVF, searchIVFCandidates, serializeIVF, deserializeIVF, ANN_THRESHOLD, DEFAULT_NPROBE, } from './ivf.js';
|
|
29
|
-
export { extractLinks, resolveLinks, buildRelationshipGraph, getRelatedNotes, computePageRank, expandGraphNeighborhood, } from './wikilinks.js';
|
|
29
|
+
export { extractLinks, resolveLinks, buildRelationshipGraph, getRelatedNotes, computePageRank, expandGraphNeighborhood, findRelatedNotes, } from './wikilinks.js';
|
|
30
30
|
export { parseFilter, tokenizeFilter, evaluateFilter, } from './filter.js';
|
|
31
31
|
export { serializeBinaryIndex, deserializeBinaryIndex, readBinaryHeader, BINARY_MAGIC, BINARY_HEADER_SIZE, } from './binary-format.js';
|
|
32
32
|
export { quantizeToInt8, dequantizeFromInt8, asymmetricCosineInt8, INT8_SCALE, INT8_INV_SCALE, } from './quantization.js';
|
|
33
33
|
export { searchFederated, } from './federation.js';
|
|
34
34
|
export { createFileWatcher, } from './watcher.js';
|
|
35
|
-
export { askQuestion, synthesizeAnswer, extractAnswerFallback, DEFAULT_RAG_SYSTEM_PROMPT, } from './rag.js';
|
|
35
|
+
export { askQuestion, synthesizeAnswer, extractAnswerFallback, chatTurn, loadChatSession, saveChatSession, listChatSessions, getSessionsDir, DEFAULT_RAG_SYSTEM_PROMPT, } from './rag.js';
|
|
36
36
|
export { extractKeyphraseTags, summarizePassage, analyzeNote, } from './summarize.js';
|
|
37
37
|
export { runTui } from './tui.js';
|
|
38
38
|
export { generateCompletion, FLAGS, COMMANDS, COMPLETION_SHELLS, } from './completions.js';
|
package/dist/indexer.d.ts
CHANGED
|
@@ -18,6 +18,14 @@ export interface PersistedIndex {
|
|
|
18
18
|
model?: string | null;
|
|
19
19
|
modelAlias?: string;
|
|
20
20
|
adapterFingerprint?: string;
|
|
21
|
+
/**
|
|
22
|
+
* issue #202: the effective chunk token budget these chunks were built
|
|
23
|
+
* with (null = legacy char budget). A mismatch forces a full re-chunk —
|
|
24
|
+
* otherwise the file-level fast path would keep stale char-budget chunks
|
|
25
|
+
* for unchanged files and produce a mixed index. Identical chunk texts
|
|
26
|
+
* still reuse their cached vectors through chunkHash.
|
|
27
|
+
*/
|
|
28
|
+
chunkBudget?: number | null;
|
|
21
29
|
dim?: number;
|
|
22
30
|
db?: string;
|
|
23
31
|
built?: string;
|
|
@@ -57,6 +65,7 @@ export interface BuildIndexOptions {
|
|
|
57
65
|
indexDir: string;
|
|
58
66
|
cacheDir: string;
|
|
59
67
|
modelName: string;
|
|
68
|
+
dimensions?: number;
|
|
60
69
|
ignore?: string[];
|
|
61
70
|
offline?: boolean;
|
|
62
71
|
log?: (s: string) => void;
|
|
@@ -65,8 +74,16 @@ export interface BuildIndexOptions {
|
|
|
65
74
|
maxRetries?: number;
|
|
66
75
|
onProgress?: (done: number, total: number, chunksPerSec: number) => void;
|
|
67
76
|
workers?: number;
|
|
68
|
-
|
|
77
|
+
/** issue #201: ANN sidecar engine — `true`/`'ivf'` = ivf.json, `'hnsw'` = hnsw.json. */
|
|
78
|
+
ann?: boolean | 'ivf' | 'hnsw';
|
|
69
79
|
quantize?: 'fp32' | 'int8';
|
|
80
|
+
/**
|
|
81
|
+
* issue #202: chunk budget in TOKENS (overrides the legacy char budget).
|
|
82
|
+
* Default: min(adapter.maxTokens, 512) — the point of the issue is that the
|
|
83
|
+
* char budget overflowed real maxTokens for Cyrillic. Explicit `0` restores
|
|
84
|
+
* the legacy char behavior.
|
|
85
|
+
*/
|
|
86
|
+
maxChunkTokens?: number;
|
|
70
87
|
}
|
|
71
88
|
export interface BuildIndexResult {
|
|
72
89
|
files: number;
|