md-semantic-search 0.5.0 → 1.0.2

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.
Files changed (52) hide show
  1. package/README.md +44 -1
  2. package/bin/cli.mjs +144 -30
  3. package/dist/binary-format.d.ts +25 -0
  4. package/dist/binary-format.js +178 -0
  5. package/dist/core.d.ts +3 -2
  6. package/dist/core.js +26 -31
  7. package/dist/federation.d.ts +15 -0
  8. package/dist/federation.js +94 -0
  9. package/dist/filter.d.ts +29 -0
  10. package/dist/filter.js +317 -0
  11. package/dist/index-format.js +1 -1
  12. package/dist/index.d.ts +9 -1
  13. package/dist/index.js +8 -0
  14. package/dist/indexer.d.ts +13 -0
  15. package/dist/indexer.js +21 -7
  16. package/dist/markdown-parser.d.ts +1 -0
  17. package/dist/markdown-parser.js +71 -12
  18. package/dist/mcp.d.ts +54 -6
  19. package/dist/mcp.js +59 -11
  20. package/dist/quantization.d.ts +24 -0
  21. package/dist/quantization.js +85 -0
  22. package/dist/rag.d.ts +49 -0
  23. package/dist/rag.js +128 -0
  24. package/dist/search.d.ts +20 -6
  25. package/dist/search.js +175 -23
  26. package/dist/serve.js +44 -3
  27. package/dist/summarize.d.ts +16 -0
  28. package/dist/summarize.js +82 -0
  29. package/dist/tui.d.ts +1 -0
  30. package/dist/tui.js +36 -13
  31. package/dist/watcher.d.ts +11 -0
  32. package/dist/watcher.js +138 -0
  33. package/dist/wikilinks.d.ts +22 -0
  34. package/dist/wikilinks.js +100 -0
  35. package/package.json +3 -1
  36. package/src/binary-format.ts +216 -0
  37. package/src/core.ts +45 -37
  38. package/src/federation.ts +118 -0
  39. package/src/filter.ts +368 -0
  40. package/src/index-format.ts +1 -1
  41. package/src/index.ts +39 -1
  42. package/src/indexer.ts +42 -10
  43. package/src/markdown-parser.ts +80 -10
  44. package/src/mcp.ts +57 -11
  45. package/src/quantization.ts +93 -0
  46. package/src/rag.ts +189 -0
  47. package/src/search.ts +202 -25
  48. package/src/serve.ts +46 -4
  49. package/src/summarize.ts +98 -0
  50. package/src/tui.ts +36 -12
  51. package/src/watcher.ts +157 -0
  52. package/src/wikilinks.ts +132 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Multi-Vault & Multi-Repository Search Federation (issue #97).
3
+ *
4
+ * Coordinates querying across multiple independent vaults / repositories,
5
+ * executing per-vault index loads in parallel and merging candidates with
6
+ * calibrated reciprocal rank fusion (RRF) and vault attribution.
7
+ */
8
+ import path from 'node:path';
9
+ import fs from 'node:fs';
10
+ import { loadIndex, searchIndex } from './search.js';
11
+ export async function searchFederated(opts) {
12
+ const { vaults, k = 10, cacheDir = '', ...searchOpts } = opts;
13
+ if (!vaults || vaults.length === 0) {
14
+ throw new Error('Federated search requires at least one vault specified');
15
+ }
16
+ const normalizedVaults = vaults.map((v) => {
17
+ if (typeof v === 'string') {
18
+ const absPath = path.resolve(v);
19
+ const name = path.basename(absPath);
20
+ return { name, path: absPath };
21
+ }
22
+ const absPath = path.resolve(v.path);
23
+ const name = v.name || path.basename(absPath);
24
+ return { name, path: absPath, indexDir: v.indexDir };
25
+ });
26
+ // Load and query each vault concurrently
27
+ const perVaultResults = await Promise.all(normalizedVaults.map(async (vault) => {
28
+ const indexDir = vault.indexDir || path.join(vault.path, '.mdss');
29
+ if (!fs.existsSync(indexDir)) {
30
+ return { vault, hits: [], error: `No index at ${indexDir}` };
31
+ }
32
+ let loaded;
33
+ try {
34
+ loaded = loadIndex(indexDir);
35
+ }
36
+ catch (err) {
37
+ return { vault, hits: [], error: err.message };
38
+ }
39
+ try {
40
+ // Query vault index with a slightly larger pool to ensure healthy global fusion
41
+ const poolK = Math.max(k * 2, 20);
42
+ const resolvedCache = cacheDir || path.join(vault.path, '.cache');
43
+ const hits = await searchIndex({
44
+ ...searchOpts,
45
+ cacheDir: resolvedCache,
46
+ loaded,
47
+ k: poolK,
48
+ });
49
+ return { vault, hits, error: null };
50
+ }
51
+ catch (err) {
52
+ return { vault, hits: [], error: err.message };
53
+ }
54
+ }));
55
+ // If single vault, return directly with attribution
56
+ if (normalizedVaults.length === 1) {
57
+ const { vault, hits } = perVaultResults[0];
58
+ return hits.slice(0, k).map((h) => ({
59
+ ...h,
60
+ vault: vault.name || path.basename(vault.path),
61
+ vaultPath: vault.path,
62
+ }));
63
+ }
64
+ // Multi-vault reciprocal rank fusion (RRF)
65
+ // Collect all hits across vaults
66
+ const hitKeys = new Map();
67
+ for (const { vault, hits } of perVaultResults) {
68
+ hits.forEach((hit, rank) => {
69
+ const key = `${vault.path}::${hit.file}::${hit.heading || ''}`;
70
+ const rrfScore = 1.0 / (60 + rank + 1);
71
+ const existing = hitKeys.get(key);
72
+ if (!existing) {
73
+ hitKeys.set(key, { hit, vault, rrfSum: rrfScore });
74
+ }
75
+ else {
76
+ existing.rrfSum += rrfScore;
77
+ }
78
+ });
79
+ }
80
+ // Sort fused hits by combined rrfSum (or fallback to score)
81
+ const fused = Array.from(hitKeys.values())
82
+ .sort((a, b) => {
83
+ if (b.rrfSum !== a.rrfSum)
84
+ return b.rrfSum - a.rrfSum;
85
+ return b.hit.score - a.hit.score;
86
+ })
87
+ .slice(0, k);
88
+ return fused.map(({ hit, vault, rrfSum }) => ({
89
+ ...hit,
90
+ score: rrfSum,
91
+ vault: vault.name || path.basename(vault.path),
92
+ vaultPath: vault.path,
93
+ }));
94
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Rich Frontmatter & Tag Filter Expression DSL (issue #94).
3
+ * Supports boolean expressions (AND, OR, NOT, grouping parentheses)
4
+ * across frontmatter tags, properties, dates, and custom fields.
5
+ */
6
+ import { DocumentMetadata } from './frontmatter.js';
7
+ export type FilterNode = {
8
+ type: 'AND';
9
+ left: FilterNode;
10
+ right: FilterNode;
11
+ } | {
12
+ type: 'OR';
13
+ left: FilterNode;
14
+ right: FilterNode;
15
+ } | {
16
+ type: 'NOT';
17
+ child: FilterNode;
18
+ } | {
19
+ type: 'COMPARE';
20
+ field: string;
21
+ op: ':' | 'contains' | '=' | '==' | '!=' | '>' | '>=' | '<' | '<=';
22
+ value: string | number | boolean;
23
+ };
24
+ export declare function tokenizeFilter(expr: string): string[];
25
+ export declare function parseFilter(expr: string): FilterNode;
26
+ export declare function evaluateFilter(node: FilterNode | string, meta?: DocumentMetadata, doc?: {
27
+ file?: string;
28
+ title?: string;
29
+ }): boolean;
package/dist/filter.js ADDED
@@ -0,0 +1,317 @@
1
+ export function tokenizeFilter(expr) {
2
+ const tokens = [];
3
+ let i = 0;
4
+ while (i < expr.length) {
5
+ const ch = expr[i];
6
+ if (/\s/.test(ch)) {
7
+ i++;
8
+ continue;
9
+ }
10
+ if (ch === '(' || ch === ')') {
11
+ tokens.push(ch);
12
+ i++;
13
+ continue;
14
+ }
15
+ // Quoted string
16
+ if (ch === '"' || ch === "'") {
17
+ const quote = ch;
18
+ let str = '';
19
+ i++;
20
+ while (i < expr.length && expr[i] !== quote) {
21
+ if (expr[i] === '\\' && i + 1 < expr.length) {
22
+ str += expr[i + 1];
23
+ i += 2;
24
+ }
25
+ else {
26
+ str += expr[i];
27
+ i++;
28
+ }
29
+ }
30
+ i++; // skip closing quote
31
+ tokens.push(`"${str}"`);
32
+ continue;
33
+ }
34
+ // Two-character operators: >=, <=, !=, ==
35
+ if (i + 1 < expr.length) {
36
+ const two = expr.slice(i, i + 2);
37
+ if (two === '>=' || two === '<=' || two === '!=' || two === '==') {
38
+ tokens.push(two);
39
+ i += 2;
40
+ continue;
41
+ }
42
+ }
43
+ // Single-character operators: >, <, =, :
44
+ if (ch === '>' || ch === '<' || ch === '=' || ch === ':') {
45
+ tokens.push(ch);
46
+ i++;
47
+ continue;
48
+ }
49
+ // Word / identifier / value token
50
+ let word = '';
51
+ while (i < expr.length &&
52
+ !/\s/.test(expr[i]) &&
53
+ expr[i] !== '(' &&
54
+ expr[i] !== ')' &&
55
+ expr[i] !== ':' &&
56
+ expr[i] !== '=' &&
57
+ expr[i] !== '!' &&
58
+ expr[i] !== '>' &&
59
+ expr[i] !== '<' &&
60
+ expr[i] !== '"' &&
61
+ expr[i] !== "'") {
62
+ word += expr[i];
63
+ i++;
64
+ }
65
+ if (word) {
66
+ tokens.push(word);
67
+ }
68
+ else if (i < expr.length) {
69
+ tokens.push(expr[i]);
70
+ i++;
71
+ }
72
+ }
73
+ return tokens;
74
+ }
75
+ export function parseFilter(expr) {
76
+ const tokens = tokenizeFilter(expr);
77
+ if (tokens.length === 0) {
78
+ throw new Error('Empty filter expression');
79
+ }
80
+ let pos = 0;
81
+ function peek() {
82
+ return tokens[pos];
83
+ }
84
+ function consume() {
85
+ return tokens[pos++];
86
+ }
87
+ function parseExpression() {
88
+ return parseOr();
89
+ }
90
+ function parseOr() {
91
+ let node = parseAnd();
92
+ while (pos < tokens.length && peek()?.toUpperCase() === 'OR') {
93
+ consume(); // OR
94
+ const beforePos = pos;
95
+ const right = parseAnd();
96
+ if (pos === beforePos) {
97
+ throw new Error(`Invalid syntax in filter expression near token "${peek()}"`);
98
+ }
99
+ node = { type: 'OR', left: node, right };
100
+ }
101
+ return node;
102
+ }
103
+ function parseAnd() {
104
+ let node = parseNot();
105
+ while (pos < tokens.length &&
106
+ peek()?.toUpperCase() !== 'OR' &&
107
+ peek() !== ')') {
108
+ if (peek()?.toUpperCase() === 'AND') {
109
+ consume(); // AND
110
+ }
111
+ const beforePos = pos;
112
+ const right = parseNot();
113
+ if (pos === beforePos) {
114
+ throw new Error(`Invalid syntax in filter expression near token "${peek()}"`);
115
+ }
116
+ node = { type: 'AND', left: node, right };
117
+ }
118
+ return node;
119
+ }
120
+ function parseNot() {
121
+ if (pos < tokens.length && (peek()?.toUpperCase() === 'NOT' || peek() === '!')) {
122
+ consume(); // NOT / !
123
+ const child = parsePrimary();
124
+ return { type: 'NOT', child };
125
+ }
126
+ return parsePrimary();
127
+ }
128
+ function parsePrimary() {
129
+ const token = peek();
130
+ if (!token) {
131
+ throw new Error(`Unexpected end of filter expression at position ${pos}`);
132
+ }
133
+ if (token === '(') {
134
+ consume(); // (
135
+ const node = parseExpression();
136
+ if (peek() !== ')') {
137
+ throw new Error('Expected closing parenthesis ")" in filter expression');
138
+ }
139
+ consume(); // )
140
+ return node;
141
+ }
142
+ // Comparison term: <field> <op> <value> or <field>:<value>
143
+ const fieldToken = consume();
144
+ const cleanField = fieldToken.replace(/^["']|["']$/g, '');
145
+ let op = ':';
146
+ let rawVal = '';
147
+ const nextTok = peek();
148
+ if (nextTok === ':' ||
149
+ nextTok === '=' ||
150
+ nextTok === '==' ||
151
+ nextTok === '!=' ||
152
+ nextTok === '>' ||
153
+ nextTok === '>=' ||
154
+ nextTok === '<' ||
155
+ nextTok === '<=' ||
156
+ nextTok?.toLowerCase() === 'contains') {
157
+ op = consume();
158
+ if (op === '==')
159
+ op = '=';
160
+ rawVal = consume();
161
+ if (!rawVal) {
162
+ throw new Error(`Missing value after operator "${op}" for field "${cleanField}"`);
163
+ }
164
+ }
165
+ else {
166
+ op = ':';
167
+ rawVal = cleanField;
168
+ }
169
+ const unquoted = rawVal.replace(/^["']|["']$/g, '');
170
+ let parsedVal = unquoted;
171
+ if (/^true$/i.test(unquoted))
172
+ parsedVal = true;
173
+ else if (/^false$/i.test(unquoted))
174
+ parsedVal = false;
175
+ else if (/^-?\d+(\.\d+)?$/.test(unquoted))
176
+ parsedVal = Number(unquoted);
177
+ return {
178
+ type: 'COMPARE',
179
+ field: cleanField,
180
+ op,
181
+ value: parsedVal,
182
+ };
183
+ }
184
+ const result = parseExpression();
185
+ if (pos < tokens.length) {
186
+ throw new Error(`Unexpected trailing token in filter: "${tokens[pos]}"`);
187
+ }
188
+ return result;
189
+ }
190
+ export function evaluateFilter(node, meta, doc) {
191
+ if (typeof node === 'string') {
192
+ if (!node.trim())
193
+ return true;
194
+ try {
195
+ node = parseFilter(node);
196
+ }
197
+ catch {
198
+ return false;
199
+ }
200
+ }
201
+ if (node.type === 'AND') {
202
+ return evaluateFilter(node.left, meta, doc) && evaluateFilter(node.right, meta, doc);
203
+ }
204
+ if (node.type === 'OR') {
205
+ return evaluateFilter(node.left, meta, doc) || evaluateFilter(node.right, meta, doc);
206
+ }
207
+ if (node.type === 'NOT') {
208
+ return !evaluateFilter(node.child, meta, doc);
209
+ }
210
+ if (node.type === 'COMPARE') {
211
+ return evaluateCompare(node.field, node.op, node.value, meta, doc);
212
+ }
213
+ return true;
214
+ }
215
+ function evaluateCompare(field, op, targetVal, meta, doc) {
216
+ const normField = field.toLowerCase().replace(/[-_]/g, '');
217
+ let actualVal = undefined;
218
+ if (normField === 'file' || normField === 'path') {
219
+ actualVal = doc?.file;
220
+ }
221
+ else if (normField === 'title') {
222
+ actualVal = meta?.title || doc?.title;
223
+ }
224
+ else if (normField === 'tag' || normField === 'tags') {
225
+ actualVal = meta?.tags || [];
226
+ }
227
+ else if (normField === 'alias' || normField === 'aliases') {
228
+ actualVal = meta?.aliases || [];
229
+ }
230
+ else if (normField === 'project') {
231
+ actualVal = meta?.project;
232
+ }
233
+ else if (normField === 'type') {
234
+ actualVal = meta?.type;
235
+ }
236
+ else if (normField === 'status') {
237
+ actualVal = meta?.status;
238
+ }
239
+ else if (normField === 'canonical') {
240
+ actualVal = meta?.canonical;
241
+ }
242
+ else if (normField === 'date' || normField === 'created') {
243
+ actualVal = meta?.created;
244
+ }
245
+ else if (normField === 'updated') {
246
+ actualVal = meta?.updated;
247
+ }
248
+ else if (meta?.custom && field in meta.custom) {
249
+ actualVal = meta.custom[field];
250
+ }
251
+ // Tag array membership
252
+ if (normField === 'tag' || normField === 'tags' || Array.isArray(actualVal)) {
253
+ const list = Array.isArray(actualVal) ? actualVal : [];
254
+ const searchTarget = String(targetVal).toLowerCase().replace(/^#/, '');
255
+ if (op === ':' || op === 'contains' || op === '=' || op === '==') {
256
+ return list.some((item) => String(item).toLowerCase().replace(/^#/, '') === searchTarget);
257
+ }
258
+ if (op === '!=') {
259
+ return !list.some((item) => String(item).toLowerCase().replace(/^#/, '') === searchTarget);
260
+ }
261
+ }
262
+ if (actualVal === undefined || actualVal === null) {
263
+ if (op === '!=')
264
+ return true;
265
+ return false;
266
+ }
267
+ // Boolean compare
268
+ if (typeof targetVal === 'boolean') {
269
+ const b = Boolean(actualVal);
270
+ return op === '!=' ? b !== targetVal : b === targetVal;
271
+ }
272
+ // Number compare
273
+ if (typeof targetVal === 'number') {
274
+ const num = Number(actualVal);
275
+ if (Number.isNaN(num))
276
+ return false;
277
+ switch (op) {
278
+ case '=':
279
+ case ':':
280
+ return num === targetVal;
281
+ case '!=':
282
+ return num !== targetVal;
283
+ case '>':
284
+ return num > targetVal;
285
+ case '>=':
286
+ return num >= targetVal;
287
+ case '<':
288
+ return num < targetVal;
289
+ case '<=':
290
+ return num <= targetVal;
291
+ default:
292
+ return false;
293
+ }
294
+ }
295
+ // String / Date comparison
296
+ const strActual = String(actualVal).toLowerCase();
297
+ const strTarget = String(targetVal).toLowerCase();
298
+ switch (op) {
299
+ case '=':
300
+ return strActual === strTarget;
301
+ case ':':
302
+ case 'contains':
303
+ return strActual.includes(strTarget);
304
+ case '!=':
305
+ return strActual !== strTarget;
306
+ case '>':
307
+ return strActual > strTarget;
308
+ case '>=':
309
+ return strActual >= strTarget;
310
+ case '<':
311
+ return strActual < strTarget;
312
+ case '<=':
313
+ return strActual <= strTarget;
314
+ default:
315
+ return false;
316
+ }
317
+ }
@@ -10,7 +10,7 @@ export function inspectIndexSchema(value, location) {
10
10
  return { index, schema: parseSchemaVersion(index.schemaVersion) };
11
11
  }
12
12
  export function validateNumericVector(value, expectedDim, where) {
13
- if (!Array.isArray(value) && !(value instanceof Float32Array)) {
13
+ if (!Array.isArray(value) && !(value instanceof Float32Array || value instanceof Int8Array)) {
14
14
  throw new Error(`${where}: missing vector${REBUILD}`);
15
15
  }
16
16
  const vec = value;
package/dist/index.d.ts CHANGED
@@ -26,4 +26,12 @@ export { getReranker, rerankScores, RERANK_MODEL, } from './rerank.js';
26
26
  export { embed, getExtractor, cosine, walkMarkdown, parseFile, chunkMarkdown, splitFrontmatter, extractTitle, globToRegExp, encodeVec, decodeVec, isBinaryIndex, } from './core.js';
27
27
  export { MCP_TOOLS, handleMcpRequest, startMcpServer, } from './mcp.js';
28
28
  export { trainIVF, searchIVFCandidates, serializeIVF, deserializeIVF, ANN_THRESHOLD, DEFAULT_NPROBE, } from './ivf.js';
29
- export { runTui } from './tui.js';
29
+ export { extractLinks, resolveLinks, buildRelationshipGraph, getRelatedNotes, computePageRank, expandGraphNeighborhood, } from './wikilinks.js';
30
+ export { parseFilter, tokenizeFilter, evaluateFilter, FilterNode, } from './filter.js';
31
+ export { serializeBinaryIndex, deserializeBinaryIndex, readBinaryHeader, BINARY_MAGIC, BINARY_HEADER_SIZE, } from './binary-format.js';
32
+ export { quantizeToInt8, dequantizeFromInt8, asymmetricCosineInt8, INT8_SCALE, INT8_INV_SCALE, } from './quantization.js';
33
+ export { searchFederated, FederatedSearchOptions, FederatedSearchResultHit, VaultConfig, } from './federation.js';
34
+ export { createFileWatcher, FileWatcher, WatcherOptions, } from './watcher.js';
35
+ export { askQuestion, synthesizeAnswer, extractAnswerFallback, AskOptions, SynthesisResult, Citation, DEFAULT_RAG_SYSTEM_PROMPT, } from './rag.js';
36
+ export { extractKeyphraseTags, summarizePassage, analyzeNote, NoteAnalysis, } from './summarize.js';
37
+ export { runTui, TuiOptions } from './tui.js';
package/dist/index.js CHANGED
@@ -26,4 +26,12 @@ export { getReranker, rerankScores, RERANK_MODEL, } from './rerank.js';
26
26
  export { embed, getExtractor, cosine, walkMarkdown, parseFile, chunkMarkdown, splitFrontmatter, extractTitle, globToRegExp, encodeVec, decodeVec, isBinaryIndex, } from './core.js';
27
27
  export { MCP_TOOLS, 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';
30
+ export { parseFilter, tokenizeFilter, evaluateFilter, } from './filter.js';
31
+ export { serializeBinaryIndex, deserializeBinaryIndex, readBinaryHeader, BINARY_MAGIC, BINARY_HEADER_SIZE, } from './binary-format.js';
32
+ export { quantizeToInt8, dequantizeFromInt8, asymmetricCosineInt8, INT8_SCALE, INT8_INV_SCALE, } from './quantization.js';
33
+ export { searchFederated, } from './federation.js';
34
+ export { createFileWatcher, } from './watcher.js';
35
+ export { askQuestion, synthesizeAnswer, extractAnswerFallback, DEFAULT_RAG_SYSTEM_PROMPT, } from './rag.js';
36
+ export { extractKeyphraseTags, summarizePassage, analyzeNote, } from './summarize.js';
29
37
  export { runTui } from './tui.js';
package/dist/indexer.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { LexicalIndex } from './lexical.js';
2
+ import { DocumentMetadata } from './frontmatter.js';
2
3
  export interface IndexChunk {
3
4
  file: string;
4
5
  title: string;
@@ -7,6 +8,9 @@ export interface IndexChunk {
7
8
  text: string;
8
9
  vec?: number[] | Float32Array | string;
9
10
  chunkHash?: string;
11
+ startLine?: number;
12
+ endLine?: number;
13
+ meta?: DocumentMetadata;
10
14
  }
11
15
  export interface PersistedIndex {
12
16
  schemaVersion?: number;
@@ -21,13 +25,20 @@ export interface PersistedIndex {
21
25
  chunkCount?: number;
22
26
  hashes?: Record<string, string>;
23
27
  lexical?: LexicalIndex;
28
+ lexicalFormat?: string;
24
29
  chunks: IndexChunk[];
25
30
  }
31
+ export declare const _stats: {
32
+ chunkHits: number;
33
+ fileHits: number;
34
+ embedCount: number;
35
+ };
26
36
  export declare function canonicalPassage(chunk: {
27
37
  title: string;
28
38
  heading: string;
29
39
  headingPath?: string[];
30
40
  text: string;
41
+ meta?: DocumentMetadata;
31
42
  }): string;
32
43
  export declare function chunkHash(model: {
33
44
  id: string;
@@ -39,6 +50,7 @@ export declare function chunkHash(model: {
39
50
  heading: string;
40
51
  headingPath?: string[];
41
52
  text: string;
53
+ meta?: DocumentMetadata;
42
54
  }): string;
43
55
  export interface BuildIndexOptions {
44
56
  db: string;
@@ -54,6 +66,7 @@ export interface BuildIndexOptions {
54
66
  onProgress?: (done: number, total: number, chunksPerSec: number) => void;
55
67
  workers?: number;
56
68
  ann?: boolean;
69
+ quantize?: 'fp32' | 'int8';
57
70
  }
58
71
  export interface BuildIndexResult {
59
72
  files: number;
package/dist/indexer.js CHANGED
@@ -15,8 +15,14 @@ import { validateIndexEnvelope, validateNumericVector } from './index-format.js'
15
15
  import { analyzeLexicalDocument, buildLexicalIndex, lexicalIdentity, reverseLexicalIndex, validateLexicalIndex, } from './lexical.js';
16
16
  import { embeddingAdapterFingerprint, legacyEmbeddingAdapterFingerprint } from './models.js';
17
17
  import { trainIVF, serializeIVF, ANN_THRESHOLD } from './ivf.js';
18
+ import { serializeBinaryIndex } from './binary-format.js';
18
19
  const md5 = (s) => crypto.createHash('md5').update(s).digest('hex');
19
20
  const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
21
+ export const _stats = {
22
+ chunkHits: 0,
23
+ fileHits: 0,
24
+ embedCount: 0,
25
+ };
20
26
  const BATCH = 32;
21
27
  const CHECKPOINT_BATCHES = 8;
22
28
  const INDEX_FORMAT = 'binary-v1';
@@ -41,11 +47,12 @@ function atomicWrite(target, data) {
41
47
  const tmp = `${target}.${process.pid}.tmp`;
42
48
  fs.writeFileSync(tmp, data);
43
49
  fs.renameSync(tmp, target);
44
- if (path.basename(target) === 'vectors.json') {
50
+ const base = path.basename(target);
51
+ if (base === 'vectors.json' || base === 'vectors.bin') {
45
52
  const digest = crypto.createHash('sha256').update(data).digest('hex');
46
53
  const shaPath = `${target}.sha256`;
47
54
  const shaTmp = `${shaPath}.${process.pid}.tmp`;
48
- fs.writeFileSync(shaTmp, `${digest} vectors.json\n`);
55
+ fs.writeFileSync(shaTmp, `${digest} ${base}\n`);
49
56
  fs.renameSync(shaTmp, shaPath);
50
57
  }
51
58
  }
@@ -56,7 +63,9 @@ export function canonicalPassage(chunk) {
56
63
  const title = normalize(chunk.title);
57
64
  const headingPath = (chunk.headingPath ?? (normalize(chunk.heading) ? [chunk.heading] : [])).map(normalize);
58
65
  const serializedPath = (headingPath[0] === title ? headingPath.slice(1) : headingPath).join(' > ');
59
- return [title, serializedPath, normalize(chunk.text)].join('\n');
66
+ const tagsStr = chunk.meta?.tags && chunk.meta.tags.length > 0 ? `tags: ${chunk.meta.tags.map(t => `#${t}`).join(' ')}` : '';
67
+ const parts = [title, serializedPath, tagsStr, normalize(chunk.text)].filter(Boolean);
68
+ return parts.join('\n');
60
69
  }
61
70
  export function chunkHash(model, chunk) {
62
71
  const pooling = model.pooling ?? 'mean';
@@ -70,12 +79,12 @@ export function chunkHash(model, chunk) {
70
79
  return sha256(input);
71
80
  }
72
81
  export async function buildIndex(opts) {
73
- const { db, indexDir, cacheDir, modelName, ignore = [], log = () => { }, offline = false, embedFn = embed, _lockHeld = false, maxRetries = 3, onProgress, workers = 1, ann = false, } = opts;
82
+ const { db, indexDir, cacheDir, modelName, ignore = [], log = () => { }, offline = false, embedFn = embed, _lockHeld = false, maxRetries = 3, onProgress, workers = 1, ann = false, quantize = 'fp32', } = opts;
74
83
  if (_lockHeld)
75
- return _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore, log, offline, embedFn, maxRetries, onProgress, workers, ann });
76
- return withIndexLock(indexDir, () => _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore, log, offline, embedFn, maxRetries, onProgress, workers, ann }));
84
+ return _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore, log, offline, embedFn, maxRetries, onProgress, workers, ann, quantize });
85
+ return withIndexLock(indexDir, () => _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore, log, offline, embedFn, maxRetries, onProgress, workers, ann, quantize }));
77
86
  }
78
- async function _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore = [], log = () => { }, offline = false, embedFn = embed, maxRetries = 3, onProgress, workers = 1, ann = false, }) {
87
+ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore = [], log = () => { }, offline = false, embedFn = embed, maxRetries = 3, onProgress, workers = 1, ann = false, quantize = 'fp32', }) {
79
88
  const model = resolveModel(modelName);
80
89
  const modelIdentity = `${model.id}@${model.revision || 'main'}`;
81
90
  const adapterFingerprint = embeddingAdapterFingerprint(model);
@@ -424,6 +433,11 @@ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, ignore = []
424
433
  atomicWrite(checkpointPath, JSON.stringify({ ...index, complete: true, hashes: newHashes }));
425
434
  atomicWrite(vectorsPath, JSON.stringify(index));
426
435
  atomicWrite(hashesPath, JSON.stringify(newHashes, null, 2));
436
+ const vectorsBinPath = path.join(indexDir, 'vectors.bin');
437
+ if (dim !== undefined && dim > 0) {
438
+ const binBuffer = serializeBinaryIndex(index, dim, { quantize });
439
+ atomicWrite(vectorsBinPath, binBuffer);
440
+ }
427
441
  const ivfPath = path.join(indexDir, 'ivf.json');
428
442
  if (ann || chunks.length >= ANN_THRESHOLD) {
429
443
  const rawVecs = chunks.map((c) => (c.vec instanceof Float32Array ? c.vec : Float32Array.from(c.vec)));
@@ -25,3 +25,4 @@ export declare function chunkMarkdownStructural(input: string | MarkdownBlock[],
25
25
  maxChunk?: number;
26
26
  targetTokens?: number;
27
27
  }): StructuralChunk[];
28
+ export declare function splitOversizedBlock(text: string, maxChunk: number, blockType?: MarkdownBlock['type']): string[];