knolo-core 0.2.3 → 0.3.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/DOCS.md +228 -14
- package/README.md +229 -151
- package/bin/knolo.mjs +120 -13
- package/dist/builder.d.ts +13 -1
- package/dist/builder.js +103 -11
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -2
- package/dist/indexer.d.ts +2 -1
- package/dist/indexer.js +3 -2
- package/dist/pack.d.ts +12 -0
- package/dist/pack.js +77 -4
- package/dist/patch.d.ts +1 -8
- package/dist/patch.js +2 -17
- package/dist/query.d.ts +28 -0
- package/dist/query.js +282 -18
- package/dist/rank.d.ts +1 -1
- package/dist/rank.js +5 -4
- package/dist/semantic.d.ts +7 -0
- package/dist/semantic.js +98 -0
- package/dist/tokenize.js +1 -1
- package/package.json +3 -2
package/dist/query.js
CHANGED
|
@@ -13,18 +13,76 @@ import { rankBM25L } from "./rank.js";
|
|
|
13
13
|
import { minCoverSpan, proximityMultiplier } from "./quality/proximity.js";
|
|
14
14
|
import { diversifyAndDedupe } from "./quality/diversify.js";
|
|
15
15
|
import { knsSignature, knsDistance } from "./quality/signature.js";
|
|
16
|
+
import { decodeScaleF16, quantizeEmbeddingInt8L2Norm } from "./semantic.js";
|
|
17
|
+
export function validateSemanticQueryOptions(options) {
|
|
18
|
+
if (!options)
|
|
19
|
+
return;
|
|
20
|
+
if (options.enabled !== undefined && typeof options.enabled !== "boolean") {
|
|
21
|
+
throw new Error("query(...): semantic.enabled must be a boolean when provided.");
|
|
22
|
+
}
|
|
23
|
+
if (options.mode !== undefined && options.mode !== "rerank") {
|
|
24
|
+
throw new Error('query(...): semantic.mode currently only supports "rerank".');
|
|
25
|
+
}
|
|
26
|
+
if (options.topN !== undefined && (!Number.isInteger(options.topN) || options.topN < 1)) {
|
|
27
|
+
throw new Error("query(...): semantic.topN must be a positive integer.");
|
|
28
|
+
}
|
|
29
|
+
if (options.minLexConfidence !== undefined && (!Number.isFinite(options.minLexConfidence) || options.minLexConfidence < 0 || options.minLexConfidence > 1)) {
|
|
30
|
+
throw new Error("query(...): semantic.minLexConfidence must be a finite number between 0 and 1.");
|
|
31
|
+
}
|
|
32
|
+
if (options.queryEmbedding !== undefined && !(options.queryEmbedding instanceof Float32Array)) {
|
|
33
|
+
throw new Error("query(...): semantic.queryEmbedding must be a Float32Array.");
|
|
34
|
+
}
|
|
35
|
+
if (options.blend) {
|
|
36
|
+
if (options.blend.enabled !== undefined && typeof options.blend.enabled !== "boolean") {
|
|
37
|
+
throw new Error("query(...): semantic.blend.enabled must be a boolean when provided.");
|
|
38
|
+
}
|
|
39
|
+
if (options.blend.wLex !== undefined && (!Number.isFinite(options.blend.wLex) || options.blend.wLex < 0)) {
|
|
40
|
+
throw new Error("query(...): semantic.blend.wLex must be a finite number >= 0.");
|
|
41
|
+
}
|
|
42
|
+
if (options.blend.wSem !== undefined && (!Number.isFinite(options.blend.wSem) || options.blend.wSem < 0)) {
|
|
43
|
+
throw new Error("query(...): semantic.blend.wSem must be a finite number >= 0.");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (options.force !== undefined && typeof options.force !== "boolean") {
|
|
47
|
+
throw new Error("query(...): semantic.force must be a boolean when provided.");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
16
50
|
export function query(pack, q, opts = {}) {
|
|
51
|
+
validateSemanticQueryOptions(opts.semantic);
|
|
17
52
|
const topK = opts.topK ?? 10;
|
|
53
|
+
const minScore = Number.isFinite(opts.minScore) ? Math.max(0, opts.minScore) : 0;
|
|
54
|
+
const expansionOpts = {
|
|
55
|
+
enabled: opts.queryExpansion?.enabled ?? true,
|
|
56
|
+
docs: Math.max(1, opts.queryExpansion?.docs ?? 3),
|
|
57
|
+
terms: Math.max(1, opts.queryExpansion?.terms ?? 4),
|
|
58
|
+
weight: Math.max(0, opts.queryExpansion?.weight ?? 0.35),
|
|
59
|
+
minTermLength: Math.max(2, opts.queryExpansion?.minTermLength ?? 3),
|
|
60
|
+
};
|
|
61
|
+
const semanticOpts = {
|
|
62
|
+
enabled: opts.semantic?.enabled ?? false,
|
|
63
|
+
mode: opts.semantic?.mode ?? "rerank",
|
|
64
|
+
topN: Math.max(1, opts.semantic?.topN ?? 50),
|
|
65
|
+
minLexConfidence: clamp01(opts.semantic?.minLexConfidence ?? 0.35),
|
|
66
|
+
blend: {
|
|
67
|
+
enabled: opts.semantic?.blend?.enabled ?? true,
|
|
68
|
+
wLex: Math.max(0, opts.semantic?.blend?.wLex ?? 0.75),
|
|
69
|
+
wSem: Math.max(0, opts.semantic?.blend?.wSem ?? 0.25),
|
|
70
|
+
},
|
|
71
|
+
queryEmbedding: opts.semantic?.queryEmbedding,
|
|
72
|
+
force: opts.semantic?.force ?? false,
|
|
73
|
+
};
|
|
18
74
|
// --- Query parsing
|
|
19
75
|
const normTokens = tokenize(q).map((t) => t.term);
|
|
20
76
|
// Normalize quoted phrases from q
|
|
21
|
-
const quotedRaw = parsePhrases(q);
|
|
22
|
-
const quoted = quotedRaw.map(seq => seq.map(t => normalize(t)).flatMap(s => s.split(/\s+/)).filter(Boolean));
|
|
77
|
+
const quotedRaw = parsePhrases(q);
|
|
78
|
+
const quoted = quotedRaw.map((seq) => seq.map((t) => normalize(t)).flatMap((s) => s.split(/\s+/)).filter(Boolean));
|
|
23
79
|
// Normalize requirePhrases the same way
|
|
24
80
|
const extraReq = (opts.requirePhrases ?? [])
|
|
25
|
-
.map(s => tokenize(s).map(t => t.term))
|
|
26
|
-
.filter(arr => arr.length > 0);
|
|
81
|
+
.map((s) => tokenize(s).map((t) => t.term))
|
|
82
|
+
.filter((arr) => arr.length > 0);
|
|
27
83
|
const requiredPhrases = [...quoted, ...extraReq];
|
|
84
|
+
const namespaceFilter = normalizeNamespaceFilter(opts.namespace);
|
|
85
|
+
const sourceFilter = normalizeSourceFilter(opts.source);
|
|
28
86
|
// --- Term ids for the free (unquoted) tokens in q
|
|
29
87
|
const termIds = normTokens
|
|
30
88
|
.map((t) => pack.lexicon.get(t))
|
|
@@ -33,39 +91,53 @@ export function query(pack, q, opts = {}) {
|
|
|
33
91
|
const termSet = new Set(termIds);
|
|
34
92
|
// --- Candidate map
|
|
35
93
|
const candidates = new Map();
|
|
94
|
+
// Query-time document frequency collection for BM25 IDF.
|
|
95
|
+
const dfs = new Map();
|
|
96
|
+
const usesOffsetBlockIds = (pack.meta?.version ?? 1) >= 3;
|
|
36
97
|
// Helper to harvest postings for a given set of termIds into candidates
|
|
37
|
-
function scanForTermIds(
|
|
98
|
+
function scanForTermIds(idWeights, cfg = { collectPositions: true, createCandidates: true }) {
|
|
38
99
|
const p = pack.postings;
|
|
39
100
|
let i = 0;
|
|
40
101
|
while (i < p.length) {
|
|
41
102
|
const tid = p[i++];
|
|
42
103
|
if (tid === 0)
|
|
43
104
|
continue;
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
105
|
+
const weight = idWeights.get(tid) ?? 0;
|
|
106
|
+
const relevant = weight > 0;
|
|
107
|
+
let termDf = 0;
|
|
108
|
+
let encodedBid = p[i++];
|
|
109
|
+
while (encodedBid !== 0) {
|
|
110
|
+
const bid = usesOffsetBlockIds ? encodedBid - 1 : encodedBid;
|
|
47
111
|
let pos = p[i++];
|
|
48
112
|
const positions = [];
|
|
49
113
|
while (pos !== 0) {
|
|
50
114
|
positions.push(pos);
|
|
51
115
|
pos = p[i++];
|
|
52
116
|
}
|
|
53
|
-
|
|
117
|
+
termDf++;
|
|
118
|
+
if (relevant && bid >= 0) {
|
|
54
119
|
let entry = candidates.get(bid);
|
|
55
|
-
if (!entry) {
|
|
120
|
+
if (!entry && cfg.createCandidates !== false) {
|
|
56
121
|
entry = { tf: new Map(), pos: new Map() };
|
|
57
122
|
candidates.set(bid, entry);
|
|
58
123
|
}
|
|
59
|
-
entry
|
|
60
|
-
|
|
124
|
+
if (entry) {
|
|
125
|
+
const prevTf = entry.tf.get(tid) ?? 0;
|
|
126
|
+
entry.tf.set(tid, prevTf + positions.length * weight);
|
|
127
|
+
if (cfg.collectPositions !== false) {
|
|
128
|
+
entry.pos.set(tid, positions);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
61
131
|
}
|
|
62
|
-
|
|
132
|
+
encodedBid = p[i++];
|
|
63
133
|
}
|
|
134
|
+
if (relevant)
|
|
135
|
+
dfs.set(tid, termDf);
|
|
64
136
|
}
|
|
65
137
|
}
|
|
66
138
|
// 1) Scan using tokens from q (if any)
|
|
67
139
|
if (termSet.size > 0) {
|
|
68
|
-
scanForTermIds(termSet);
|
|
140
|
+
scanForTermIds(new Map(Array.from(termSet.values(), (tid) => [tid, 1])));
|
|
69
141
|
}
|
|
70
142
|
// 2) Phrase-first rescue:
|
|
71
143
|
// If nothing matched the free tokens, but we do have required phrases,
|
|
@@ -80,7 +152,27 @@ export function query(pack, q, opts = {}) {
|
|
|
80
152
|
}
|
|
81
153
|
}
|
|
82
154
|
if (phraseTokenIds.size > 0) {
|
|
83
|
-
scanForTermIds(phraseTokenIds);
|
|
155
|
+
scanForTermIds(new Map(Array.from(phraseTokenIds.values(), (tid) => [tid, 1])));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// --- Namespace filtering
|
|
159
|
+
if (namespaceFilter.size > 0) {
|
|
160
|
+
for (const bid of [...candidates.keys()]) {
|
|
161
|
+
const ns = pack.namespaces?.[bid];
|
|
162
|
+
const normalizedNs = typeof ns === "string" ? normalize(ns) : "";
|
|
163
|
+
if (!normalizedNs || !namespaceFilter.has(normalizedNs)) {
|
|
164
|
+
candidates.delete(bid);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// --- Source/docId filtering
|
|
169
|
+
if (sourceFilter.size > 0) {
|
|
170
|
+
for (const bid of [...candidates.keys()]) {
|
|
171
|
+
const source = pack.docIds?.[bid];
|
|
172
|
+
const normalizedSource = typeof source === "string" ? normalize(source) : "";
|
|
173
|
+
if (!normalizedSource || !sourceFilter.has(normalizedSource)) {
|
|
174
|
+
candidates.delete(bid);
|
|
175
|
+
}
|
|
84
176
|
}
|
|
85
177
|
}
|
|
86
178
|
// --- Phrase enforcement (now that we have some candidates)
|
|
@@ -119,11 +211,30 @@ export function query(pack, q, opts = {}) {
|
|
|
119
211
|
(pack.blocks.length
|
|
120
212
|
? pack.blocks.reduce((s, b) => s + tokenize(b).length, 0) / pack.blocks.length
|
|
121
213
|
: 1);
|
|
122
|
-
const
|
|
214
|
+
const docCount = pack.meta?.stats?.blocks ?? pack.blocks.length;
|
|
215
|
+
let prelim = rankBM25L(candidates, avgLen, docCount, dfs, pack.blockTokenLens, {
|
|
123
216
|
proximityBonus: (cand) => proximityMultiplier(minCoverSpan(cand.pos)),
|
|
124
217
|
});
|
|
218
|
+
if (expansionOpts.enabled && prelim.length > 0) {
|
|
219
|
+
const expansionWeights = deriveExpansionTerms(pack, prelim, termSet, requiredPhrases, expansionOpts);
|
|
220
|
+
if (expansionWeights.size > 0) {
|
|
221
|
+
scanForTermIds(expansionWeights, { collectPositions: false, createCandidates: true });
|
|
222
|
+
prelim = rankBM25L(candidates, avgLen, docCount, dfs, pack.blockTokenLens, {
|
|
223
|
+
proximityBonus: (cand) => proximityMultiplier(minCoverSpan(cand.pos)),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
125
227
|
if (prelim.length === 0)
|
|
126
228
|
return [];
|
|
229
|
+
if (minScore > 0) {
|
|
230
|
+
prelim = prelim.filter((item) => item.score >= minScore);
|
|
231
|
+
if (prelim.length === 0)
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
const confidence = lexConfidence(prelim);
|
|
235
|
+
if (shouldRerankWithSemantic(pack, semanticOpts, confidence)) {
|
|
236
|
+
prelim = rerankLexicalHitsWithSemantic(pack, prelim, semanticOpts);
|
|
237
|
+
}
|
|
127
238
|
// --- KNS tie-breaker + de-dup/MMR
|
|
128
239
|
const qSig = knsSignature(normalize(q));
|
|
129
240
|
const pool = prelim.slice(0, topK * 5).map((r) => {
|
|
@@ -134,17 +245,158 @@ export function query(pack, q, opts = {}) {
|
|
|
134
245
|
score: r.score * boost,
|
|
135
246
|
text,
|
|
136
247
|
source: pack.docIds?.[r.blockId] ?? undefined,
|
|
248
|
+
namespace: pack.namespaces?.[r.blockId] ?? undefined,
|
|
137
249
|
};
|
|
138
250
|
});
|
|
139
251
|
const finalHits = diversifyAndDedupe(pool, { k: topK });
|
|
140
252
|
return finalHits;
|
|
141
253
|
}
|
|
254
|
+
export function lexConfidence(hits) {
|
|
255
|
+
if (hits.length === 0)
|
|
256
|
+
return 0;
|
|
257
|
+
const top1 = Math.max(0, hits[0]?.score ?? 0);
|
|
258
|
+
const top2 = Math.max(0, hits[1]?.score ?? 0);
|
|
259
|
+
const gapRatio = top1 > 0 ? clamp01((top1 - top2) / top1) : 0;
|
|
260
|
+
const strength = top1 > 0 ? top1 / (top1 + 1) : 0;
|
|
261
|
+
return clamp01(0.65 * gapRatio + 0.35 * strength);
|
|
262
|
+
}
|
|
263
|
+
function shouldRerankWithSemantic(pack, opts, confidence) {
|
|
264
|
+
if (!opts.enabled || opts.mode !== "rerank")
|
|
265
|
+
return false;
|
|
266
|
+
if (!pack.semantic)
|
|
267
|
+
return false;
|
|
268
|
+
if (!opts.queryEmbedding) {
|
|
269
|
+
throw new Error("query(...): semantic.queryEmbedding (Float32Array) is required when semantic.enabled=true.");
|
|
270
|
+
}
|
|
271
|
+
return opts.force || confidence < opts.minLexConfidence;
|
|
272
|
+
}
|
|
273
|
+
function rerankLexicalHitsWithSemantic(pack, prelim, opts) {
|
|
274
|
+
const sem = pack.semantic;
|
|
275
|
+
if (!sem || !opts.queryEmbedding)
|
|
276
|
+
return prelim;
|
|
277
|
+
if (sem.dims <= 0 || sem.vecs.length === 0 || sem.dims !== opts.queryEmbedding.length)
|
|
278
|
+
return prelim;
|
|
279
|
+
const topN = Math.min(opts.topN, prelim.length);
|
|
280
|
+
const rerankSlice = prelim.slice(0, topN);
|
|
281
|
+
const tail = prelim.slice(topN);
|
|
282
|
+
const lexScores = new Float64Array(topN);
|
|
283
|
+
for (let i = 0; i < topN; i++)
|
|
284
|
+
lexScores[i] = rerankSlice[i].score;
|
|
285
|
+
const normLex = minMaxNormalizeTyped(lexScores);
|
|
286
|
+
const quantizedQuery = quantizeEmbeddingInt8L2Norm(opts.queryEmbedding);
|
|
287
|
+
const semScores = scoreSemanticInt8(quantizedQuery.q, quantizedQuery.scale, sem, rerankSlice);
|
|
288
|
+
const normSem = minMaxNormalizeTyped(semScores);
|
|
289
|
+
const denom = opts.blend.wLex + opts.blend.wSem;
|
|
290
|
+
const wLex = denom > 0 ? opts.blend.wLex / denom : 0.5;
|
|
291
|
+
const wSem = denom > 0 ? opts.blend.wSem / denom : 0.5;
|
|
292
|
+
const reranked = new Array(topN);
|
|
293
|
+
for (let i = 0; i < topN; i++) {
|
|
294
|
+
const hit = rerankSlice[i];
|
|
295
|
+
reranked[i] = {
|
|
296
|
+
blockId: hit.blockId,
|
|
297
|
+
score: opts.blend.enabled ? wLex * normLex[i] + wSem * normSem[i] : semScores[i],
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
reranked.sort((a, b) => b.score - a.score || a.blockId - b.blockId);
|
|
301
|
+
return [...reranked, ...tail];
|
|
302
|
+
}
|
|
303
|
+
function scoreSemanticInt8(queryQ, queryScale, semantic, hits) {
|
|
304
|
+
const scores = new Float64Array(hits.length);
|
|
305
|
+
const dims = semantic.dims;
|
|
306
|
+
if (queryQ.length !== dims || queryScale === 0)
|
|
307
|
+
return scores;
|
|
308
|
+
const vecs = semantic.vecs;
|
|
309
|
+
const scales = semantic.scales;
|
|
310
|
+
for (let h = 0; h < hits.length; h++) {
|
|
311
|
+
const blockId = hits[h].blockId;
|
|
312
|
+
if (blockId < 0)
|
|
313
|
+
continue;
|
|
314
|
+
const base = blockId * dims;
|
|
315
|
+
if (base + dims > vecs.length)
|
|
316
|
+
continue;
|
|
317
|
+
let dot = 0;
|
|
318
|
+
for (let i = 0; i < dims; i++) {
|
|
319
|
+
dot += queryQ[i] * vecs[base + i];
|
|
320
|
+
}
|
|
321
|
+
const blockScale = scales?.[blockId] !== undefined ? decodeScaleF16(scales[blockId]) : (1 / 127);
|
|
322
|
+
scores[h] = dot * queryScale * blockScale;
|
|
323
|
+
}
|
|
324
|
+
return scores;
|
|
325
|
+
}
|
|
326
|
+
function minMaxNormalizeTyped(values) {
|
|
327
|
+
if (values.length === 0)
|
|
328
|
+
return values;
|
|
329
|
+
let min = Infinity;
|
|
330
|
+
let max = -Infinity;
|
|
331
|
+
for (let i = 0; i < values.length; i++) {
|
|
332
|
+
const v = values[i];
|
|
333
|
+
if (v < min)
|
|
334
|
+
min = v;
|
|
335
|
+
if (v > max)
|
|
336
|
+
max = v;
|
|
337
|
+
}
|
|
338
|
+
if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) {
|
|
339
|
+
const out = new Float64Array(values.length);
|
|
340
|
+
out.fill(1);
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
const out = new Float64Array(values.length);
|
|
344
|
+
const span = max - min;
|
|
345
|
+
for (let i = 0; i < values.length; i++) {
|
|
346
|
+
out[i] = clamp01((values[i] - min) / span);
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
function clamp01(v) {
|
|
351
|
+
if (!Number.isFinite(v))
|
|
352
|
+
return 0;
|
|
353
|
+
if (v < 0)
|
|
354
|
+
return 0;
|
|
355
|
+
if (v > 1)
|
|
356
|
+
return 1;
|
|
357
|
+
return v;
|
|
358
|
+
}
|
|
359
|
+
function deriveExpansionTerms(pack, prelim, baseTermSet, requiredPhrases, opts) {
|
|
360
|
+
if (prelim.length === 0 || opts.weight <= 0)
|
|
361
|
+
return new Map();
|
|
362
|
+
const forbidden = new Set(baseTermSet);
|
|
363
|
+
for (const seq of requiredPhrases) {
|
|
364
|
+
for (const term of seq) {
|
|
365
|
+
const tid = pack.lexicon.get(term);
|
|
366
|
+
if (tid !== undefined)
|
|
367
|
+
forbidden.add(tid);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const cap = Math.min(opts.docs, prelim.length);
|
|
371
|
+
const bestScore = Math.max(prelim[0]?.score ?? 0, 1e-6);
|
|
372
|
+
const termScores = new Map();
|
|
373
|
+
for (let i = 0; i < cap; i++) {
|
|
374
|
+
const item = prelim[i];
|
|
375
|
+
const text = pack.blocks[item.blockId] ?? "";
|
|
376
|
+
const docWeight = Math.max(item.score / bestScore, 0.2);
|
|
377
|
+
const localTfs = new Map();
|
|
378
|
+
for (const tok of tokenize(text)) {
|
|
379
|
+
if (tok.term.length < opts.minTermLength)
|
|
380
|
+
continue;
|
|
381
|
+
const tid = pack.lexicon.get(tok.term);
|
|
382
|
+
if (tid === undefined || forbidden.has(tid))
|
|
383
|
+
continue;
|
|
384
|
+
localTfs.set(tid, (localTfs.get(tid) ?? 0) + 1);
|
|
385
|
+
}
|
|
386
|
+
for (const [tid, tf] of localTfs) {
|
|
387
|
+
termScores.set(tid, (termScores.get(tid) ?? 0) + tf * docWeight);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const selected = [...termScores.entries()]
|
|
391
|
+
.sort((a, b) => b[1] - a[1])
|
|
392
|
+
.slice(0, opts.terms);
|
|
393
|
+
return new Map(selected.map(([tid, score]) => [tid, opts.weight * Math.max(0.5, Math.min(1.5, score))]));
|
|
394
|
+
}
|
|
142
395
|
/** Ordered phrase check using the SAME tokenizer/normalizer path as the index. */
|
|
143
396
|
function containsPhrase(text, seq) {
|
|
144
397
|
if (seq.length === 0)
|
|
145
398
|
return false;
|
|
146
|
-
|
|
147
|
-
const seqNorm = tokenize(seq.join(" ")).map(t => t.term);
|
|
399
|
+
const seqNorm = tokenize(seq.join(" ")).map((t) => t.term);
|
|
148
400
|
const toks = tokenize(text).map((t) => t.term);
|
|
149
401
|
outer: for (let i = 0; i <= toks.length - seqNorm.length; i++) {
|
|
150
402
|
for (let j = 0; j < seqNorm.length; j++) {
|
|
@@ -155,3 +407,15 @@ function containsPhrase(text, seq) {
|
|
|
155
407
|
}
|
|
156
408
|
return false;
|
|
157
409
|
}
|
|
410
|
+
function normalizeNamespaceFilter(input) {
|
|
411
|
+
if (input === undefined)
|
|
412
|
+
return new Set();
|
|
413
|
+
const values = Array.isArray(input) ? input : [input];
|
|
414
|
+
return new Set(values.map((v) => normalize(v)).filter(Boolean));
|
|
415
|
+
}
|
|
416
|
+
function normalizeSourceFilter(input) {
|
|
417
|
+
if (input === undefined)
|
|
418
|
+
return new Set();
|
|
419
|
+
const values = Array.isArray(input) ? input : [input];
|
|
420
|
+
return new Set(values.map((v) => normalize(v)).filter(Boolean));
|
|
421
|
+
}
|
package/dist/rank.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export declare function rankBM25L(candidates: Map<number, {
|
|
|
15
15
|
pos?: Map<number, number[]>;
|
|
16
16
|
hasPhrase?: boolean;
|
|
17
17
|
headingScore?: number;
|
|
18
|
-
}>, avgLen: number, opts?: RankOptions): Array<{
|
|
18
|
+
}>, avgLen: number, docCount: number, dfs: Map<number, number>, blockTokenLens?: number[], opts?: RankOptions): Array<{
|
|
19
19
|
blockId: number;
|
|
20
20
|
score: number;
|
|
21
21
|
}>;
|
package/dist/rank.js
CHANGED
|
@@ -2,17 +2,18 @@
|
|
|
2
2
|
* rank.ts
|
|
3
3
|
* BM25L ranker with optional heading/phrase boosts and a proximity bonus hook.
|
|
4
4
|
*/
|
|
5
|
-
export function rankBM25L(candidates, avgLen, opts = {}) {
|
|
5
|
+
export function rankBM25L(candidates, avgLen, docCount, dfs, blockTokenLens, opts = {}) {
|
|
6
6
|
const k1 = opts.k1 ?? 1.5;
|
|
7
7
|
const b = opts.b ?? 0.75;
|
|
8
8
|
const headingBoost = opts.headingBoost ?? 0.3;
|
|
9
9
|
const phraseBoost = opts.phraseBoost ?? 0.6;
|
|
10
10
|
const results = [];
|
|
11
11
|
for (const [bid, data] of candidates) {
|
|
12
|
-
const len = Array.from(data.tf.values()).reduce((sum, tf) => sum + tf, 0) || 1;
|
|
12
|
+
const len = blockTokenLens?.[bid] ?? (Array.from(data.tf.values()).reduce((sum, tf) => sum + tf, 0) || 1);
|
|
13
13
|
let score = 0;
|
|
14
|
-
for (const [, tf] of data.tf) {
|
|
15
|
-
const
|
|
14
|
+
for (const [tid, tf] of data.tf) {
|
|
15
|
+
const df = dfs.get(tid) ?? 0;
|
|
16
|
+
const idf = Math.log(1 + (docCount - df + 0.5) / (df + 0.5));
|
|
16
17
|
const numer = tf * (k1 + 1);
|
|
17
18
|
const denom = tf + k1 * (1 - b + b * (len / avgLen));
|
|
18
19
|
score += idf * (numer / denom);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type QuantizedVector = {
|
|
2
|
+
q: Int8Array;
|
|
3
|
+
scale: number;
|
|
4
|
+
};
|
|
5
|
+
export declare function quantizeEmbeddingInt8L2Norm(embedding: Float32Array): QuantizedVector;
|
|
6
|
+
export declare function encodeScaleF16(scale: number): number;
|
|
7
|
+
export declare function decodeScaleF16(encoded: number): number;
|
package/dist/semantic.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export function quantizeEmbeddingInt8L2Norm(embedding) {
|
|
2
|
+
const dims = embedding.length;
|
|
3
|
+
const normalized = new Float32Array(dims);
|
|
4
|
+
let normSq = 0;
|
|
5
|
+
for (let i = 0; i < dims; i++)
|
|
6
|
+
normSq += embedding[i] * embedding[i];
|
|
7
|
+
const norm = Math.sqrt(normSq);
|
|
8
|
+
if (norm === 0) {
|
|
9
|
+
return { q: new Int8Array(dims), scale: 0 };
|
|
10
|
+
}
|
|
11
|
+
let maxAbs = 0;
|
|
12
|
+
for (let i = 0; i < dims; i++) {
|
|
13
|
+
const value = embedding[i] / norm;
|
|
14
|
+
normalized[i] = value;
|
|
15
|
+
const abs = Math.abs(value);
|
|
16
|
+
if (abs > maxAbs)
|
|
17
|
+
maxAbs = abs;
|
|
18
|
+
}
|
|
19
|
+
const scale = maxAbs / 127;
|
|
20
|
+
if (scale === 0) {
|
|
21
|
+
return { q: new Int8Array(dims), scale: 0 };
|
|
22
|
+
}
|
|
23
|
+
const q = new Int8Array(dims);
|
|
24
|
+
for (let i = 0; i < dims; i++) {
|
|
25
|
+
const quantized = Math.round(normalized[i] / scale);
|
|
26
|
+
q[i] = clampInt8(quantized);
|
|
27
|
+
}
|
|
28
|
+
return { q, scale };
|
|
29
|
+
}
|
|
30
|
+
export function encodeScaleF16(scale) {
|
|
31
|
+
return float32ToFloat16(scale);
|
|
32
|
+
}
|
|
33
|
+
export function decodeScaleF16(encoded) {
|
|
34
|
+
return float16ToFloat32(encoded);
|
|
35
|
+
}
|
|
36
|
+
function clampInt8(value) {
|
|
37
|
+
if (value > 127)
|
|
38
|
+
return 127;
|
|
39
|
+
if (value < -127)
|
|
40
|
+
return -127;
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
function float32ToFloat16(value) {
|
|
44
|
+
if (Number.isNaN(value))
|
|
45
|
+
return 0x7e00;
|
|
46
|
+
if (value === Infinity)
|
|
47
|
+
return 0x7c00;
|
|
48
|
+
if (value === -Infinity)
|
|
49
|
+
return 0xfc00;
|
|
50
|
+
const f32 = new Float32Array(1);
|
|
51
|
+
const u32 = new Uint32Array(f32.buffer);
|
|
52
|
+
f32[0] = value;
|
|
53
|
+
const bits = u32[0];
|
|
54
|
+
const sign = (bits >>> 16) & 0x8000;
|
|
55
|
+
let exp = (bits >>> 23) & 0xff;
|
|
56
|
+
let mantissa = bits & 0x7fffff;
|
|
57
|
+
if (exp === 0xff) {
|
|
58
|
+
return sign | (mantissa ? 0x7e00 : 0x7c00);
|
|
59
|
+
}
|
|
60
|
+
const halfExp = exp - 127 + 15;
|
|
61
|
+
if (halfExp >= 0x1f) {
|
|
62
|
+
return sign | 0x7c00;
|
|
63
|
+
}
|
|
64
|
+
if (halfExp <= 0) {
|
|
65
|
+
if (halfExp < -10)
|
|
66
|
+
return sign;
|
|
67
|
+
mantissa = (mantissa | 0x800000) >>> (1 - halfExp);
|
|
68
|
+
if (mantissa & 0x1000)
|
|
69
|
+
mantissa += 0x2000;
|
|
70
|
+
return sign | (mantissa >>> 13);
|
|
71
|
+
}
|
|
72
|
+
if (mantissa & 0x1000) {
|
|
73
|
+
mantissa += 0x2000;
|
|
74
|
+
if (mantissa & 0x800000) {
|
|
75
|
+
mantissa = 0;
|
|
76
|
+
exp += 1;
|
|
77
|
+
if (exp > 142)
|
|
78
|
+
return sign | 0x7c00;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return sign | (halfExp << 10) | (mantissa >>> 13);
|
|
82
|
+
}
|
|
83
|
+
function float16ToFloat32(bits) {
|
|
84
|
+
const sign = (bits & 0x8000) ? -1 : 1;
|
|
85
|
+
const exp = (bits >>> 10) & 0x1f;
|
|
86
|
+
const frac = bits & 0x03ff;
|
|
87
|
+
if (exp === 0) {
|
|
88
|
+
if (frac === 0)
|
|
89
|
+
return sign * 0;
|
|
90
|
+
return sign * Math.pow(2, -14) * (frac / 1024);
|
|
91
|
+
}
|
|
92
|
+
if (exp === 0x1f) {
|
|
93
|
+
if (frac === 0)
|
|
94
|
+
return sign * Infinity;
|
|
95
|
+
return NaN;
|
|
96
|
+
}
|
|
97
|
+
return sign * Math.pow(2, exp - 15) * (1 + frac / 1024);
|
|
98
|
+
}
|
package/dist/tokenize.js
CHANGED
|
@@ -42,7 +42,7 @@ export function tokenize(text) {
|
|
|
42
42
|
*/
|
|
43
43
|
export function parsePhrases(q) {
|
|
44
44
|
const parts = [];
|
|
45
|
-
const regex = /"([
|
|
45
|
+
const regex = /["“”]([^"“”]+)["“”]/g;
|
|
46
46
|
let match;
|
|
47
47
|
while ((match = regex.exec(q)) !== null) {
|
|
48
48
|
const phrase = match[1].trim().split(/\s+/);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "knolo-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Local-first knowledge packs for small LLMs.",
|
|
6
6
|
"keywords": [
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"scripts": {
|
|
34
34
|
"build": "tsc -p tsconfig.json",
|
|
35
35
|
"prepublishOnly": "npm run build",
|
|
36
|
-
"smoke": "node scripts/smoke.mjs"
|
|
36
|
+
"smoke": "node scripts/smoke.mjs",
|
|
37
|
+
"test": "npm run build && node scripts/test.mjs"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
40
|
"typescript": "^5.5.0",
|