md-semantic-search 0.3.0 → 0.4.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 +357 -20
- package/RESEARCH.md +117 -37
- package/bin/cli.mjs +573 -32
- package/package.json +11 -2
- package/src/collapse.mjs +31 -0
- package/src/core.mjs +424 -60
- package/src/frontmatter.mjs +213 -0
- package/src/index-format.mjs +134 -0
- package/src/index.mjs +47 -0
- package/src/indexer.mjs +390 -47
- package/src/lexical.mjs +291 -0
- package/src/markdown-parser.mjs +347 -0
- package/src/metrics.mjs +186 -0
- package/src/models.mjs +250 -18
- package/src/rerank.mjs +75 -0
- package/src/search.mjs +571 -42
- package/src/serve.mjs +387 -0
- package/src/wikilinks.mjs +241 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Typed YAML Frontmatter parser and metadata normalizer (issue #58).
|
|
4
|
+
* Parses YAML frontmatter into a validated document metadata object without
|
|
5
|
+
* unsafe executable tags. Supports scalars, inline/block lists, quotes, booleans,
|
|
6
|
+
* dates, tags normalization, and canonical document identity.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {{
|
|
11
|
+
* title?: string,
|
|
12
|
+
* aliases: string[],
|
|
13
|
+
* tags: string[],
|
|
14
|
+
* project?: string,
|
|
15
|
+
* type?: string,
|
|
16
|
+
* status?: string,
|
|
17
|
+
* canonical?: boolean,
|
|
18
|
+
* canonicalRef?: string,
|
|
19
|
+
* created?: string,
|
|
20
|
+
* updated?: string,
|
|
21
|
+
* custom: Record<string, string | number | boolean | string[]>,
|
|
22
|
+
* }} DocumentMetadata
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse raw YAML frontmatter text into a typed DocumentMetadata object.
|
|
27
|
+
* @param {string} rawYaml
|
|
28
|
+
* @returns {DocumentMetadata}
|
|
29
|
+
*/
|
|
30
|
+
export function parseFrontmatter(rawYaml) {
|
|
31
|
+
const metadata = {
|
|
32
|
+
aliases: /** @type {string[]} */ ([]),
|
|
33
|
+
tags: /** @type {string[]} */ ([]),
|
|
34
|
+
custom: /** @type {Record<string, string | number | boolean | string[]>} */ ({}),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
if (!rawYaml || typeof rawYaml !== 'string') {
|
|
38
|
+
return metadata;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const lines = rawYaml.split(/\r?\n/);
|
|
42
|
+
let currentKey = null;
|
|
43
|
+
let currentBlockList = null;
|
|
44
|
+
|
|
45
|
+
for (let i = 0; i < lines.length; i++) {
|
|
46
|
+
const line = lines[i];
|
|
47
|
+
const trimmed = line.trim();
|
|
48
|
+
|
|
49
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Block list item: " - item"
|
|
54
|
+
const blockListMatch = line.match(/^(\s+)-\s+(.+)$/);
|
|
55
|
+
if (blockListMatch && currentKey && currentBlockList) {
|
|
56
|
+
const val = parseYAMLValue(blockListMatch[2]);
|
|
57
|
+
if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {
|
|
58
|
+
currentBlockList.push(String(val));
|
|
59
|
+
}
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Key-value pair: "key: value" or "key:"
|
|
64
|
+
const kvMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/);
|
|
65
|
+
if (kvMatch) {
|
|
66
|
+
if (currentKey && currentBlockList) {
|
|
67
|
+
setMetadataValue(metadata, currentKey, currentBlockList);
|
|
68
|
+
currentKey = null;
|
|
69
|
+
currentBlockList = null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const key = kvMatch[1].trim();
|
|
73
|
+
const rawVal = kvMatch[2].trim();
|
|
74
|
+
|
|
75
|
+
if (rawVal === '') {
|
|
76
|
+
// Start of potential block list
|
|
77
|
+
currentKey = key;
|
|
78
|
+
currentBlockList = [];
|
|
79
|
+
} else {
|
|
80
|
+
const val = parseYAMLValue(rawVal);
|
|
81
|
+
setMetadataValue(metadata, key, val);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (currentKey && currentBlockList) {
|
|
87
|
+
setMetadataValue(metadata, currentKey, currentBlockList);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return metadata;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parse a raw YAML value string (supports quotes, inline arrays, booleans, numbers).
|
|
95
|
+
* @param {string} raw
|
|
96
|
+
* @returns {string | number | boolean | string[]}
|
|
97
|
+
*/
|
|
98
|
+
function parseYAMLValue(raw) {
|
|
99
|
+
const trimmed = raw.trim();
|
|
100
|
+
|
|
101
|
+
// Inline array: [item1, item2]
|
|
102
|
+
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
103
|
+
const inner = trimmed.slice(1, -1).trim();
|
|
104
|
+
if (!inner) return [];
|
|
105
|
+
return inner
|
|
106
|
+
.split(',')
|
|
107
|
+
.map((item) => parseYAMLValue(item))
|
|
108
|
+
.map((item) => (Array.isArray(item) ? item.join(' ') : String(item).trim()))
|
|
109
|
+
.filter((item) => item.length > 0);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Quoted string
|
|
113
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
114
|
+
return trimmed.slice(1, -1).trim();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Boolean
|
|
118
|
+
if (/^true$/i.test(trimmed)) return true;
|
|
119
|
+
if (/^false$/i.test(trimmed)) return false;
|
|
120
|
+
|
|
121
|
+
// Number
|
|
122
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
|
123
|
+
const num = Number(trimmed);
|
|
124
|
+
if (!Number.isNaN(num)) return num;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return trimmed;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Set a parsed key-value pair on the DocumentMetadata object.
|
|
132
|
+
* @param {DocumentMetadata} meta
|
|
133
|
+
* @param {string} key
|
|
134
|
+
* @param {string | number | boolean | string[]} val
|
|
135
|
+
*/
|
|
136
|
+
function setMetadataValue(meta, key, val) {
|
|
137
|
+
const normKey = key.toLowerCase().replace(/[-_]/g, '');
|
|
138
|
+
|
|
139
|
+
if (normKey === 'title' && typeof val === 'string') {
|
|
140
|
+
meta.title = val;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (normKey === 'alias' || normKey === 'aliases') {
|
|
145
|
+
const items = Array.isArray(val) ? val : [String(val)];
|
|
146
|
+
for (const item of items) {
|
|
147
|
+
const cleaned = item.trim().replace(/^["']|["']$/g, '');
|
|
148
|
+
if (cleaned && !meta.aliases.includes(cleaned)) {
|
|
149
|
+
meta.aliases.push(cleaned);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (normKey === 'tag' || normKey === 'tags') {
|
|
156
|
+
let items = [];
|
|
157
|
+
if (Array.isArray(val)) {
|
|
158
|
+
items = val;
|
|
159
|
+
} else if (typeof val === 'string') {
|
|
160
|
+
items = val.includes(',') ? val.split(',') : [val];
|
|
161
|
+
} else {
|
|
162
|
+
items = [String(val)];
|
|
163
|
+
}
|
|
164
|
+
for (const item of items) {
|
|
165
|
+
const cleaned = String(item)
|
|
166
|
+
.trim()
|
|
167
|
+
.replace(/^#/, '')
|
|
168
|
+
.toLowerCase();
|
|
169
|
+
if (cleaned && !meta.tags.includes(cleaned)) {
|
|
170
|
+
meta.tags.push(cleaned);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (normKey === 'project' && (typeof val === 'string' || typeof val === 'number')) {
|
|
177
|
+
meta.project = String(val);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (normKey === 'type' && typeof val === 'string') {
|
|
182
|
+
meta.type = val;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (normKey === 'status' && typeof val === 'string') {
|
|
187
|
+
meta.status = val;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (normKey === 'canonical') {
|
|
192
|
+
meta.canonical = Boolean(val);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if ((normKey === 'canonicalref' || normKey === 'canonical_ref') && typeof val === 'string') {
|
|
197
|
+
meta.canonicalRef = val.replace(/^\[\[(.*)\]\]$/, '$1').trim();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (normKey === 'created') {
|
|
202
|
+
meta.created = String(val);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (normKey === 'updated') {
|
|
207
|
+
meta.updated = String(val);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Store custom property
|
|
212
|
+
meta.custom[key] = val;
|
|
213
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { decodeVec, parseSchemaVersion, resolveIndexDimension, SCHEMA_VERSION } from './core.mjs';
|
|
3
|
+
import { validateLexicalIndex } from './lexical.mjs';
|
|
4
|
+
import { resolveModel } from './models.mjs';
|
|
5
|
+
|
|
6
|
+
const REBUILD = ' — run `mdss index` to rebuild';
|
|
7
|
+
|
|
8
|
+
/** @param {unknown} value @param {string} location */
|
|
9
|
+
export function inspectIndexSchema(value, location) {
|
|
10
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
11
|
+
throw new Error(`${location} root must be an object${REBUILD}`);
|
|
12
|
+
}
|
|
13
|
+
const index = /** @type {Record<string, unknown>} */ (value);
|
|
14
|
+
return { index, schema: parseSchemaVersion(index.schemaVersion) };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** @param {unknown} value @param {number|undefined} expectedDim @param {string} where */
|
|
18
|
+
export function validateNumericVector(value, expectedDim, where) {
|
|
19
|
+
if (!Array.isArray(value) && !(value instanceof Float32Array)) {
|
|
20
|
+
throw new Error(`${where}: missing vector${REBUILD}`);
|
|
21
|
+
}
|
|
22
|
+
if (value.length === 0) throw new Error(`${where}: vector must not be empty${REBUILD}`);
|
|
23
|
+
if (expectedDim !== undefined && value.length !== expectedDim) {
|
|
24
|
+
throw new Error(`${where}: vector has ${value.length} dims, expected ${expectedDim}${REBUILD}`);
|
|
25
|
+
}
|
|
26
|
+
for (let position = 0; position < value.length; position++) {
|
|
27
|
+
if (Array.isArray(value) && !Object.hasOwn(value, position)) {
|
|
28
|
+
throw new Error(`${where}: missing vector component at ${position}${REBUILD}`);
|
|
29
|
+
}
|
|
30
|
+
if (typeof value[position] !== 'number' || !Number.isFinite(value[position])) {
|
|
31
|
+
throw new Error(`${where}: non-finite vector value${REBUILD}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return value.length;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @param {unknown} chunk @param {number} position @param {{dim:number|undefined, encoding:'stored'|'loaded'|'none', allowMissingVector:(boolean|undefined)}} options */
|
|
38
|
+
export function validateCurrentChunk(chunk, position, options) {
|
|
39
|
+
const prefix = `chunk ${position}`;
|
|
40
|
+
if (chunk === null || typeof chunk !== 'object' || Array.isArray(chunk)) {
|
|
41
|
+
throw new Error(`${prefix} must be an object${REBUILD}`);
|
|
42
|
+
}
|
|
43
|
+
const value = /** @type {Record<string, unknown>} */ (chunk);
|
|
44
|
+
for (const field of ['file', 'title', 'heading', 'text', 'chunkHash']) {
|
|
45
|
+
if (typeof value[field] !== 'string') {
|
|
46
|
+
throw new Error(`${prefix} ${field} must be a string${REBUILD}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!Array.isArray(value.headingPath) || value.headingPath.length === 0) {
|
|
50
|
+
throw new Error(`${prefix} headingPath must contain nonempty strings${REBUILD}`);
|
|
51
|
+
}
|
|
52
|
+
for (let pathPosition = 0; pathPosition < value.headingPath.length; pathPosition++) {
|
|
53
|
+
if (!Object.hasOwn(value.headingPath, pathPosition) ||
|
|
54
|
+
typeof value.headingPath[pathPosition] !== 'string' ||
|
|
55
|
+
value.headingPath[pathPosition].trim().length === 0) {
|
|
56
|
+
throw new Error(`${prefix} headingPath must contain nonempty strings${REBUILD}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (value.headingPath[value.headingPath.length - 1] !== value.heading) {
|
|
60
|
+
throw new Error(`${prefix} headingPath leaf must equal heading${REBUILD}`);
|
|
61
|
+
}
|
|
62
|
+
if (options.encoding === 'none') return;
|
|
63
|
+
const vectorWhere = `${prefix}; chunk ${value.file}` +
|
|
64
|
+
(value.heading ? ` › ${value.heading}` : '');
|
|
65
|
+
if (value.vec === undefined && options.allowMissingVector) return;
|
|
66
|
+
if (options.encoding === 'stored') {
|
|
67
|
+
if (typeof value.vec !== 'string') {
|
|
68
|
+
throw new Error(`${prefix} vec must be canonical base64${REBUILD}`);
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
decodeVec(value.vec, options.dim);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw new Error(`${vectorWhere}: ${error.message}`);
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
validateNumericVector(value.vec, options.dim, vectorWhere);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Shared vectors.json boundary for loader, diagnostics, stats, and recovery.
|
|
82
|
+
* Legacy schemas intentionally keep their historical loose chunk metadata.
|
|
83
|
+
* @param {unknown} value
|
|
84
|
+
* @param {string} location
|
|
85
|
+
* @param {{encoding?:'stored'|'loaded', validateVectors?:boolean,
|
|
86
|
+
* allowMissingVectors?:boolean, validateLexical?:boolean}} [options]
|
|
87
|
+
*/
|
|
88
|
+
export function validateIndexEnvelope(value, location, options = {}) {
|
|
89
|
+
const { index, schema } = inspectIndexSchema(value, location);
|
|
90
|
+
if (schema > SCHEMA_VERSION) {
|
|
91
|
+
throw new Error(`${location} uses schema v${schema}, newer than this mdss supports; ` +
|
|
92
|
+
`supports up to v${SCHEMA_VERSION} — upgrade md-semantic-search`);
|
|
93
|
+
}
|
|
94
|
+
if (!Array.isArray(index.chunks)) {
|
|
95
|
+
throw new Error(`${location} chunks must be an array${REBUILD}`);
|
|
96
|
+
}
|
|
97
|
+
let modelName = index.model ?? index.modelAlias;
|
|
98
|
+
if (schema >= 3) {
|
|
99
|
+
if (typeof index.model !== 'string' || index.model.trim().length === 0) {
|
|
100
|
+
throw new Error(`schema v3 model must be a nonempty string${REBUILD}`);
|
|
101
|
+
}
|
|
102
|
+
modelName = index.model;
|
|
103
|
+
} else if (modelName !== undefined && modelName !== null && typeof modelName !== 'string') {
|
|
104
|
+
throw new Error(`${location} model metadata must be a string${REBUILD}`);
|
|
105
|
+
}
|
|
106
|
+
const model = resolveModel(/** @type {string|undefined} */ (modelName ?? undefined));
|
|
107
|
+
const dim = resolveIndexDimension(index.dim, model.dim);
|
|
108
|
+
if (schema < 3) {
|
|
109
|
+
return { index, schema, model, dim };
|
|
110
|
+
}
|
|
111
|
+
if (index.format !== 'binary-v1') {
|
|
112
|
+
throw new Error(`schema v3 format must be binary-v1${REBUILD}`);
|
|
113
|
+
}
|
|
114
|
+
if (typeof index.chunkCount !== 'number' || !Number.isSafeInteger(index.chunkCount) || index.chunkCount < 0 ||
|
|
115
|
+
index.chunkCount !== index.chunks.length) {
|
|
116
|
+
throw new Error(`schema v3 chunkCount must equal chunks.length${REBUILD}`);
|
|
117
|
+
}
|
|
118
|
+
if (index.chunks.length > 0 && dim === undefined) {
|
|
119
|
+
throw new Error(`nonempty schema v3 custom index requires explicit dim${REBUILD}`);
|
|
120
|
+
}
|
|
121
|
+
const encoding = options.validateVectors === false ? 'none' : (options.encoding ?? 'stored');
|
|
122
|
+
for (let position = 0; position < index.chunks.length; position++) {
|
|
123
|
+
validateCurrentChunk(index.chunks[position], position, {
|
|
124
|
+
dim, encoding, allowMissingVector: options.allowMissingVectors,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if (options.validateLexical !== false) {
|
|
128
|
+
const lexicalError = validateLexicalIndex(index.lexical, index.chunks.length);
|
|
129
|
+
if (lexicalError) {
|
|
130
|
+
throw new Error(`schema v3 lexical index is invalid (${lexicalError})${REBUILD}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { index, schema, model, dim };
|
|
134
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Public library API for md-semantic-search.
|
|
4
|
+
*
|
|
5
|
+
* Import from a consumer project:
|
|
6
|
+
*
|
|
7
|
+
* import { buildIndex, search, loadIndex, searchIndex, resolveModel, MODELS } from 'md-semantic-search';
|
|
8
|
+
*
|
|
9
|
+
* One-shot usage (parses the index on every call):
|
|
10
|
+
*
|
|
11
|
+
* await buildIndex({ db: './docs', indexDir: './.mdss', cacheDir: '~/.cache/mdss' });
|
|
12
|
+
* const hits = await search({ indexDir: './.mdss', cacheDir: '~/.cache/mdss', query: 'rotate api token' });
|
|
13
|
+
*
|
|
14
|
+
* Repeated queries in one process (parse + model cached, issue #2):
|
|
15
|
+
*
|
|
16
|
+
* const loaded = loadIndex('./.mdss');
|
|
17
|
+
* const hitsA = await searchIndex({ loaded, cacheDir: '~/.cache/mdss', query: 'failover runbook' });
|
|
18
|
+
* const hitsB = await searchIndex({ loaded, cacheDir: '~/.cache/mdss', query: 'db backup' });
|
|
19
|
+
*
|
|
20
|
+
* All functions are model- and path-agnostic: they take explicit paths, so the
|
|
21
|
+
* same API works on any folder of markdown anywhere on disk.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
buildIndex, chunkHash,
|
|
26
|
+
} from './indexer.mjs';
|
|
27
|
+
|
|
28
|
+
export {
|
|
29
|
+
search, searchIndex, loadIndex,
|
|
30
|
+
tokenize, keywordScores, rrf, QueryEmbeddingCache,
|
|
31
|
+
} from './search.mjs';
|
|
32
|
+
|
|
33
|
+
export {
|
|
34
|
+
resolveModel, MODELS, DEFAULT_MODEL,
|
|
35
|
+
normalizeAdapter, embeddingAdapterFingerprint, legacyEmbeddingAdapterFingerprint,
|
|
36
|
+
} from './models.mjs';
|
|
37
|
+
|
|
38
|
+
export {
|
|
39
|
+
getReranker, rerankScores, RERANK_MODEL,
|
|
40
|
+
} from './rerank.mjs';
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
embed, getExtractor, cosine,
|
|
44
|
+
walkMarkdown, parseFile, chunkMarkdown,
|
|
45
|
+
splitFrontmatter, extractTitle, globToRegExp,
|
|
46
|
+
encodeVec, decodeVec, isBinaryIndex,
|
|
47
|
+
} from './core.mjs';
|