-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsearch.js
More file actions
438 lines (401 loc) · 17.6 KB
/
Copy pathsearch.js
File metadata and controls
438 lines (401 loc) · 17.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
/* ===========================================================================
* search.js — the browser side of the search: files, worker, ranked results.
*
* Loads what is needed, when it is needed:
* catalog.json always, on start (a few KB, fills the browse view)
* index/topics.json.gz the browse screen's "all topics" list (~386 KB)
* index/lex.bin.gz on the first question (~3 MB, the word index)
* index/docs.json with it (docId → collection/book/hadith)
* index/vectors.bin.gz when the meaning model is switched on (~10 MB)
* the model itself before any search — see below
* <collection>/<book>.json the text of the hadiths that actually ranked
*
* The vectors live here and the model lives in a worker (embed-worker.js): the
* worker turns a question into one vector, this file compares it against the
* 20,000 stored ones and fuses the two rankings.
*
* The model is REQUIRED: a search is words + meaning, and meaning needs the
* model. So this file also answers the three questions the page has to ask
* before it may search — what will this device download (plan + exact size),
* is it already on the device (CacheStorage under 'transformers-cache'), and
* how far along is the download now (progress events are per file; the sizes
* below are the denominator that turns them into one honest percentage).
* =========================================================================== */
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.HadithSearchApp = factory();
}
}(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const PATHS = {
catalog: 'HadithData/catalog.json',
topics: 'HadithData/index/topics.json.gz',
lexical: 'HadithData/index/lex.bin.gz',
docs: 'HadithData/index/docs.json',
vectors: 'HadithData/index/vectors.bin.gz',
manifest: 'HadithData/index/manifest.json'
};
/* ----------------------------------------------------------- the model ---
One table, three jobs: the size warning, the progress denominator and the
"already downloaded" check. Sizes are bytes and were read from the host
on 2026-09-18 (huggingface.co/api/models/Xenova/multilingual-e5-small);
changing the model or the quantisation means re-reading them. */
const MODEL_ID = 'Xenova/multilingual-e5-small';
const MODEL_BASE = `https://huggingface.co/${MODEL_ID}/resolve/main/`;
const MODEL_CACHE = 'transformers-cache'; // transformers.js's own cache
const MODEL_FILES = {
shared: [
{ file: 'config.json', size: 658 },
{ file: 'tokenizer.json', size: 17082730 },
{ file: 'tokenizer_config.json', size: 443 }
],
weights: {
webgpu: { file: 'onnx/model_fp16.onnx', size: 235336732 },
wasm: { file: 'onnx/model_quantized.onnx', size: 118308185 }
},
/* onnxruntime-web's binaries and the transformers.js bundle, served from
jsdelivr; the service worker caches them, not transformers.js */
runtime: 6000000
};
function modelFiles(plan) {
return MODEL_FILES.shared.concat([MODEL_FILES.weights[plan] || MODEL_FILES.weights.wasm]);
}
function planBytes(plan) {
return modelFiles(plan).reduce((sum, item) => sum + item.size, MODEL_FILES.runtime);
}
function sizeOf(plan, file) {
const item = modelFiles(plan).find(entry => entry.file === file);
return item ? item.size : 0;
}
/* WebGPU is twice the download (fp16 weights) and much faster per question;
asking the adapter before promising a size keeps the warning true. */
async function planFor() {
if (typeof navigator === 'undefined' || !navigator.gpu) return 'wasm';
try {
const adapter = await navigator.gpu.requestAdapter();
return adapter ? 'webgpu' : 'wasm';
} catch (error) {
return 'wasm';
}
}
/* planFor without the await, for the places that only need a denominator. */
function syncPlan() {
return (typeof navigator !== 'undefined' && navigator.gpu) ? 'webgpu' : 'wasm';
}
async function inspectModel() {
const plan = await planFor();
const files = modelFiles(plan);
let missing = null;
try {
const cache = await caches.open(MODEL_CACHE);
missing = [];
for (const item of files) {
const hit = await cache.match(MODEL_BASE + item.file, { ignoreSearch: true });
if (!hit) missing.push(item.file);
}
} catch (error) {
missing = null; // cache unreadable: assume nothing is stored
}
return {
plan,
files: files.map(item => item.file),
bytes: planBytes(plan),
cached: missing !== null && missing.length === 0,
known: missing !== null,
missing: missing || files.map(item => item.file)
};
}
/* gzip in, bytes out — the index files ship compressed; static hosts do not
always compress a .bin, and 3 MB + 10 MB is worth controlling ourselves. */
async function fetchGzip(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`${url} → HTTP ${response.status}`);
if (typeof DecompressionStream !== 'function') {
throw new Error('this browser cannot decompress the index (DecompressionStream)');
}
const stream = response.body.pipeThrough(new DecompressionStream('gzip'));
return new Response(stream).arrayBuffer();
}
async function fetchJson(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`${url} → HTTP ${response.status}`);
return response.json();
}
function create(options) {
const settings = options || {};
const core = settings.core;
const lexicon = settings.lexicon;
const fusion = settings.fusion;
const books = new Map(); // "bukhari/1" → parsed book file
let catalog = null;
let topicsIndex = null;
let index = null;
let docs = null;
let vectors = null;
let engine = null;
let worker = null;
let workerReady = null;
let onModelEvent = () => { };
const state = {
ready: false,
model: 'off', // 'off' | 'loading' | 'ready' | 'failed'
modelDevice: '',
vectorsLoaded: false
};
/* -------------------------------------------------------- progress ---
transformers.js reports bytes per file, and the big file is always
last. Counting them into one total against the table above is what
makes the bar mean "download", not "whatever is moving now". */
let progress = null;
let lastShare = -1;
function resetProgress(plan) {
progress = { plan, loaded: new Map(), seen: new Map() };
lastShare = -1;
}
function planOf(device) {
return device === 'webgpu' ? 'webgpu' : 'wasm';
}
function noteProgress(message) {
if (!progress) return null;
const file = message.file || 'model';
const loaded = Math.max(message.loaded || 0, progress.loaded.get(file) || 0);
progress.loaded.set(file, loaded);
if (message.total) {
progress.seen.set(file, Math.max(message.total, progress.seen.get(file) || 0));
}
let bytes = 0;
for (const value of progress.loaded.values()) bytes += value;
let total = planBytes(progress.plan);
for (const [name, size] of progress.seen) {
const expected = sizeOf(progress.plan, name);
if (size > expected) total += size - expected; // a file we did not count
}
const share = total ? Math.min(0.99, bytes / total) : 0;
return { file, loaded: bytes, total, share };
}
/* -------------------------------------------------------- loading --- */
async function start() {
if (catalog) return catalog;
catalog = await fetchJson(PATHS.catalog);
state.ready = true;
return catalog;
}
/* Every named chapter of the three collections in one list — built by
tools/build-topics-index.mjs, so the browser never reads 196 book
files just to name them. */
async function loadTopics() {
if (topicsIndex) return topicsIndex;
const bytes = await fetchGzip(PATHS.topics);
topicsIndex = JSON.parse(new TextDecoder().decode(bytes));
return topicsIndex;
}
async function loadLexical() {
if (index) return;
const [lexicalBytes, docsJson] = await Promise.all([
fetchGzip(PATHS.lexical),
fetchJson(PATHS.docs)
]);
index = lexicon.decode(lexicalBytes);
docs = docsJson;
engine = fusion.create({
core,
lexicon,
index,
vectors: null,
embed: async question => {
const vector = await embedQuestion(question);
return vector;
}
});
}
async function loadVectors() {
if (vectors) return;
vectors = lexicon.decodeVectors(await fetchGzip(PATHS.vectors));
state.vectorsLoaded = true;
rebuildEngine();
}
function rebuildEngine() {
engine = fusion.create({
core,
lexicon,
index,
vectors,
embed: vectors ? embedQuestion : null
});
}
/* --------------------------------------------------------- worker --- */
function startWorker(prefer) {
if (worker) return workerReady;
state.model = 'loading';
/* The device in the first 'runtime' message resets this anyway; this
is only so the bar has a denominator from the very first byte. */
resetProgress(prefer === 'webgpu' || prefer === 'wasm' ? prefer : syncPlan());
worker = new Worker('embed-worker.js', { type: 'module' });
workerReady = new Promise((resolve, reject) => {
worker.addEventListener('message', event => {
const message = event.data || {};
if (message.type === 'ready') {
state.model = 'ready';
state.modelDevice = message.device;
onModelEvent(message);
resolve(message);
} else if (message.type === 'progress') {
const counted = noteProgress(message);
if (!counted) return;
/* Hundreds of these arrive per file; only the ones that
move the bar by a fifth of a percent are worth a
repaint. */
if (counted.share - lastShare < 0.002 && counted.share < 0.99) return;
lastShare = counted.share;
onModelEvent({
type: 'progress',
file: counted.file,
loaded: counted.loaded,
total: counted.total,
share: counted.share,
plan: progress.plan
});
} else if (message.type === 'error') {
if (message.id === undefined) {
state.model = 'failed';
reject(new Error(message.message));
}
onModelEvent(message);
} else {
if (message.type === 'status' && message.stage === 'runtime') {
/* A new attempt starts its own byte count (a failed
WebGPU run may have moved to the smaller file). */
resetProgress(message.device ? planOf(message.device) : progress.plan);
}
onModelEvent(message);
}
});
worker.addEventListener('error', error => {
state.model = 'failed';
reject(new Error(error.message || 'the embedding worker failed'));
});
worker.postMessage({ type: 'load', prefer: prefer || 'auto' });
}).catch(error => {
state.model = 'failed';
throw error;
});
return workerReady;
}
let pending = 0;
function embedQuestion(question) {
if (!worker || state.model !== 'ready') return Promise.reject(new Error('model not ready'));
pending += 1;
const id = pending;
return new Promise((resolve, reject) => {
const listener = event => {
const message = event.data || {};
if (message.id !== id) return;
worker.removeEventListener('message', listener);
if (message.type === 'embedding') resolve(message.vector);
else if (message.type === 'error') reject(new Error(message.message));
};
worker.addEventListener('message', listener);
worker.postMessage({ type: 'embed', id, text: question });
});
}
/* Turn the model on. The vectors load first: if they are missing there
is no point downloading a large model for nothing. A previous failed
attempt is torn down here, so trying again really is a new attempt. */
async function enableMeaning(options) {
const prefer = (options && options.prefer) || 'auto';
if (state.model === 'ready' && vectors) return true;
if (state.model === 'failed') {
if (worker) worker.terminate();
worker = null;
workerReady = null;
}
try {
await loadVectors();
await startWorker(prefer);
rebuildEngine();
return true;
} catch (error) {
state.model = 'failed';
onModelEvent({ type: 'error', message: error.message });
return false;
}
}
/* ---------------------------------------------------------- query --- */
async function ask(question, queryOptions) {
const settings2 = queryOptions || {};
await loadLexical();
const outcome = await engine.search(question, {
limit: settings2.limit || 20,
pool: settings2.pool || 60,
semantic: settings2.semantic !== false && state.model === 'ready'
});
const results = [];
for (const entry of outcome.results) {
const hadith = await hadithOf(entry.docId);
if (!hadith) continue;
results.push({
docId: entry.docId,
hadith,
score: entry.score,
lexicalRank: entry.lexicalRank,
vectorRank: entry.vectorRank,
terms: entry.terms,
highlightTerms: entry.highlightTerms || []
});
}
return {
results,
confidence: outcome.confidence,
semantic: outcome.semanticCount > 0
};
}
/* ------------------------------------------------------- documents --- */
async function bookOf(collection, bookNumber) {
const key = `${collection}/${bookNumber}`;
if (!books.has(key)) {
books.set(key, fetchJson(`HadithData/${collection}/${bookNumber}.json`));
}
return books.get(key);
}
function pointerOf(docId) {
const base = docId * 3;
return {
collection: docs.collections[docs.docs[base]],
bookNumber: docs.docs[base + 1],
hadithIndex: docs.docs[base + 2]
};
}
async function hadithOf(docId) {
if (!docs) return null;
const pointer = pointerOf(docId);
const page = await bookOf(pointer.collection, pointer.bookNumber);
const hadith = page.hadiths[pointer.hadithIndex];
if (!hadith) return null;
return Object.assign({}, hadith, {
collection: pointer.collection,
bookNumber: pointer.bookNumber,
book: page.book,
chapter: hadith.ch >= 0 ? page.chapters[hadith.ch] : null,
url: hadith.slug ? `https://sunnah.com/${hadith.slug}` : null
});
}
return {
start,
ask,
hadithOf,
bookOf,
topics: loadTopics,
enableMeaning,
inspectModel,
loadLexical,
loadVectors,
state,
get catalog() { return catalog; },
set onModelEvent(handler) { onModelEvent = handler || (() => { }); },
paths: PATHS
};
}
return { create, PATHS, MODEL_FILES, MODEL_ID };
}));