polite-crawler 0.1.2 → 0.1.3
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 +533 -8
- package/build/cli-daemon--cTmq0Z5.mjs +38 -0
- package/build/cli-daemon--cTmq0Z5.mjs.map +1 -0
- package/build/cli.mjs +288 -20
- package/build/cli.mjs.map +1 -1
- package/build/{open-Ca1lcWR3.d.mts → daemon-DP4EqjFL.d.mts} +913 -16
- package/build/daemon-fZTE24P5.mjs +515 -0
- package/build/daemon-fZTE24P5.mjs.map +1 -0
- package/build/{diff-Dk2eSvi-.mjs → diff-NQcQqKe9.mjs} +2 -2
- package/build/{diff-Dk2eSvi-.mjs.map → diff-NQcQqKe9.mjs.map} +1 -1
- package/build/{extract-DcwNrTK7.mjs → extract-ByFfc8Zk.mjs} +2 -2
- package/build/{extract-DcwNrTK7.mjs.map → extract-ByFfc8Zk.mjs.map} +1 -1
- package/build/hours.d.mts +26 -0
- package/build/hours.mjs +48 -1
- package/build/hours.mjs.map +1 -1
- package/build/index.d.mts +491 -11
- package/build/index.mjs +898 -9
- package/build/index.mjs.map +1 -0
- package/build/loopback-C_QYowSf.mjs +22 -0
- package/build/loopback-C_QYowSf.mjs.map +1 -0
- package/build/mcp.d.mts +158 -2
- package/build/mcp.mjs +567 -18
- package/build/mcp.mjs.map +1 -1
- package/build/ollama-BJxkjny4.mjs +160 -0
- package/build/ollama-BJxkjny4.mjs.map +1 -0
- package/build/{open-DnrWgkC0.mjs → open-C4fhXJgD.mjs} +1404 -21
- package/build/open-C4fhXJgD.mjs.map +1 -0
- package/build/{runtime-k8mo1_7U.mjs → runtime-JToRO63o.mjs} +3 -3
- package/build/{runtime-k8mo1_7U.mjs.map → runtime-JToRO63o.mjs.map} +1 -1
- package/build/transformers-BQMlaGj4.mjs +133 -0
- package/build/transformers-BQMlaGj4.mjs.map +1 -0
- package/build/{duration-iIuMMofi.mjs → version-ntbY-6wh.mjs} +10 -10
- package/build/version-ntbY-6wh.mjs.map +1 -0
- package/build/{wacz-sfOLT8e9.mjs → wacz-BFNG42Kt.mjs} +3 -3
- package/build/{wacz-sfOLT8e9.mjs.map → wacz-BFNG42Kt.mjs.map} +1 -1
- package/build/{warc-VIPscWxY.mjs → warc-CVS0t1fW.mjs} +2 -2
- package/build/{warc-VIPscWxY.mjs.map → warc-CVS0t1fW.mjs.map} +1 -1
- package/package.json +10 -2
- package/build/duration-iIuMMofi.mjs.map +0 -1
- package/build/open-DnrWgkC0.mjs.map +0 -1
|
@@ -2,14 +2,14 @@ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
|
2
2
|
import { n as FileBlobStore, t as CaptureLog } from "./log-Dx-wXjJl.mjs";
|
|
3
3
|
import { r as playwrightRenderer } from "./render-C1CUraWF.mjs";
|
|
4
4
|
import { r as checkedRenderer } from "./browser-CKRNbIDT.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { i as durationMs, n as DEFAULT_TOKEN, r as VERSION, t as BOT_PAGE } from "./version-ntbY-6wh.mjs";
|
|
6
|
+
import { A as snippetOf, C as fallbackReason, E as fold, L as sha256$1, M as Crawler, N as DEADLINE_NOTE, R as stableStringify, S as archivedOf, a as fetchPage, c as isHeldBack, f as readDocument, g as verifyQuote, i as fetchAndIndex, k as parseQuery, m as reindex, n as HELD_BACK, o as findDocuments, p as recordOverride, r as catchUpIndex, s as heldBackMeta, t as Extractors, y as archivedCopy } from "./runtime-JToRO63o.mjs";
|
|
6
7
|
import { MemoryHostStateStore, nextValidatorMemo, realClock } from "./politeness.mjs";
|
|
7
8
|
import { matchesPattern } from "./robots.mjs";
|
|
8
|
-
import { i as VERSION, n as BOT_PAGE, r as DEFAULT_TOKEN, t as durationMs } from "./duration-iIuMMofi.mjs";
|
|
9
9
|
import { n as canonicalUrl, r as scopeTest, t as cacheKeyOf } from "./urls-BHFeSyEK.mjs";
|
|
10
10
|
import { o as decodeBody, t as extractPage } from "./html-DkQUAE_d.mjs";
|
|
11
|
-
import { n as diffDocument, r as diffTexts } from "./diff-
|
|
12
|
-
import { r as extractDocument } from "./extract-
|
|
11
|
+
import { n as diffDocument, r as diffTexts } from "./diff-NQcQqKe9.mjs";
|
|
12
|
+
import { r as extractDocument } from "./extract-ByFfc8Zk.mjs";
|
|
13
13
|
import { createHash, randomUUID } from "node:crypto";
|
|
14
14
|
import { dirname, join } from "node:path";
|
|
15
15
|
import { existsSync, lstatSync, mkdirSync, realpathSync, statSync } from "node:fs";
|
|
@@ -19,10 +19,13 @@ import { DatabaseSync } from "node:sqlite";
|
|
|
19
19
|
const MAX_INDEX_CHARS = 2e5;
|
|
20
20
|
/** In the mode `fast`, a part is common when more than this share of the documents hold it. */
|
|
21
21
|
const FAST_RANK_SHARE = .2;
|
|
22
|
+
/** The text of the error of hybrid search with no embedder. */
|
|
23
|
+
const NO_EMBEDDER = "hybrid search needs an embedder: open the crawler with the option embedder (transformersEmbedder or ollamaEmbedder), or give --embedder transformers or ollama to the command line";
|
|
22
24
|
/** This function gives the rank mode of an option. A value that is not a rank mode throws a `RangeError`. */
|
|
23
25
|
const rankModeOf = (value) => {
|
|
24
26
|
if (value === void 0 || value === "exact") return "exact";
|
|
25
27
|
if (value === "fast") return "fast";
|
|
28
|
+
if (value === "hybrid") throw new RangeError("the index ranks with \"exact\" and \"fast\" only: rank \"hybrid\" needs the vectors of an embedder, so use find or rankWithin of a crawler with the option embedder");
|
|
26
29
|
throw new RangeError(`rank must be "exact" or "fast", not ${JSON.stringify(value) ?? "undefined"}`);
|
|
27
30
|
};
|
|
28
31
|
const namesOf = (generation) => ({
|
|
@@ -539,6 +542,20 @@ var DocIndex = class {
|
|
|
539
542
|
hasVersion(capture) {
|
|
540
543
|
return this.live((n) => this.stmt(`SELECT 1 AS found FROM ${n.versions} WHERE capture = ? LIMIT 1`).get(capture) !== void 0);
|
|
541
544
|
}
|
|
545
|
+
/**
|
|
546
|
+
* This method gives the hash of the current text of documents, with no text. It gives the
|
|
547
|
+
* documents of `docs`, or each document after `after` in the sequence of the ids, to `limit`.
|
|
548
|
+
*/
|
|
549
|
+
digests(options = {}) {
|
|
550
|
+
return this.live((n) => {
|
|
551
|
+
if (options.docs !== void 0) {
|
|
552
|
+
if (options.docs.length === 0) return [];
|
|
553
|
+
return this.stmt(`SELECT doc, kind, ai_input, text_sha FROM ${n.docs} WHERE doc IN (SELECT value FROM json_each(?))`).all(JSON.stringify(options.docs));
|
|
554
|
+
}
|
|
555
|
+
const limit = Math.max(1, Math.floor(options.limit ?? 500));
|
|
556
|
+
return this.stmt(`SELECT doc, kind, ai_input, text_sha FROM ${n.docs} WHERE doc > ? ORDER BY doc LIMIT ?`).all(options.after ?? "", limit);
|
|
557
|
+
});
|
|
558
|
+
}
|
|
542
559
|
/** This method gives the newest fetch time of the cache entries, or undefined for an empty index. */
|
|
543
560
|
newestFetch() {
|
|
544
561
|
return this.live((n) => this.stmt(`SELECT max(fetched_at) AS newest FROM ${n.latest}`).get()?.newest ?? void 0);
|
|
@@ -830,7 +847,7 @@ const XML_ENTITIES = {
|
|
|
830
847
|
};
|
|
831
848
|
/** A tag keeps this number of attributes at most. The reader reads the others and keeps nothing of them. */
|
|
832
849
|
const MAX_ATTRIBUTES = 64;
|
|
833
|
-
const NAME = /^[A-Za-z_][\w.:-]*$/;
|
|
850
|
+
const NAME$1 = /^[A-Za-z_][\w.:-]*$/;
|
|
834
851
|
const W3C_DATE = /^\d{4}(?:-\d{2}(?:-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?)?)?$/;
|
|
835
852
|
const MAIL_DATE = /^(?:[A-Za-z]{3},?\s+)?\d{1,2}\s+[A-Za-z]{3,9}\s+\d{2,4}\s+\d{1,2}:\d{2}(?::\d{2})?(?:\s*(?:[A-Za-z]{1,5}|[+-]\d{4}))?$/;
|
|
836
853
|
const short = (name) => name === void 0 ? "nothing" : name.length > 40 ? `${name.slice(0, 40)}...` : name;
|
|
@@ -903,7 +920,7 @@ const tagAt = (text, from) => {
|
|
|
903
920
|
at += 1;
|
|
904
921
|
}
|
|
905
922
|
const name = text.slice(from, Math.min(at, from + 256));
|
|
906
|
-
if (at - from > 256 || !NAME.test(name)) return void 0;
|
|
923
|
+
if (at - from > 256 || !NAME$1.test(name)) return void 0;
|
|
907
924
|
const attributes = [];
|
|
908
925
|
for (;;) {
|
|
909
926
|
while (at < n && isSpace(text.charCodeAt(at))) at += 1;
|
|
@@ -928,7 +945,7 @@ const tagAt = (text, from) => {
|
|
|
928
945
|
at += 1;
|
|
929
946
|
}
|
|
930
947
|
const key = text.slice(start, Math.min(at, start + 256));
|
|
931
|
-
if (at - start > 256 || !NAME.test(key)) return void 0;
|
|
948
|
+
if (at - start > 256 || !NAME$1.test(key)) return void 0;
|
|
932
949
|
while (at < n && isSpace(text.charCodeAt(at))) at += 1;
|
|
933
950
|
if (text.charCodeAt(at) !== 61) return void 0;
|
|
934
951
|
at += 1;
|
|
@@ -1322,7 +1339,7 @@ const DISCOVER_LIMITS = {
|
|
|
1322
1339
|
/** The most feeds that discovery reads from the links of the page. */
|
|
1323
1340
|
maxLinkedFeeds: 5
|
|
1324
1341
|
};
|
|
1325
|
-
const PAGE_ACCEPT = "text/html,application/xhtml+xml;q=0.9,*/*;q=0.5";
|
|
1342
|
+
const PAGE_ACCEPT$1 = "text/html,application/xhtml+xml;q=0.9,*/*;q=0.5";
|
|
1326
1343
|
const SITEMAP_ACCEPT = "application/xml,text/xml;q=0.9,text/plain;q=0.8,*/*;q=0.5";
|
|
1327
1344
|
const FEED_ACCEPT = "application/rss+xml,application/atom+xml,application/xml;q=0.9,text/xml;q=0.8,*/*;q=0.5";
|
|
1328
1345
|
const FEED_KINDS = /* @__PURE__ */ new Set([
|
|
@@ -1517,7 +1534,7 @@ const discoverSite = async (runtime, site, options = {}) => {
|
|
|
1517
1534
|
url: start.href,
|
|
1518
1535
|
mode: "page",
|
|
1519
1536
|
recipe: "page",
|
|
1520
|
-
accept: PAGE_ACCEPT,
|
|
1537
|
+
accept: PAGE_ACCEPT$1,
|
|
1521
1538
|
maxAgeMs,
|
|
1522
1539
|
onBehalfOf: options.onBehalfOf,
|
|
1523
1540
|
signal,
|
|
@@ -2332,6 +2349,1326 @@ var SqliteHostStateStore = class {
|
|
|
2332
2349
|
}
|
|
2333
2350
|
};
|
|
2334
2351
|
//#endregion
|
|
2352
|
+
//#region src/embed.ts
|
|
2353
|
+
/** These are the limits of the chunks of a text. */
|
|
2354
|
+
const CHUNK_LIMITS = {
|
|
2355
|
+
maxTokens: 512,
|
|
2356
|
+
defaultTokens: 256,
|
|
2357
|
+
fill: .9,
|
|
2358
|
+
overlap: .1,
|
|
2359
|
+
maxChunks: 64
|
|
2360
|
+
};
|
|
2361
|
+
/** These scripts write no space between words, so the estimate counts each character as a token. */
|
|
2362
|
+
const SEGMENTED = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u;
|
|
2363
|
+
const WORD = /[\p{L}\p{N}\p{M}]/u;
|
|
2364
|
+
const SPACE = /\s/u;
|
|
2365
|
+
/** A word of this number of letters or fewer is one token in the estimate. A longer word is more tokens. */
|
|
2366
|
+
const LETTERS_PER_TOKEN = 6;
|
|
2367
|
+
const isAsciiWord = (code) => code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 97 && code <= 122;
|
|
2368
|
+
const isAsciiSpace = (code) => code === 32 || code >= 9 && code <= 13;
|
|
2369
|
+
const kindOf = (point) => {
|
|
2370
|
+
if (point < 128) return isAsciiWord(point) ? "word" : isAsciiSpace(point) ? "space" : "other";
|
|
2371
|
+
const char = String.fromCodePoint(point);
|
|
2372
|
+
if (SEGMENTED.test(char)) return "segmented";
|
|
2373
|
+
if (WORD.test(char)) return "word";
|
|
2374
|
+
return SPACE.test(char) ? "space" : "other";
|
|
2375
|
+
};
|
|
2376
|
+
/**
|
|
2377
|
+
* This function estimates the number of tokens of a part of a text. A word is one token for each
|
|
2378
|
+
* six letters. A Chinese, Japanese or Thai character is one token. Each other character that is
|
|
2379
|
+
* not a space is one token. The estimate is a little high for English text, so a chunk fits.
|
|
2380
|
+
*/
|
|
2381
|
+
const estimateTokens = (text, start = 0, end = text.length) => {
|
|
2382
|
+
let tokens = 0;
|
|
2383
|
+
let run = 0;
|
|
2384
|
+
for (let at = start; at < end;) {
|
|
2385
|
+
const point = text.codePointAt(at);
|
|
2386
|
+
at += point > 65535 ? 2 : 1;
|
|
2387
|
+
const kind = kindOf(point);
|
|
2388
|
+
if (kind === "word") {
|
|
2389
|
+
run += 1;
|
|
2390
|
+
continue;
|
|
2391
|
+
}
|
|
2392
|
+
if (run > 0) tokens += Math.ceil(run / LETTERS_PER_TOKEN);
|
|
2393
|
+
run = 0;
|
|
2394
|
+
if (kind !== "space") tokens += 1;
|
|
2395
|
+
}
|
|
2396
|
+
if (run > 0) tokens += Math.ceil(run / LETTERS_PER_TOKEN);
|
|
2397
|
+
return tokens;
|
|
2398
|
+
};
|
|
2399
|
+
/** These characters end a sentence when a space follows them. */
|
|
2400
|
+
const STOP = /[.!?…]+["'”’)\]]*(?=\s|$)|[。!?]+/gu;
|
|
2401
|
+
/** This function gives the ranges of the sentences of a part of a text, with no space at the ends. */
|
|
2402
|
+
const sentencesOf = (text, start, end) => {
|
|
2403
|
+
const part = text.slice(start, end);
|
|
2404
|
+
const out = [];
|
|
2405
|
+
let from = 0;
|
|
2406
|
+
for (const match of part.matchAll(STOP)) {
|
|
2407
|
+
const to = match.index + match[0].length;
|
|
2408
|
+
if (to > from) out.push([from, to]);
|
|
2409
|
+
from = to;
|
|
2410
|
+
}
|
|
2411
|
+
if (from < part.length) out.push([from, part.length]);
|
|
2412
|
+
return out.map(([a, b]) => {
|
|
2413
|
+
let first = a;
|
|
2414
|
+
let last = b;
|
|
2415
|
+
while (first < last && SPACE.test(part[first])) first += 1;
|
|
2416
|
+
while (last > first && SPACE.test(part[last - 1])) last -= 1;
|
|
2417
|
+
return [start + first, start + last];
|
|
2418
|
+
}).filter(([a, b]) => b > a);
|
|
2419
|
+
};
|
|
2420
|
+
/** This function cuts a part of a text that is longer than the limit. It cuts at the spaces, and in a word that is too long. */
|
|
2421
|
+
const cutLong = (text, start, end, size) => {
|
|
2422
|
+
const out = [];
|
|
2423
|
+
let from = start;
|
|
2424
|
+
let tokens = 0;
|
|
2425
|
+
let run = 0;
|
|
2426
|
+
let lastSpace = -1;
|
|
2427
|
+
let at = start;
|
|
2428
|
+
const emit = (to) => {
|
|
2429
|
+
let first = from;
|
|
2430
|
+
while (first < to && SPACE.test(text[first])) first += 1;
|
|
2431
|
+
let last = to;
|
|
2432
|
+
while (last > first && SPACE.test(text[last - 1])) last -= 1;
|
|
2433
|
+
if (last > first) out.push({
|
|
2434
|
+
start: first,
|
|
2435
|
+
end: last,
|
|
2436
|
+
tokens: estimateTokens(text, first, last),
|
|
2437
|
+
paragraph: false
|
|
2438
|
+
});
|
|
2439
|
+
from = to;
|
|
2440
|
+
tokens = 0;
|
|
2441
|
+
run = 0;
|
|
2442
|
+
lastSpace = -1;
|
|
2443
|
+
};
|
|
2444
|
+
while (at < end) {
|
|
2445
|
+
const point = text.codePointAt(at);
|
|
2446
|
+
const width = point > 65535 ? 2 : 1;
|
|
2447
|
+
const kind = kindOf(point);
|
|
2448
|
+
let cost = 0;
|
|
2449
|
+
if (kind === "word") {
|
|
2450
|
+
run += 1;
|
|
2451
|
+
if (run % LETTERS_PER_TOKEN === 1) cost = 1;
|
|
2452
|
+
} else {
|
|
2453
|
+
run = 0;
|
|
2454
|
+
if (kind !== "space") cost = 1;
|
|
2455
|
+
}
|
|
2456
|
+
if (tokens + cost > size && at > from) {
|
|
2457
|
+
if (lastSpace > from) {
|
|
2458
|
+
const to = lastSpace;
|
|
2459
|
+
emit(to);
|
|
2460
|
+
at = to;
|
|
2461
|
+
continue;
|
|
2462
|
+
}
|
|
2463
|
+
emit(at);
|
|
2464
|
+
}
|
|
2465
|
+
if (kind === "space") lastSpace = at;
|
|
2466
|
+
tokens += cost;
|
|
2467
|
+
at += width;
|
|
2468
|
+
}
|
|
2469
|
+
emit(end);
|
|
2470
|
+
return out;
|
|
2471
|
+
};
|
|
2472
|
+
/** This function gives the pieces of a text: its sentences, in paragraphs, and the parts of a sentence that is longer than the limit. */
|
|
2473
|
+
const piecesOf = (text, size) => {
|
|
2474
|
+
const pieces = [];
|
|
2475
|
+
for (const line of text.matchAll(/[^\r\n]+/g)) {
|
|
2476
|
+
let first = true;
|
|
2477
|
+
for (const [start, end] of sentencesOf(text, line.index, line.index + line[0].length)) {
|
|
2478
|
+
const tokens = estimateTokens(text, start, end);
|
|
2479
|
+
const parts = tokens <= size ? [{
|
|
2480
|
+
start,
|
|
2481
|
+
end,
|
|
2482
|
+
tokens,
|
|
2483
|
+
paragraph: false
|
|
2484
|
+
}] : cutLong(text, start, end, size);
|
|
2485
|
+
for (const part of parts) {
|
|
2486
|
+
pieces.push({
|
|
2487
|
+
...part,
|
|
2488
|
+
paragraph: first
|
|
2489
|
+
});
|
|
2490
|
+
first = false;
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
return pieces;
|
|
2495
|
+
};
|
|
2496
|
+
const positive = (name, value) => {
|
|
2497
|
+
if (!Number.isFinite(value) || value < 1) throw new RangeError(`${name} must be a number of 1 or more, not ${String(value)}`);
|
|
2498
|
+
return Math.floor(value);
|
|
2499
|
+
};
|
|
2500
|
+
/**
|
|
2501
|
+
* This function cuts a text into chunks for an embedder. A chunk is a window of whole sentences.
|
|
2502
|
+
* It ends at the end of a paragraph when the paragraph fills half of the window or more. A
|
|
2503
|
+
* sentence that is longer than the window is cut at its spaces. Two chunks next to each other share
|
|
2504
|
+
* a few sentences (`overlapTokens`), so a passage at a cut is in one chunk. Each chunk keeps its
|
|
2505
|
+
* position in the text, so a hit points at a passage.
|
|
2506
|
+
*/
|
|
2507
|
+
const chunkText = (text, options) => {
|
|
2508
|
+
const size = positive("maxTokens", options.maxTokens);
|
|
2509
|
+
const overlap = Math.min(Math.floor(size / 2), Math.max(0, Math.floor(options.overlapTokens ?? size * CHUNK_LIMITS.overlap)));
|
|
2510
|
+
const maxChunks = positive("maxChunks", options.maxChunks ?? CHUNK_LIMITS.maxChunks);
|
|
2511
|
+
const pieces = piecesOf(text, size);
|
|
2512
|
+
const chunks = [];
|
|
2513
|
+
let first = 0;
|
|
2514
|
+
let fresh = 0;
|
|
2515
|
+
while (first < pieces.length && chunks.length < maxChunks) {
|
|
2516
|
+
let last = first;
|
|
2517
|
+
let tokens = pieces[first].tokens;
|
|
2518
|
+
while (last + 1 < pieces.length && tokens + pieces[last + 1].tokens <= size) {
|
|
2519
|
+
last += 1;
|
|
2520
|
+
tokens += pieces[last].tokens;
|
|
2521
|
+
}
|
|
2522
|
+
if (last + 1 < pieces.length && !pieces[last + 1].paragraph) {
|
|
2523
|
+
let filled = 0;
|
|
2524
|
+
let cut = -1;
|
|
2525
|
+
for (let at = first; at < last; at += 1) {
|
|
2526
|
+
filled += pieces[at].tokens;
|
|
2527
|
+
if (at >= fresh && pieces[at + 1].paragraph && filled * 2 >= size) cut = at;
|
|
2528
|
+
}
|
|
2529
|
+
if (cut !== -1) last = cut;
|
|
2530
|
+
}
|
|
2531
|
+
fresh = last + 1;
|
|
2532
|
+
const start = pieces[first].start;
|
|
2533
|
+
const end = pieces[last].end;
|
|
2534
|
+
chunks.push({
|
|
2535
|
+
index: chunks.length,
|
|
2536
|
+
start,
|
|
2537
|
+
end,
|
|
2538
|
+
text: text.slice(start, end)
|
|
2539
|
+
});
|
|
2540
|
+
if (last + 1 >= pieces.length) break;
|
|
2541
|
+
let next = last + 1;
|
|
2542
|
+
let shared = 0;
|
|
2543
|
+
while (next - 1 > first && shared + pieces[next - 1].tokens <= overlap) {
|
|
2544
|
+
next -= 1;
|
|
2545
|
+
shared += pieces[next].tokens;
|
|
2546
|
+
}
|
|
2547
|
+
if (shared + pieces[last + 1].tokens > size) next = last + 1;
|
|
2548
|
+
first = next;
|
|
2549
|
+
}
|
|
2550
|
+
return chunks;
|
|
2551
|
+
};
|
|
2552
|
+
/**
|
|
2553
|
+
* This function gives the number of tokens of a chunk for an embedder. It is the input limit with a
|
|
2554
|
+
* margin for the estimate, and `CHUNK_LIMITS.maxTokens` at most.
|
|
2555
|
+
*/
|
|
2556
|
+
const chunkTokensOf = (embedder) => {
|
|
2557
|
+
const limit = embedder.maxInputTokens;
|
|
2558
|
+
const tokens = limit === void 0 || !Number.isFinite(limit) || limit < 1 ? CHUNK_LIMITS.defaultTokens : Math.min(limit, CHUNK_LIMITS.maxTokens);
|
|
2559
|
+
return Math.max(8, Math.floor(tokens * CHUNK_LIMITS.fill));
|
|
2560
|
+
};
|
|
2561
|
+
/** This function gives a copy of a vector with a length of 1. A vector of zeros stays zeros. A value that is not finite throws a `RangeError`. */
|
|
2562
|
+
const unitVector = (vector) => {
|
|
2563
|
+
const out = Float32Array.from(vector);
|
|
2564
|
+
let sum = 0;
|
|
2565
|
+
for (const value of out) {
|
|
2566
|
+
if (!Number.isFinite(value)) throw new RangeError("the embedder gave a value that is not finite");
|
|
2567
|
+
sum += value * value;
|
|
2568
|
+
}
|
|
2569
|
+
if (sum === 0) return out;
|
|
2570
|
+
const scale = 1 / Math.sqrt(sum);
|
|
2571
|
+
for (let at = 0; at < out.length; at += 1) out[at] = out[at] * scale;
|
|
2572
|
+
return out;
|
|
2573
|
+
};
|
|
2574
|
+
/** These are the defaults of a hybrid search. */
|
|
2575
|
+
const HYBRID_DEFAULTS = {
|
|
2576
|
+
candidates: 50,
|
|
2577
|
+
neighbors: 50,
|
|
2578
|
+
fusionK: 60
|
|
2579
|
+
};
|
|
2580
|
+
/**
|
|
2581
|
+
* This function fuses ranked lists with reciprocal rank fusion. Each item gets the sum of
|
|
2582
|
+
* 1 / (k + rank) over the lists that hold it, with the rank from 1. The best item is first. A tie goes to
|
|
2583
|
+
* the item with the best rank in a list, then to the earlier list, then to the smaller id. An item
|
|
2584
|
+
* that is two times in one list counts at its first place.
|
|
2585
|
+
*/
|
|
2586
|
+
const fuseRanks = (lists, k = HYBRID_DEFAULTS.fusionK) => {
|
|
2587
|
+
if (!Number.isFinite(k) || k < 0) throw new RangeError(`the fusion constant k must be a number of 0 or more, not ${String(k)}`);
|
|
2588
|
+
const ranks = /* @__PURE__ */ new Map();
|
|
2589
|
+
lists.forEach((list, which) => {
|
|
2590
|
+
list.forEach((id, at) => {
|
|
2591
|
+
let entry = ranks.get(id);
|
|
2592
|
+
if (entry === void 0) {
|
|
2593
|
+
entry = lists.map(() => null);
|
|
2594
|
+
ranks.set(id, entry);
|
|
2595
|
+
}
|
|
2596
|
+
entry[which] ??= at + 1;
|
|
2597
|
+
});
|
|
2598
|
+
});
|
|
2599
|
+
const keyed = [...ranks].map(([id, entry]) => {
|
|
2600
|
+
let score = 0;
|
|
2601
|
+
let best = Number.POSITIVE_INFINITY;
|
|
2602
|
+
for (const rank of entry) {
|
|
2603
|
+
if (rank === null) continue;
|
|
2604
|
+
score += 1 / (k + rank);
|
|
2605
|
+
best = Math.min(best, rank);
|
|
2606
|
+
}
|
|
2607
|
+
return {
|
|
2608
|
+
id,
|
|
2609
|
+
score,
|
|
2610
|
+
ranks: entry,
|
|
2611
|
+
best,
|
|
2612
|
+
first: entry.findIndex((rank) => rank !== null)
|
|
2613
|
+
};
|
|
2614
|
+
});
|
|
2615
|
+
keyed.sort((a, b) => b.score - a.score || a.best - b.best || a.first - b.first || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
2616
|
+
return keyed.map(({ id, score, ranks: entry }) => ({
|
|
2617
|
+
id,
|
|
2618
|
+
score,
|
|
2619
|
+
ranks: entry
|
|
2620
|
+
}));
|
|
2621
|
+
};
|
|
2622
|
+
//#endregion
|
|
2623
|
+
//#region src/hybrid.ts
|
|
2624
|
+
/** The embedder gets this number of texts in one call at most. */
|
|
2625
|
+
const BATCH = 32;
|
|
2626
|
+
/** The search keeps the vectors of this number of questions. */
|
|
2627
|
+
const QUERY_CACHE = 32;
|
|
2628
|
+
/** The search checks this number of texts of the vector search against the index at one time. */
|
|
2629
|
+
const PAGE = 64;
|
|
2630
|
+
const sha256 = (text) => createHash("sha256").update(text, "utf8").digest("hex");
|
|
2631
|
+
const count = (name, value, fallback, max) => {
|
|
2632
|
+
if (value === void 0) return fallback;
|
|
2633
|
+
if (!Number.isFinite(value) || value < 1) throw new RangeError(`${name} must be a number of 1 or more, not ${String(value)}`);
|
|
2634
|
+
return Math.min(max, Math.floor(value));
|
|
2635
|
+
};
|
|
2636
|
+
/** This function checks the constant k of reciprocal rank fusion. */
|
|
2637
|
+
const fusionKOf = (value) => {
|
|
2638
|
+
if (value === void 0) return HYBRID_DEFAULTS.fusionK;
|
|
2639
|
+
if (!Number.isFinite(value) || value < 0) throw new RangeError(`fusionK must be a number of 0 or more, not ${String(value)}`);
|
|
2640
|
+
return value;
|
|
2641
|
+
};
|
|
2642
|
+
/** This function gives a promise with its resolve and its reject. A rejection with no listener is not an error of the process. */
|
|
2643
|
+
const deferred = () => {
|
|
2644
|
+
let resolve = () => void 0;
|
|
2645
|
+
let reject = () => void 0;
|
|
2646
|
+
const promise = new Promise((yes, no) => {
|
|
2647
|
+
resolve = yes;
|
|
2648
|
+
reject = no;
|
|
2649
|
+
});
|
|
2650
|
+
promise.catch(() => void 0);
|
|
2651
|
+
return {
|
|
2652
|
+
promise,
|
|
2653
|
+
resolve,
|
|
2654
|
+
reject
|
|
2655
|
+
};
|
|
2656
|
+
};
|
|
2657
|
+
const round = (value, digits) => {
|
|
2658
|
+
const scale = 10 ** digits;
|
|
2659
|
+
return Math.round(value * scale) / scale;
|
|
2660
|
+
};
|
|
2661
|
+
/** This function makes a hybrid search over a runtime. The vectors go into the store, with the id of the embedder. */
|
|
2662
|
+
const createHybridSearch = (runtime, options) => {
|
|
2663
|
+
const { embedder, store } = options;
|
|
2664
|
+
const tokens = chunkTokensOf(embedder);
|
|
2665
|
+
const plan = `chunks-1/${String(tokens)}/${String(Math.floor(tokens * CHUNK_LIMITS.overlap))}/${String(CHUNK_LIMITS.maxChunks)}`;
|
|
2666
|
+
const working = /* @__PURE__ */ new Map();
|
|
2667
|
+
const queries = /* @__PURE__ */ new Map();
|
|
2668
|
+
let dimensions = embedder.dimensions > 0 ? embedder.dimensions : store.dimensions(embedder.id);
|
|
2669
|
+
/** This function gives the heads of documents, from `digests` when the index has it. */
|
|
2670
|
+
const headsOf = (docs) => {
|
|
2671
|
+
const heads = /* @__PURE__ */ new Map();
|
|
2672
|
+
if (docs.length === 0) return heads;
|
|
2673
|
+
const index = runtime.index;
|
|
2674
|
+
if (index.digests !== void 0) {
|
|
2675
|
+
for (const head of index.digests({ docs })) heads.set(head.doc, head);
|
|
2676
|
+
return heads;
|
|
2677
|
+
}
|
|
2678
|
+
for (const doc of docs) {
|
|
2679
|
+
const found = index.get(doc);
|
|
2680
|
+
if (found !== void 0) heads.set(doc, headOfDoc(found));
|
|
2681
|
+
}
|
|
2682
|
+
return heads;
|
|
2683
|
+
};
|
|
2684
|
+
const headOfDoc = (doc) => ({
|
|
2685
|
+
doc: doc.doc,
|
|
2686
|
+
kind: doc.kind,
|
|
2687
|
+
ai_input: doc.ai_input,
|
|
2688
|
+
text_sha: sha256(doc.text)
|
|
2689
|
+
});
|
|
2690
|
+
/** This function asks the embedder for the vectors of texts, in batches, and checks them. */
|
|
2691
|
+
const vectorsOf = async (texts, purpose, signal) => {
|
|
2692
|
+
const out = [];
|
|
2693
|
+
for (let at = 0; at < texts.length; at += BATCH) {
|
|
2694
|
+
signal?.throwIfAborted();
|
|
2695
|
+
const part = texts.slice(at, at + BATCH);
|
|
2696
|
+
const given = await embedder.embed(part, {
|
|
2697
|
+
signal,
|
|
2698
|
+
purpose
|
|
2699
|
+
});
|
|
2700
|
+
if (!Array.isArray(given) || given.length !== part.length) throw new Error(`the embedder ${embedder.id} gave ${String(Array.isArray(given) ? given.length : 0)} vectors for ${String(part.length)} texts`);
|
|
2701
|
+
for (const vector of given) {
|
|
2702
|
+
const unit = unitVector(vector);
|
|
2703
|
+
dimensions ??= unit.length;
|
|
2704
|
+
if (unit.length !== dimensions || unit.length === 0) throw new Error(`the embedder ${embedder.id} gave a vector of ${String(unit.length)} dimensions, not ${String(dimensions)}`);
|
|
2705
|
+
out.push(unit);
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
return out;
|
|
2709
|
+
};
|
|
2710
|
+
const queryVector = async (q, signal) => {
|
|
2711
|
+
const text = q.replace(/["*]/g, " ").replace(/\s+/g, " ").trim();
|
|
2712
|
+
const known = queries.get(text);
|
|
2713
|
+
if (known !== void 0) {
|
|
2714
|
+
queries.delete(text);
|
|
2715
|
+
queries.set(text, known);
|
|
2716
|
+
return known;
|
|
2717
|
+
}
|
|
2718
|
+
const [vector] = await vectorsOf([text], "query", signal);
|
|
2719
|
+
queries.set(text, vector);
|
|
2720
|
+
if (queries.size > QUERY_CACHE) queries.delete(queries.keys().next().value);
|
|
2721
|
+
return vector;
|
|
2722
|
+
};
|
|
2723
|
+
/**
|
|
2724
|
+
* This function gives vectors to the texts of documents that have none, and points each document
|
|
2725
|
+
* at its current text. It reads each text from the index, and it does not send the text
|
|
2726
|
+
* of a held-back document. Two calls for one text share the work. It gives the number of new vectors.
|
|
2727
|
+
*/
|
|
2728
|
+
const ensure = async (docs, signal) => {
|
|
2729
|
+
const tally = {
|
|
2730
|
+
embedded: 0,
|
|
2731
|
+
reused: 0,
|
|
2732
|
+
held: 0,
|
|
2733
|
+
chunks: 0
|
|
2734
|
+
};
|
|
2735
|
+
const now = runtime.now();
|
|
2736
|
+
const heads = headsOf(docs);
|
|
2737
|
+
const waiting = /* @__PURE__ */ new Map();
|
|
2738
|
+
const shared = [];
|
|
2739
|
+
for (const id of docs) {
|
|
2740
|
+
const head = heads.get(id);
|
|
2741
|
+
if (head === void 0) continue;
|
|
2742
|
+
if (isHeldBack(head)) {
|
|
2743
|
+
tally.held += 1;
|
|
2744
|
+
if (store.pointed(embedder.id, id) !== void 0) store.forget(embedder.id, id);
|
|
2745
|
+
continue;
|
|
2746
|
+
}
|
|
2747
|
+
if (store.plan(embedder.id, head.text_sha) === plan) {
|
|
2748
|
+
tally.reused += 1;
|
|
2749
|
+
if (store.pointed(embedder.id, id) !== head.text_sha) store.point(embedder.id, id, head.text_sha, now);
|
|
2750
|
+
continue;
|
|
2751
|
+
}
|
|
2752
|
+
const doc = runtime.index.get(id);
|
|
2753
|
+
if (doc === void 0 || isHeldBack(doc)) continue;
|
|
2754
|
+
const sha = sha256(doc.text);
|
|
2755
|
+
const entry = waiting.get(sha);
|
|
2756
|
+
if (entry !== void 0) {
|
|
2757
|
+
entry.docs.push(id);
|
|
2758
|
+
continue;
|
|
2759
|
+
}
|
|
2760
|
+
const busy = working.get(sha);
|
|
2761
|
+
if (busy !== void 0) {
|
|
2762
|
+
shared.push(busy.then((made) => {
|
|
2763
|
+
store.point(embedder.id, id, sha, now);
|
|
2764
|
+
return made;
|
|
2765
|
+
}));
|
|
2766
|
+
continue;
|
|
2767
|
+
}
|
|
2768
|
+
waiting.set(sha, {
|
|
2769
|
+
text: doc.text,
|
|
2770
|
+
docs: [id]
|
|
2771
|
+
});
|
|
2772
|
+
}
|
|
2773
|
+
const jobs = [...waiting].map(([sha, entry]) => {
|
|
2774
|
+
const job = {
|
|
2775
|
+
sha,
|
|
2776
|
+
entry,
|
|
2777
|
+
chunks: chunkText(entry.text, { maxTokens: tokens }),
|
|
2778
|
+
...deferred()
|
|
2779
|
+
};
|
|
2780
|
+
working.set(sha, job.promise);
|
|
2781
|
+
return job;
|
|
2782
|
+
});
|
|
2783
|
+
try {
|
|
2784
|
+
for (let at = 0; at < jobs.length;) {
|
|
2785
|
+
const batch = [];
|
|
2786
|
+
let size = 0;
|
|
2787
|
+
while (at < jobs.length && (batch.length === 0 || size + jobs[at].chunks.length <= BATCH)) {
|
|
2788
|
+
batch.push(jobs[at]);
|
|
2789
|
+
size += jobs[at].chunks.length;
|
|
2790
|
+
at += 1;
|
|
2791
|
+
}
|
|
2792
|
+
const vectors = await vectorsOf(batch.flatMap((job) => job.chunks.map((chunk) => chunk.text)), "document", signal);
|
|
2793
|
+
let next = 0;
|
|
2794
|
+
for (const job of batch) {
|
|
2795
|
+
const chunks = job.chunks.map((chunk) => ({
|
|
2796
|
+
start: chunk.start,
|
|
2797
|
+
end: chunk.end,
|
|
2798
|
+
vector: vectors[next++]
|
|
2799
|
+
}));
|
|
2800
|
+
store.put({
|
|
2801
|
+
embedder: embedder.id,
|
|
2802
|
+
dimensions: dimensions ?? chunks[0]?.vector.length ?? 0,
|
|
2803
|
+
sha: job.sha,
|
|
2804
|
+
plan,
|
|
2805
|
+
chars: job.entry.text.length,
|
|
2806
|
+
chunks,
|
|
2807
|
+
docs: job.entry.docs,
|
|
2808
|
+
at: now
|
|
2809
|
+
});
|
|
2810
|
+
tally.embedded += 1;
|
|
2811
|
+
tally.chunks += chunks.length;
|
|
2812
|
+
working.delete(job.sha);
|
|
2813
|
+
job.resolve(chunks.length);
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
} catch (error) {
|
|
2817
|
+
for (const job of jobs) {
|
|
2818
|
+
if (working.get(job.sha) === job.promise) working.delete(job.sha);
|
|
2819
|
+
job.reject(error);
|
|
2820
|
+
}
|
|
2821
|
+
throw error;
|
|
2822
|
+
}
|
|
2823
|
+
await Promise.all(shared);
|
|
2824
|
+
return tally;
|
|
2825
|
+
};
|
|
2826
|
+
/**
|
|
2827
|
+
* This function gives the documents of the vector search, the best first, to `limit`. It checks
|
|
2828
|
+
* each document against the index. Its current text must be the text of the hit, it must not be
|
|
2829
|
+
* held back, and it must have the kind. With `within`, it ranks only those documents.
|
|
2830
|
+
*/
|
|
2831
|
+
const neighborsOf = (query, limit, kind, within) => {
|
|
2832
|
+
const hits = store.search(embedder.id, query, within === void 0 ? {} : { within });
|
|
2833
|
+
const out = [];
|
|
2834
|
+
for (let at = 0; at < hits.length && out.length < limit; at += PAGE) {
|
|
2835
|
+
const page = hits.slice(at, at + PAGE);
|
|
2836
|
+
const docs = [...new Set(page.flatMap((hit) => within === void 0 ? hit.docs : hit.docs.filter((doc) => within.has(doc))))];
|
|
2837
|
+
const heads = headsOf(docs);
|
|
2838
|
+
for (const hit of page) for (const doc of hit.docs) {
|
|
2839
|
+
if (out.length >= limit) break;
|
|
2840
|
+
if (within !== void 0 && !within.has(doc)) continue;
|
|
2841
|
+
const head = heads.get(doc);
|
|
2842
|
+
if (head === void 0 || head.text_sha !== hit.sha || isHeldBack(head)) continue;
|
|
2843
|
+
if (kind !== void 0 && kind !== null && head.kind !== kind) continue;
|
|
2844
|
+
out.push({
|
|
2845
|
+
doc,
|
|
2846
|
+
hit
|
|
2847
|
+
});
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
return out;
|
|
2851
|
+
};
|
|
2852
|
+
const passageOf = (hit) => ({
|
|
2853
|
+
start: hit.start,
|
|
2854
|
+
end: hit.end,
|
|
2855
|
+
similarity: round(hit.similarity, 4)
|
|
2856
|
+
});
|
|
2857
|
+
return {
|
|
2858
|
+
embedder,
|
|
2859
|
+
async find(q, findOptions = {}) {
|
|
2860
|
+
const query = parseQuery(q);
|
|
2861
|
+
if (query === void 0) return [];
|
|
2862
|
+
const limit = Math.max(1, Math.min(1e3, Math.floor(findOptions.limit ?? 10) || 10));
|
|
2863
|
+
const candidates = count("candidates", findOptions.candidates, HYBRID_DEFAULTS.candidates, 1e3);
|
|
2864
|
+
const neighbors = count("neighbors", findOptions.neighbors, HYBRID_DEFAULTS.neighbors, 1e3);
|
|
2865
|
+
const k = fusionKOf(findOptions.fusionK);
|
|
2866
|
+
const signal = findOptions.signal;
|
|
2867
|
+
const vector = await queryVector(q, signal);
|
|
2868
|
+
const kind = findOptions.kind ?? null;
|
|
2869
|
+
const lexical = runtime.index.find(q, {
|
|
2870
|
+
limit: candidates,
|
|
2871
|
+
kind,
|
|
2872
|
+
rank: "exact"
|
|
2873
|
+
});
|
|
2874
|
+
await ensure(lexical.map((hit) => hit.doc), signal);
|
|
2875
|
+
const near = neighborsOf(vector, neighbors, kind, void 0);
|
|
2876
|
+
const fused = fuseRanks([lexical.map((hit) => hit.doc), near.map((one) => one.doc)], k).slice(0, limit);
|
|
2877
|
+
const byDoc = new Map(lexical.map((hit) => [hit.doc, hit]));
|
|
2878
|
+
const nearBy = new Map(near.map((one) => [one.doc, one.hit]));
|
|
2879
|
+
const results = [];
|
|
2880
|
+
for (const one of fused) {
|
|
2881
|
+
const doc = runtime.index.get(one.id);
|
|
2882
|
+
if (doc === void 0 || isHeldBack(doc)) continue;
|
|
2883
|
+
const best = nearBy.get(one.id) ?? store.best(embedder.id, sha256(doc.text), vector);
|
|
2884
|
+
const lexicalHit = byDoc.get(one.id);
|
|
2885
|
+
const archived = doc.kind === "archived" ? archivedOf(doc.meta) : void 0;
|
|
2886
|
+
results.push({
|
|
2887
|
+
doc: doc.doc,
|
|
2888
|
+
kind: doc.kind,
|
|
2889
|
+
title: doc.title,
|
|
2890
|
+
url: doc.url,
|
|
2891
|
+
snippet: best === void 0 ? lexicalHit?.snippet ?? snippetOf(doc.text, query.terms) : snippetOf(doc.text.slice(best.start, best.end), query.terms),
|
|
2892
|
+
score: round(one.score, 6),
|
|
2893
|
+
ai_input: doc.ai_input,
|
|
2894
|
+
fetched_at: doc.fetched_at,
|
|
2895
|
+
capture: doc.capture,
|
|
2896
|
+
...archived === void 0 ? {} : { archived },
|
|
2897
|
+
...best === void 0 ? {} : { passage: passageOf(best) }
|
|
2898
|
+
});
|
|
2899
|
+
}
|
|
2900
|
+
return results;
|
|
2901
|
+
},
|
|
2902
|
+
async rankWithin(q, docs, rankOptions = {}) {
|
|
2903
|
+
if (parseQuery(q) === void 0 || docs.length === 0) return [];
|
|
2904
|
+
const candidates = count("candidates", rankOptions.candidates, HYBRID_DEFAULTS.candidates, 1e3);
|
|
2905
|
+
const neighbors = count("neighbors", rankOptions.neighbors, HYBRID_DEFAULTS.neighbors, 1e5);
|
|
2906
|
+
const k = fusionKOf(rankOptions.fusionK);
|
|
2907
|
+
const signal = rankOptions.signal;
|
|
2908
|
+
const vector = await queryVector(q, signal);
|
|
2909
|
+
const lexical = runtime.index.rankWithin(q, docs, { rank: "exact" });
|
|
2910
|
+
const first = [.../* @__PURE__ */ new Set([...lexical.map((hit) => hit.doc), ...docs])].slice(0, candidates);
|
|
2911
|
+
await ensure(first, signal);
|
|
2912
|
+
const within = new Set(docs);
|
|
2913
|
+
const near = neighborsOf(vector, neighbors, void 0, within);
|
|
2914
|
+
const nearBy = new Map(near.map((one) => [one.doc, one.hit]));
|
|
2915
|
+
const heads = headsOf(lexical.map((hit) => hit.doc).filter((doc) => !nearBy.has(doc)));
|
|
2916
|
+
return fuseRanks([lexical.map((hit) => hit.doc), near.map((one) => one.doc)], k).map((one) => {
|
|
2917
|
+
const sha = heads.get(one.id)?.text_sha;
|
|
2918
|
+
const best = nearBy.get(one.id) ?? (sha === void 0 ? void 0 : store.best(embedder.id, sha, vector));
|
|
2919
|
+
return {
|
|
2920
|
+
doc: one.id,
|
|
2921
|
+
score: round(one.score, 6),
|
|
2922
|
+
...best === void 0 ? {} : { passage: passageOf(best) }
|
|
2923
|
+
};
|
|
2924
|
+
});
|
|
2925
|
+
},
|
|
2926
|
+
async embed(embedOptions = {}) {
|
|
2927
|
+
const started = Date.now();
|
|
2928
|
+
const budgetMs = durationMs(embedOptions.budget ?? "10m");
|
|
2929
|
+
const maxDocs = embedOptions.maxDocs === void 0 ? Number.POSITIVE_INFINITY : count("maxDocs", embedOptions.maxDocs, 1, Number.MAX_SAFE_INTEGER);
|
|
2930
|
+
const kind = embedOptions.kind ?? null;
|
|
2931
|
+
const timer = new AbortController();
|
|
2932
|
+
const clock = setTimeout(() => timer.abort(/* @__PURE__ */ new Error("the budget of embed is over")), Math.min(budgetMs, 2147483647));
|
|
2933
|
+
clock.unref();
|
|
2934
|
+
const signal = embedOptions.signal === void 0 ? timer.signal : AbortSignal.any([embedOptions.signal, timer.signal]);
|
|
2935
|
+
const tally = {
|
|
2936
|
+
docs: 0,
|
|
2937
|
+
embedded: 0,
|
|
2938
|
+
reused: 0,
|
|
2939
|
+
held_back: 0,
|
|
2940
|
+
chunks: 0
|
|
2941
|
+
};
|
|
2942
|
+
let stopped = "done";
|
|
2943
|
+
const stopOf = () => embedOptions.signal?.aborted === true ? "signal" : timer.signal.aborted || Date.now() - started >= budgetMs ? "budget" : tally.embedded >= maxDocs ? "max-docs" : void 0;
|
|
2944
|
+
try {
|
|
2945
|
+
for await (const page of pagesOf(runtime)) {
|
|
2946
|
+
const docs = page.filter((head) => kind === null || head.kind === kind);
|
|
2947
|
+
for (let at = 0; at < docs.length;) {
|
|
2948
|
+
const stop = stopOf();
|
|
2949
|
+
if (stop !== void 0) {
|
|
2950
|
+
stopped = stop;
|
|
2951
|
+
break;
|
|
2952
|
+
}
|
|
2953
|
+
const room = Math.max(1, Math.min(BATCH, maxDocs - tally.embedded));
|
|
2954
|
+
const group = docs.slice(at, at + room).map((head) => head.doc);
|
|
2955
|
+
at += group.length;
|
|
2956
|
+
try {
|
|
2957
|
+
const done = await ensure(group, signal);
|
|
2958
|
+
tally.docs += group.length;
|
|
2959
|
+
tally.embedded += done.embedded;
|
|
2960
|
+
tally.reused += done.reused;
|
|
2961
|
+
tally.held_back += done.held;
|
|
2962
|
+
tally.chunks += done.chunks;
|
|
2963
|
+
} catch (error) {
|
|
2964
|
+
const stop = stopOf();
|
|
2965
|
+
if (stop === void 0) throw error;
|
|
2966
|
+
stopped = stop;
|
|
2967
|
+
break;
|
|
2968
|
+
}
|
|
2969
|
+
}
|
|
2970
|
+
if (stopped !== "done") break;
|
|
2971
|
+
}
|
|
2972
|
+
} finally {
|
|
2973
|
+
clearTimeout(clock);
|
|
2974
|
+
}
|
|
2975
|
+
return {
|
|
2976
|
+
embedder: embedder.id,
|
|
2977
|
+
...tally,
|
|
2978
|
+
stopped,
|
|
2979
|
+
ms: Date.now() - started
|
|
2980
|
+
};
|
|
2981
|
+
}
|
|
2982
|
+
};
|
|
2983
|
+
};
|
|
2984
|
+
/**
|
|
2985
|
+
* This function gives the heads of each stored document, in pages. It uses `digests` when the
|
|
2986
|
+
* index has it. Else it reads the capture log and asks the index for the documents of each capture.
|
|
2987
|
+
*/
|
|
2988
|
+
async function* pagesOf(runtime) {
|
|
2989
|
+
const index = runtime.index;
|
|
2990
|
+
if (index.digests !== void 0) {
|
|
2991
|
+
let after = "";
|
|
2992
|
+
for (;;) {
|
|
2993
|
+
const page = index.digests({
|
|
2994
|
+
after,
|
|
2995
|
+
limit: 500
|
|
2996
|
+
});
|
|
2997
|
+
if (page.length === 0) return;
|
|
2998
|
+
yield page;
|
|
2999
|
+
after = page.at(-1).doc;
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3003
|
+
let page = [];
|
|
3004
|
+
for await (const envelope of runtime.captures.scan({ source: "fetch" })) for (const doc of index.docsOfCapture(envelope.id)) {
|
|
3005
|
+
if (seen.has(doc.doc)) continue;
|
|
3006
|
+
seen.add(doc.doc);
|
|
3007
|
+
page.push({
|
|
3008
|
+
doc: doc.doc,
|
|
3009
|
+
kind: doc.kind,
|
|
3010
|
+
ai_input: doc.ai_input,
|
|
3011
|
+
text_sha: sha256(doc.text)
|
|
3012
|
+
});
|
|
3013
|
+
if (page.length >= 500) {
|
|
3014
|
+
yield page;
|
|
3015
|
+
page = [];
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
if (page.length > 0) yield page;
|
|
3019
|
+
}
|
|
3020
|
+
//#endregion
|
|
3021
|
+
//#region src/recipe.ts
|
|
3022
|
+
/**
|
|
3023
|
+
* This function reads a body as JSON, in the encoding that the response declares. A body that is
|
|
3024
|
+
* not JSON (a page about maintenance, or a login page) throws an error that names the URL and the
|
|
3025
|
+
* content type. In an extractor, the runtime then reports that error.
|
|
3026
|
+
*/
|
|
3027
|
+
const readJson = (body, capture) => {
|
|
3028
|
+
const text = decodeBody(body, capture.contentType);
|
|
3029
|
+
try {
|
|
3030
|
+
return JSON.parse(text);
|
|
3031
|
+
} catch {
|
|
3032
|
+
throw new Error(`${capture.url} answered ${capture.contentType ?? "a body of no stated type"}, not JSON`);
|
|
3033
|
+
}
|
|
3034
|
+
};
|
|
3035
|
+
/**
|
|
3036
|
+
* These recipe names belong to the crawler: the page reader, the archived fallback, robots.txt,
|
|
3037
|
+
* and the sitemaps and feeds of `discover`. A recipe cannot use them.
|
|
3038
|
+
*/
|
|
3039
|
+
const RESERVED_RECIPES = [
|
|
3040
|
+
"page",
|
|
3041
|
+
"archive-copy",
|
|
3042
|
+
"archive-index",
|
|
3043
|
+
"robots.txt",
|
|
3044
|
+
"sitemap",
|
|
3045
|
+
"feed"
|
|
3046
|
+
];
|
|
3047
|
+
/** These are the limits of a recipe. */
|
|
3048
|
+
const RECIPE_LIMITS = {
|
|
3049
|
+
nameChars: 64,
|
|
3050
|
+
docIdChars: 2048
|
|
3051
|
+
};
|
|
3052
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
|
|
3053
|
+
const PAGE_ACCEPT = "text/html,application/xhtml+xml;q=0.9,*/*;q=0.5";
|
|
3054
|
+
const API_ACCEPT = "application/json";
|
|
3055
|
+
const AI_INPUTS = [
|
|
3056
|
+
"yes",
|
|
3057
|
+
"no",
|
|
3058
|
+
"unset"
|
|
3059
|
+
];
|
|
3060
|
+
/** This function tells what is wrong with one document of an extractor, or gives undefined. */
|
|
3061
|
+
const faultOf = (value, seen) => {
|
|
3062
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return "a document must be an object";
|
|
3063
|
+
const doc = value;
|
|
3064
|
+
const id = doc["doc"];
|
|
3065
|
+
if (typeof id !== "string" || id.length === 0 || id.length > RECIPE_LIMITS.docIdChars) return `doc must be a text of 1 to ${String(RECIPE_LIMITS.docIdChars)} characters`;
|
|
3066
|
+
if (id.startsWith("archive:")) return `the id ${id} starts with archive:, which is for the archived copies`;
|
|
3067
|
+
if (seen.has(id)) return `two documents have the id ${id}`;
|
|
3068
|
+
seen.add(id);
|
|
3069
|
+
const kind = doc["kind"];
|
|
3070
|
+
if (typeof kind !== "string" || kind.length === 0) return "kind must be a text that is not empty";
|
|
3071
|
+
if (kind === "archived") return "the kind archived is for the archived copies";
|
|
3072
|
+
if (typeof doc["title"] !== "string") return "title must be a text";
|
|
3073
|
+
if (typeof doc["text"] !== "string") return "text must be a text";
|
|
3074
|
+
if (doc["url"] !== null && typeof doc["url"] !== "string") return "url must be a text or null";
|
|
3075
|
+
const meta = doc["meta"];
|
|
3076
|
+
if (meta === null || typeof meta !== "object" || Array.isArray(meta)) return "meta must be an object";
|
|
3077
|
+
try {
|
|
3078
|
+
JSON.stringify(meta);
|
|
3079
|
+
} catch {
|
|
3080
|
+
return "meta must be JSON: no cycle and no BigInt";
|
|
3081
|
+
}
|
|
3082
|
+
const merge = doc["merge"];
|
|
3083
|
+
if (merge !== void 0 && merge !== "latest" && merge !== "richest") return "merge must be \"latest\" or \"richest\"";
|
|
3084
|
+
if (doc["full_text"] !== void 0 && typeof doc["full_text"] !== "string") return "full_text must be a text";
|
|
3085
|
+
const aiInput = doc["ai_input"];
|
|
3086
|
+
if (aiInput !== void 0 && !AI_INPUTS.includes(aiInput)) return "ai_input must be \"yes\", \"no\" or \"unset\"";
|
|
3087
|
+
};
|
|
3088
|
+
/** This function gives the documents of an extractor, or throws an error that names the first fault. */
|
|
3089
|
+
const checkedDocs = (name, docs) => {
|
|
3090
|
+
if (!Array.isArray(docs)) throw new TypeError(`the recipe ${name} gave ${typeof docs}, not an array of documents`);
|
|
3091
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3092
|
+
for (const [index, doc] of docs.entries()) {
|
|
3093
|
+
const fault = faultOf(doc, seen);
|
|
3094
|
+
if (fault !== void 0) throw new TypeError(`the recipe ${name} gave a document that is not valid (${String(index)}): ${fault}`);
|
|
3095
|
+
}
|
|
3096
|
+
return docs;
|
|
3097
|
+
};
|
|
3098
|
+
/**
|
|
3099
|
+
* This function declares a recipe. It checks the definition at once: a name that is not valid, a
|
|
3100
|
+
* reserved name or a duration that it cannot read throws an error. The extractor of the recipe
|
|
3101
|
+
* checks each document of `extract`. A document that is not valid makes the extractor throw, and
|
|
3102
|
+
* the runtime reports it as an error of the extractor.
|
|
3103
|
+
*/
|
|
3104
|
+
const defineRecipe = (definition) => {
|
|
3105
|
+
const name = definition.name;
|
|
3106
|
+
if (typeof name !== "string" || name.length > RECIPE_LIMITS.nameChars || !NAME.test(name)) throw new RangeError(`a recipe name is 1 to ${String(RECIPE_LIMITS.nameChars)} letters, digits and . _ : -, and it starts with a letter or a digit, not ${JSON.stringify(name) ?? "undefined"}`);
|
|
3107
|
+
if (RESERVED_RECIPES.includes(name)) throw new RangeError(`the recipe name ${name} belongs to the crawler: give the recipe a name of its own`);
|
|
3108
|
+
if (typeof definition.extract !== "function") throw new TypeError(`the recipe ${name} needs extract, a function that reads a body into documents`);
|
|
3109
|
+
const mode = definition.mode ?? "page";
|
|
3110
|
+
if (mode !== "page" && mode !== "api") throw new RangeError(`the mode of the recipe ${name} must be "page" or "api", not ${String(mode)}`);
|
|
3111
|
+
const accept = definition.accept ?? (mode === "api" ? API_ACCEPT : PAGE_ACCEPT);
|
|
3112
|
+
const minIntervalMs = definition.minInterval === void 0 ? void 0 : durationMs(definition.minInterval);
|
|
3113
|
+
const maxAgeOf = (value) => value === "auto" ? "auto" : durationMs(value);
|
|
3114
|
+
const maxAge = maxAgeOf(definition.maxAge ?? "24h");
|
|
3115
|
+
const maxBytes = definition.maxBytes;
|
|
3116
|
+
if (maxBytes !== void 0 && (!Number.isSafeInteger(maxBytes) || maxBytes < 1)) throw new RangeError(`maxBytes of the recipe ${name} must be a whole number of 1 or more`);
|
|
3117
|
+
const extract = definition.extract;
|
|
3118
|
+
const extractor = async (body, capture) => checkedDocs(name, await extract(body, {
|
|
3119
|
+
url: capture.url,
|
|
3120
|
+
contentType: capture.contentType
|
|
3121
|
+
}));
|
|
3122
|
+
const fetch = definition.fetch;
|
|
3123
|
+
return {
|
|
3124
|
+
name,
|
|
3125
|
+
description: definition.description ?? null,
|
|
3126
|
+
mode,
|
|
3127
|
+
accept,
|
|
3128
|
+
extractor,
|
|
3129
|
+
request(input, options = {}) {
|
|
3130
|
+
const asked = fetch === void 0 ? input : fetch(input);
|
|
3131
|
+
const target = typeof asked === "string" ? { url: asked } : asked;
|
|
3132
|
+
const url = target?.url;
|
|
3133
|
+
let parsed;
|
|
3134
|
+
try {
|
|
3135
|
+
parsed = typeof url === "string" ? new URL(url) : void 0;
|
|
3136
|
+
} catch {
|
|
3137
|
+
parsed = void 0;
|
|
3138
|
+
}
|
|
3139
|
+
if (parsed === void 0 || parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new TypeError(`the recipe ${name} needs an http or https URL for its input, not ${JSON.stringify(url) ?? String(url)}`);
|
|
3140
|
+
return {
|
|
3141
|
+
url: parsed.href,
|
|
3142
|
+
mode,
|
|
3143
|
+
recipe: name,
|
|
3144
|
+
accept: target?.accept ?? accept,
|
|
3145
|
+
headers: target?.headers,
|
|
3146
|
+
secretParams: target?.secretParams,
|
|
3147
|
+
minIntervalMs,
|
|
3148
|
+
maxAgeMs: options.maxAge === void 0 ? maxAge : maxAgeOf(options.maxAge),
|
|
3149
|
+
maxBytes,
|
|
3150
|
+
onBehalfOf: options.onBehalfOf,
|
|
3151
|
+
job: options.job,
|
|
3152
|
+
signal: options.signal,
|
|
3153
|
+
scope: options.scope,
|
|
3154
|
+
labels: options.labels,
|
|
3155
|
+
deadline: options.deadline
|
|
3156
|
+
};
|
|
3157
|
+
}
|
|
3158
|
+
};
|
|
3159
|
+
};
|
|
3160
|
+
/**
|
|
3161
|
+
* This function gives the extractor of a recipe to the extractors of a runtime. A second
|
|
3162
|
+
* registration of the same recipe does nothing. A different extractor with the same name throws
|
|
3163
|
+
* an error, because `reindex` must read each capture with one extractor.
|
|
3164
|
+
*/
|
|
3165
|
+
const registerRecipe = (extractors, recipe) => {
|
|
3166
|
+
const known = extractors.get(recipe.name);
|
|
3167
|
+
if (known === recipe.extractor) return;
|
|
3168
|
+
if (known !== void 0) throw new Error(`the recipe ${recipe.name} has a different extractor in this runtime: give each recipe a name of its own`);
|
|
3169
|
+
extractors.register(recipe.name, recipe.extractor);
|
|
3170
|
+
};
|
|
3171
|
+
/** This function gives the runtime of a target. */
|
|
3172
|
+
const runtimeOf = (target) => "crawler" in target ? target : target.runtime;
|
|
3173
|
+
/** This function gives the deadline of a budget, on the clock of the runtime. */
|
|
3174
|
+
const deadlineOf = (runtime, budget) => budget === void 0 ? void 0 : Date.parse(runtime.now()) + durationMs(budget);
|
|
3175
|
+
/** This function gives one document of a run, with the rule for a held-back document. */
|
|
3176
|
+
const recipeDoc = async (runtime, doc, capture, userProvided, action) => {
|
|
3177
|
+
const current = runtime.index.get(doc.doc) ?? doc;
|
|
3178
|
+
const version = (capture === void 0 ? void 0 : runtime.index.versionText(doc.doc, capture)) ?? current;
|
|
3179
|
+
const aiInput = current.ai_input === "no" || version.ai_input === "no" ? "no" : version.ai_input;
|
|
3180
|
+
const held = isHeldBack({ ai_input: aiInput }, userProvided);
|
|
3181
|
+
if (aiInput === "no" && userProvided) await recordOverride(runtime, current, action);
|
|
3182
|
+
const archived = archivedOf(version.meta);
|
|
3183
|
+
return {
|
|
3184
|
+
doc: doc.doc,
|
|
3185
|
+
kind: version.kind,
|
|
3186
|
+
title: held ? "" : version.title,
|
|
3187
|
+
url: version.url,
|
|
3188
|
+
meta: held ? heldBackMeta(version.meta) : version.meta,
|
|
3189
|
+
chars: version.text.length,
|
|
3190
|
+
capture: version.capture,
|
|
3191
|
+
fetched_at: version.fetched_at,
|
|
3192
|
+
held_back: held,
|
|
3193
|
+
...archived === void 0 ? {} : { archived }
|
|
3194
|
+
};
|
|
3195
|
+
};
|
|
3196
|
+
const noteOf = (parts) => {
|
|
3197
|
+
const kept = parts.filter((part) => typeof part === "string" && part.length > 0);
|
|
3198
|
+
return kept.length > 0 ? kept.join("; ") : null;
|
|
3199
|
+
};
|
|
3200
|
+
/**
|
|
3201
|
+
* This function starts a recipe for one input. It fetches the request through the crawler, and it
|
|
3202
|
+
* reads the capture with the extractor of the recipe. It gives the documents of that capture.
|
|
3203
|
+
* These are its rules:
|
|
3204
|
+
*
|
|
3205
|
+
* - The runtime registers the recipe the first time that it gets it. Give the recipe to
|
|
3206
|
+
* `openCrawler` too, so that `reindex` reads its captures before the first fetch.
|
|
3207
|
+
* - A stored copy younger than `maxAge` answers with no request. When the extractor cannot read a
|
|
3208
|
+
* stored copy, the crawler asks the source one more time.
|
|
3209
|
+
* - An extractor that throws gives no document, and `error` tells why. The fetch still succeeds.
|
|
3210
|
+
* - A document that asked not to be AI input shows no title and only its structural metadata,
|
|
3211
|
+
* unless `userProvided`. The crawler logs each `userProvided` read of a held-back document.
|
|
3212
|
+
* - With `fallback: "archive"`, a page that the site refuses gets an archived copy, with
|
|
3213
|
+
* `archived`. The page reader reads the copy. The extractor of the recipe reads only live
|
|
3214
|
+
* captures. Thus its documents do not mix a live answer and an old copy of a third party.
|
|
3215
|
+
*/
|
|
3216
|
+
const runRecipe = async (target, recipe, input, options = {}) => {
|
|
3217
|
+
const runtime = runtimeOf(target);
|
|
3218
|
+
if (options.fallback === "archive" && recipe.mode !== "page") throw new RangeError(`the recipe ${recipe.name} reads an API: an archived copy is for a page, so give fallback only to a recipe of the mode page`);
|
|
3219
|
+
registerRecipe(runtime.extractors, recipe);
|
|
3220
|
+
const deadline = deadlineOf(runtime, options.budget);
|
|
3221
|
+
const request = recipe.request(input, {
|
|
3222
|
+
...options,
|
|
3223
|
+
deadline
|
|
3224
|
+
});
|
|
3225
|
+
let indexed = await fetchAndIndex(runtime, request);
|
|
3226
|
+
if (indexed.error !== void 0 && indexed.blocked === void 0 && indexed.fetched.fromCache) indexed = await fetchAndIndex(runtime, {
|
|
3227
|
+
...request,
|
|
3228
|
+
maxAgeMs: 0
|
|
3229
|
+
});
|
|
3230
|
+
const userProvided = options.userProvided ?? false;
|
|
3231
|
+
const action = options.job ?? `recipe ${recipe.name}`;
|
|
3232
|
+
const fetched = indexed.fetched;
|
|
3233
|
+
const notes = [fetched.note, indexed.error];
|
|
3234
|
+
if (indexed.docs.length === 0 && options.fallback === "archive") {
|
|
3235
|
+
const reason = fallbackReason(indexed);
|
|
3236
|
+
if (reason !== void 0) {
|
|
3237
|
+
const page = {
|
|
3238
|
+
render: "never",
|
|
3239
|
+
maxAgeMs: request.maxAgeMs,
|
|
3240
|
+
onBehalfOf: request.onBehalfOf,
|
|
3241
|
+
job: request.job,
|
|
3242
|
+
signal: request.signal,
|
|
3243
|
+
scope: request.scope,
|
|
3244
|
+
labels: request.labels,
|
|
3245
|
+
deadline,
|
|
3246
|
+
fallback: "archive",
|
|
3247
|
+
archiveDespiteRobots: options.archiveDespiteRobots
|
|
3248
|
+
};
|
|
3249
|
+
const answer = await archivedCopy(runtime, request.url, reason, page);
|
|
3250
|
+
if (answer.doc !== void 0 && answer.archived !== void 0) {
|
|
3251
|
+
const doc = await recipeDoc(runtime, answer.doc, answer.doc.capture, userProvided, action);
|
|
3252
|
+
return {
|
|
3253
|
+
recipe: recipe.name,
|
|
3254
|
+
url: fetched.url,
|
|
3255
|
+
outcome: fetched.outcome,
|
|
3256
|
+
status: fetched.status,
|
|
3257
|
+
docs: [doc],
|
|
3258
|
+
capture: doc.capture,
|
|
3259
|
+
fetched_at: doc.fetched_at,
|
|
3260
|
+
from_cache: answer.fromCache,
|
|
3261
|
+
held_back: doc.held_back,
|
|
3262
|
+
error: indexed.error ?? null,
|
|
3263
|
+
archived: answer.archived,
|
|
3264
|
+
note: noteOf([
|
|
3265
|
+
...notes,
|
|
3266
|
+
...answer.notes,
|
|
3267
|
+
"the page reader read the archived copy, not the extractor of the recipe",
|
|
3268
|
+
doc.held_back ? HELD_BACK : void 0
|
|
3269
|
+
])
|
|
3270
|
+
};
|
|
3271
|
+
}
|
|
3272
|
+
notes.push(...answer.notes);
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
const capture = fetched.capture;
|
|
3276
|
+
const docs = [];
|
|
3277
|
+
for (const doc of indexed.docs) docs.push(await recipeDoc(runtime, doc, capture, userProvided, action));
|
|
3278
|
+
const held = docs.some((doc) => doc.held_back);
|
|
3279
|
+
return {
|
|
3280
|
+
recipe: recipe.name,
|
|
3281
|
+
url: fetched.url,
|
|
3282
|
+
outcome: fetched.outcome,
|
|
3283
|
+
status: fetched.status,
|
|
3284
|
+
docs,
|
|
3285
|
+
capture: docs[0]?.capture ?? capture ?? null,
|
|
3286
|
+
fetched_at: docs[0]?.fetched_at ?? fetched.fetchedAt ?? null,
|
|
3287
|
+
from_cache: fetched.fromCache,
|
|
3288
|
+
held_back: held,
|
|
3289
|
+
error: indexed.error ?? null,
|
|
3290
|
+
note: noteOf([...notes, held ? HELD_BACK : void 0])
|
|
3291
|
+
};
|
|
3292
|
+
};
|
|
3293
|
+
//#endregion
|
|
3294
|
+
//#region src/vectors.ts
|
|
3295
|
+
/**
|
|
3296
|
+
* The vector store keeps the vectors of hybrid search in `vectors.sqlite`, beside the index. It is
|
|
3297
|
+
* a projection, as the index is. Delete the file, and the next hybrid search or `embed` makes the
|
|
3298
|
+
* vectors again from the stored texts.
|
|
3299
|
+
*
|
|
3300
|
+
* - A row is one chunk of one text for one embedder. Its key is the SHA-256 of the text, the
|
|
3301
|
+
* number of the chunk and the id of the embedder. Thus a reindex keeps each vector, and the same
|
|
3302
|
+
* text gets its vectors one time for each model, also when two documents hold it.
|
|
3303
|
+
* - A text is complete when the table `texts` has it, with the plan of its chunks. A text with a
|
|
3304
|
+
* different plan gets new chunks.
|
|
3305
|
+
* - The table `docs` points each document at the text that has its vectors. A search uses only
|
|
3306
|
+
* the texts that a document points at, and the caller checks each document against the index.
|
|
3307
|
+
* - A search compares the vector of the question with each stored vector (a brute-force cosine).
|
|
3308
|
+
* The store keeps the vectors of each embedder in memory after the first search. It reads them
|
|
3309
|
+
* again when a different process writes the file.
|
|
3310
|
+
*
|
|
3311
|
+
* Each connection waits for the lock of a different process (`busy_timeout`), and each write is
|
|
3312
|
+
* one transaction (`BEGIN IMMEDIATE`). The file has a schema version (`VECTORS_SCHEMA`). A file of
|
|
3313
|
+
* a different version opens empty.
|
|
3314
|
+
*/
|
|
3315
|
+
/** This value is the version of the schema of the vector file. */
|
|
3316
|
+
const VECTORS_SCHEMA = 1;
|
|
3317
|
+
const DDL = `
|
|
3318
|
+
CREATE TABLE IF NOT EXISTS vectors_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
3319
|
+
CREATE TABLE IF NOT EXISTS embedders (
|
|
3320
|
+
id TEXT PRIMARY KEY,
|
|
3321
|
+
dimensions INTEGER NOT NULL,
|
|
3322
|
+
first_at TEXT NOT NULL
|
|
3323
|
+
);
|
|
3324
|
+
CREATE TABLE IF NOT EXISTS texts (
|
|
3325
|
+
sha TEXT NOT NULL,
|
|
3326
|
+
embedder TEXT NOT NULL,
|
|
3327
|
+
plan TEXT NOT NULL,
|
|
3328
|
+
chunks INTEGER NOT NULL,
|
|
3329
|
+
chars INTEGER NOT NULL,
|
|
3330
|
+
embedded_at TEXT NOT NULL,
|
|
3331
|
+
PRIMARY KEY (sha, embedder)
|
|
3332
|
+
);
|
|
3333
|
+
CREATE TABLE IF NOT EXISTS chunks (
|
|
3334
|
+
sha TEXT NOT NULL,
|
|
3335
|
+
chunk INTEGER NOT NULL,
|
|
3336
|
+
embedder TEXT NOT NULL,
|
|
3337
|
+
char_start INTEGER NOT NULL,
|
|
3338
|
+
char_end INTEGER NOT NULL,
|
|
3339
|
+
vector BLOB NOT NULL,
|
|
3340
|
+
PRIMARY KEY (sha, chunk, embedder)
|
|
3341
|
+
);
|
|
3342
|
+
CREATE INDEX IF NOT EXISTS chunks_embedder ON chunks (embedder, sha, chunk);
|
|
3343
|
+
CREATE TABLE IF NOT EXISTS docs (
|
|
3344
|
+
doc TEXT NOT NULL,
|
|
3345
|
+
embedder TEXT NOT NULL,
|
|
3346
|
+
sha TEXT NOT NULL,
|
|
3347
|
+
pointed_at TEXT NOT NULL,
|
|
3348
|
+
PRIMARY KEY (doc, embedder)
|
|
3349
|
+
);
|
|
3350
|
+
CREATE INDEX IF NOT EXISTS docs_sha ON docs (embedder, sha);
|
|
3351
|
+
`;
|
|
3352
|
+
/** This function gives the bytes of a vector for a BLOB. The bytes are in the order of the platform (little-endian on each common platform). */
|
|
3353
|
+
const bytesOf = (vector) => new Uint8Array(vector.buffer, vector.byteOffset, vector.byteLength);
|
|
3354
|
+
/** This function gives the vector of the bytes of a BLOB. It copies the bytes, so an odd offset of the BLOB is not a problem. */
|
|
3355
|
+
const vectorOf = (bytes) => {
|
|
3356
|
+
const out = new Float32Array(Math.floor(bytes.byteLength / 4));
|
|
3357
|
+
new Uint8Array(out.buffer).set(bytes.subarray(0, out.length * 4));
|
|
3358
|
+
return out;
|
|
3359
|
+
};
|
|
3360
|
+
const dot = (data, row, query, dims) => {
|
|
3361
|
+
let sum = 0;
|
|
3362
|
+
const base = row * dims;
|
|
3363
|
+
for (let at = 0; at < dims; at += 1) sum += data[base + at] * query[at];
|
|
3364
|
+
return sum;
|
|
3365
|
+
};
|
|
3366
|
+
var VectorStore = class {
|
|
3367
|
+
path;
|
|
3368
|
+
db;
|
|
3369
|
+
statements = /* @__PURE__ */ new Map();
|
|
3370
|
+
matrices = /* @__PURE__ */ new Map();
|
|
3371
|
+
closed = false;
|
|
3372
|
+
constructor(path, options = {}) {
|
|
3373
|
+
this.path = path;
|
|
3374
|
+
if (path !== ":memory:") mkdirSync(dirname(path), {
|
|
3375
|
+
recursive: true,
|
|
3376
|
+
mode: 448
|
|
3377
|
+
});
|
|
3378
|
+
this.db = new DatabaseSync(path);
|
|
3379
|
+
const wait = Math.max(0, Math.floor(options.busyTimeoutMs ?? 1e4));
|
|
3380
|
+
this.db.exec(`PRAGMA busy_timeout = ${String(wait)};`);
|
|
3381
|
+
if (path !== ":memory:") this.db.exec("PRAGMA journal_mode = WAL;");
|
|
3382
|
+
this.db.exec("PRAGMA synchronous = NORMAL;");
|
|
3383
|
+
this.write(() => {
|
|
3384
|
+
if (this.db.prepare("PRAGMA user_version").get().user_version !== 1) {
|
|
3385
|
+
const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
|
|
3386
|
+
for (const { name } of tables) this.db.exec(`DROP TABLE IF EXISTS "${name}"`);
|
|
3387
|
+
}
|
|
3388
|
+
this.db.exec(DDL);
|
|
3389
|
+
this.db.exec(`PRAGMA user_version = ${String(1)}`);
|
|
3390
|
+
});
|
|
3391
|
+
}
|
|
3392
|
+
stmt(sql) {
|
|
3393
|
+
let statement = this.statements.get(sql);
|
|
3394
|
+
if (statement === void 0) {
|
|
3395
|
+
statement = this.db.prepare(sql);
|
|
3396
|
+
this.statements.set(sql, statement);
|
|
3397
|
+
}
|
|
3398
|
+
return statement;
|
|
3399
|
+
}
|
|
3400
|
+
write(work) {
|
|
3401
|
+
if (this.closed) throw new Error("the vector store is closed");
|
|
3402
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
3403
|
+
try {
|
|
3404
|
+
const result = work();
|
|
3405
|
+
this.db.exec("COMMIT");
|
|
3406
|
+
return result;
|
|
3407
|
+
} catch (error) {
|
|
3408
|
+
if (this.db.isTransaction) this.db.exec("ROLLBACK");
|
|
3409
|
+
throw error;
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
live() {
|
|
3413
|
+
if (this.closed) throw new Error("the vector store is closed");
|
|
3414
|
+
}
|
|
3415
|
+
/** This method gives the plan of the chunks of a text when the store has each of its vectors, or undefined. */
|
|
3416
|
+
plan(embedder, sha) {
|
|
3417
|
+
this.live();
|
|
3418
|
+
return this.stmt("SELECT plan FROM texts WHERE sha = ? AND embedder = ?").get(sha, embedder)?.plan;
|
|
3419
|
+
}
|
|
3420
|
+
/** This method gives the text that a document points at, or undefined. */
|
|
3421
|
+
pointed(embedder, doc) {
|
|
3422
|
+
this.live();
|
|
3423
|
+
return this.stmt("SELECT sha FROM docs WHERE doc = ? AND embedder = ?").get(doc, embedder)?.sha;
|
|
3424
|
+
}
|
|
3425
|
+
/** This method gives the dimensions that the store keeps for an embedder, or undefined. */
|
|
3426
|
+
dimensions(embedder) {
|
|
3427
|
+
this.live();
|
|
3428
|
+
return this.stmt("SELECT dimensions FROM embedders WHERE id = ?").get(embedder)?.dimensions;
|
|
3429
|
+
}
|
|
3430
|
+
/**
|
|
3431
|
+
* This method writes the chunks of one text and points its documents at it, in one transaction.
|
|
3432
|
+
* A vector with a length that is different from the dimensions of the embedder throws an error.
|
|
3433
|
+
*/
|
|
3434
|
+
put(input) {
|
|
3435
|
+
const dims = input.dimensions;
|
|
3436
|
+
for (const chunk of input.chunks) if (chunk.vector.length !== dims) throw new Error(`the embedder ${input.embedder} gave a vector of ${String(chunk.vector.length)} dimensions, not ${String(dims)}`);
|
|
3437
|
+
let replaced = false;
|
|
3438
|
+
this.write(() => {
|
|
3439
|
+
const known = this.stmt("SELECT dimensions FROM embedders WHERE id = ?").get(input.embedder);
|
|
3440
|
+
if (known === void 0 && input.chunks.length > 0) this.stmt("INSERT INTO embedders (id, dimensions, first_at) VALUES (?, ?, ?)").run(input.embedder, dims, input.at);
|
|
3441
|
+
else if (known !== void 0 && input.chunks.length > 0 && known.dimensions !== dims) throw new Error(`vectors.sqlite holds vectors of ${String(known.dimensions)} dimensions for the embedder ${input.embedder}, and it gave ${String(dims)}: give the embedder a new id, or delete vectors.sqlite`);
|
|
3442
|
+
const before = this.stmt("SELECT plan FROM texts WHERE sha = ? AND embedder = ?").get(input.sha, input.embedder);
|
|
3443
|
+
replaced = before !== void 0 && before.plan !== input.plan;
|
|
3444
|
+
this.stmt("DELETE FROM chunks WHERE sha = ? AND embedder = ?").run(input.sha, input.embedder);
|
|
3445
|
+
const insert = this.stmt("INSERT INTO chunks (sha, chunk, embedder, char_start, char_end, vector) VALUES (?, ?, ?, ?, ?, ?)");
|
|
3446
|
+
input.chunks.forEach((chunk, at) => {
|
|
3447
|
+
insert.run(input.sha, at, input.embedder, chunk.start, chunk.end, bytesOf(chunk.vector));
|
|
3448
|
+
});
|
|
3449
|
+
this.stmt(`INSERT INTO texts (sha, embedder, plan, chunks, chars, embedded_at) VALUES (?, ?, ?, ?, ?, ?)
|
|
3450
|
+
ON CONFLICT(sha, embedder) DO UPDATE SET plan = excluded.plan, chunks = excluded.chunks,
|
|
3451
|
+
chars = excluded.chars, embedded_at = excluded.embedded_at`).run(input.sha, input.embedder, input.plan, input.chunks.length, input.chars, input.at);
|
|
3452
|
+
for (const doc of input.docs) this.pointRow(input.embedder, doc, input.sha, input.at);
|
|
3453
|
+
});
|
|
3454
|
+
const matrix = this.matrices.get(input.embedder);
|
|
3455
|
+
if (matrix === void 0) return;
|
|
3456
|
+
if (replaced || matrix.texts.has(input.sha)) {
|
|
3457
|
+
this.matrices.delete(input.embedder);
|
|
3458
|
+
return;
|
|
3459
|
+
}
|
|
3460
|
+
this.append(matrix, input.sha, input.chunks.map((chunk, at) => ({
|
|
3461
|
+
...chunk,
|
|
3462
|
+
chunk: at
|
|
3463
|
+
})));
|
|
3464
|
+
for (const doc of input.docs) this.pointMatrix(matrix, doc, input.sha);
|
|
3465
|
+
}
|
|
3466
|
+
pointRow(embedder, doc, sha, at) {
|
|
3467
|
+
this.stmt(`INSERT INTO docs (doc, embedder, sha, pointed_at) VALUES (?, ?, ?, ?)
|
|
3468
|
+
ON CONFLICT(doc, embedder) DO UPDATE SET sha = excluded.sha, pointed_at = excluded.pointed_at`).run(doc, embedder, sha, at);
|
|
3469
|
+
}
|
|
3470
|
+
/** This method points a document at a text that the store has. */
|
|
3471
|
+
point(embedder, doc, sha, at) {
|
|
3472
|
+
this.write(() => this.pointRow(embedder, doc, sha, at));
|
|
3473
|
+
const matrix = this.matrices.get(embedder);
|
|
3474
|
+
if (matrix === void 0) return;
|
|
3475
|
+
if (!matrix.texts.has(sha)) this.append(matrix, sha, this.chunks(embedder, sha));
|
|
3476
|
+
this.pointMatrix(matrix, doc, sha);
|
|
3477
|
+
}
|
|
3478
|
+
/** This method removes the pointer of a document, for example when the document is held back. Its text keeps its vectors. */
|
|
3479
|
+
forget(embedder, doc) {
|
|
3480
|
+
this.write(() => this.stmt("DELETE FROM docs WHERE doc = ? AND embedder = ?").run(doc, embedder));
|
|
3481
|
+
const matrix = this.matrices.get(embedder);
|
|
3482
|
+
if (matrix === void 0) return;
|
|
3483
|
+
const sha = matrix.shaOf.get(doc);
|
|
3484
|
+
if (sha !== void 0) matrix.docsOf.get(sha)?.delete(doc);
|
|
3485
|
+
matrix.shaOf.delete(doc);
|
|
3486
|
+
}
|
|
3487
|
+
/** This method gives the chunks of a text, in their sequence. */
|
|
3488
|
+
chunks(embedder, sha) {
|
|
3489
|
+
this.live();
|
|
3490
|
+
return this.stmt("SELECT chunk, char_start, char_end, vector FROM chunks WHERE sha = ? AND embedder = ? ORDER BY chunk").all(sha, embedder).map((row) => ({
|
|
3491
|
+
chunk: row.chunk,
|
|
3492
|
+
start: row.char_start,
|
|
3493
|
+
end: row.char_end,
|
|
3494
|
+
vector: vectorOf(row.vector)
|
|
3495
|
+
}));
|
|
3496
|
+
}
|
|
3497
|
+
pointMatrix(matrix, doc, sha) {
|
|
3498
|
+
const before = matrix.shaOf.get(doc);
|
|
3499
|
+
if (before !== void 0 && before !== sha) matrix.docsOf.get(before)?.delete(doc);
|
|
3500
|
+
matrix.shaOf.set(doc, sha);
|
|
3501
|
+
let docs = matrix.docsOf.get(sha);
|
|
3502
|
+
if (docs === void 0) {
|
|
3503
|
+
docs = /* @__PURE__ */ new Set();
|
|
3504
|
+
matrix.docsOf.set(sha, docs);
|
|
3505
|
+
}
|
|
3506
|
+
docs.add(doc);
|
|
3507
|
+
}
|
|
3508
|
+
append(matrix, sha, chunks) {
|
|
3509
|
+
const dims = matrix.dimensions;
|
|
3510
|
+
const need = matrix.rows + chunks.length;
|
|
3511
|
+
if (need > matrix.chunk.length) {
|
|
3512
|
+
const size = Math.max(need, matrix.chunk.length * 2, 64);
|
|
3513
|
+
const data = new Float32Array(size * dims);
|
|
3514
|
+
data.set(matrix.data.subarray(0, matrix.rows * dims));
|
|
3515
|
+
const grow = (old) => {
|
|
3516
|
+
const next = new Int32Array(size);
|
|
3517
|
+
next.set(old.subarray(0, matrix.rows));
|
|
3518
|
+
return next;
|
|
3519
|
+
};
|
|
3520
|
+
matrix.data = data;
|
|
3521
|
+
matrix.chunk = grow(matrix.chunk);
|
|
3522
|
+
matrix.start = grow(matrix.start);
|
|
3523
|
+
matrix.end = grow(matrix.end);
|
|
3524
|
+
}
|
|
3525
|
+
const from = matrix.rows;
|
|
3526
|
+
for (const chunk of chunks) {
|
|
3527
|
+
const row = matrix.rows;
|
|
3528
|
+
if (chunk.vector.length === dims) matrix.data.set(chunk.vector, row * dims);
|
|
3529
|
+
matrix.chunk[row] = chunk.chunk;
|
|
3530
|
+
matrix.start[row] = chunk.start;
|
|
3531
|
+
matrix.end[row] = chunk.end;
|
|
3532
|
+
matrix.rows += 1;
|
|
3533
|
+
}
|
|
3534
|
+
matrix.texts.set(sha, {
|
|
3535
|
+
from,
|
|
3536
|
+
to: matrix.rows
|
|
3537
|
+
});
|
|
3538
|
+
}
|
|
3539
|
+
/** This method gives the vectors of an embedder in memory. It reads them again when a different process wrote the file. */
|
|
3540
|
+
matrix(embedder) {
|
|
3541
|
+
this.live();
|
|
3542
|
+
const version = this.db.prepare("PRAGMA data_version").get().data_version;
|
|
3543
|
+
const cached = this.matrices.get(embedder);
|
|
3544
|
+
if (cached !== void 0 && cached.version === version) return cached;
|
|
3545
|
+
const dims = this.dimensions(embedder);
|
|
3546
|
+
if (dims === void 0) return void 0;
|
|
3547
|
+
this.db.exec("BEGIN");
|
|
3548
|
+
try {
|
|
3549
|
+
const matrix = this.load(embedder, dims, version);
|
|
3550
|
+
this.matrices.set(embedder, matrix);
|
|
3551
|
+
return matrix;
|
|
3552
|
+
} finally {
|
|
3553
|
+
if (this.db.isTransaction) this.db.exec("COMMIT");
|
|
3554
|
+
}
|
|
3555
|
+
}
|
|
3556
|
+
load(embedder, dims, version) {
|
|
3557
|
+
const count = this.db.prepare("SELECT count(*) AS n FROM chunks WHERE embedder = ? AND sha IN (SELECT sha FROM docs WHERE embedder = ?)").get(embedder, embedder).n;
|
|
3558
|
+
const matrix = {
|
|
3559
|
+
dimensions: dims,
|
|
3560
|
+
rows: 0,
|
|
3561
|
+
data: new Float32Array(Math.max(1, count) * dims),
|
|
3562
|
+
chunk: new Int32Array(Math.max(1, count)),
|
|
3563
|
+
start: new Int32Array(Math.max(1, count)),
|
|
3564
|
+
end: new Int32Array(Math.max(1, count)),
|
|
3565
|
+
texts: /* @__PURE__ */ new Map(),
|
|
3566
|
+
docsOf: /* @__PURE__ */ new Map(),
|
|
3567
|
+
shaOf: /* @__PURE__ */ new Map(),
|
|
3568
|
+
version
|
|
3569
|
+
};
|
|
3570
|
+
const rows = this.db.prepare(`SELECT sha, chunk, char_start, char_end, vector FROM chunks
|
|
3571
|
+
WHERE embedder = ? AND sha IN (SELECT sha FROM docs WHERE embedder = ?)
|
|
3572
|
+
ORDER BY sha, chunk`).iterate(embedder, embedder);
|
|
3573
|
+
let sha;
|
|
3574
|
+
let from = 0;
|
|
3575
|
+
for (const row of rows) {
|
|
3576
|
+
if (row.sha !== sha) {
|
|
3577
|
+
if (sha !== void 0) matrix.texts.set(sha, {
|
|
3578
|
+
from,
|
|
3579
|
+
to: matrix.rows
|
|
3580
|
+
});
|
|
3581
|
+
sha = row.sha;
|
|
3582
|
+
from = matrix.rows;
|
|
3583
|
+
}
|
|
3584
|
+
if (matrix.rows >= matrix.chunk.length) break;
|
|
3585
|
+
const at = matrix.rows;
|
|
3586
|
+
if (row.vector.byteLength === dims * 4) new Uint8Array(matrix.data.buffer, at * dims * 4, dims * 4).set(row.vector);
|
|
3587
|
+
matrix.chunk[at] = row.chunk;
|
|
3588
|
+
matrix.start[at] = row.char_start;
|
|
3589
|
+
matrix.end[at] = row.char_end;
|
|
3590
|
+
matrix.rows += 1;
|
|
3591
|
+
}
|
|
3592
|
+
if (sha !== void 0) matrix.texts.set(sha, {
|
|
3593
|
+
from,
|
|
3594
|
+
to: matrix.rows
|
|
3595
|
+
});
|
|
3596
|
+
for (const row of this.db.prepare("SELECT doc, sha FROM docs WHERE embedder = ?").all(embedder)) this.pointMatrix(matrix, row.doc, row.sha);
|
|
3597
|
+
return matrix;
|
|
3598
|
+
}
|
|
3599
|
+
/** This method gives the best chunk of one text for a question, or undefined when the store has no vector of the text. */
|
|
3600
|
+
best(embedder, sha, query) {
|
|
3601
|
+
const matrix = this.matrix(embedder);
|
|
3602
|
+
const range = matrix?.texts.get(sha);
|
|
3603
|
+
if (matrix === void 0 || range === void 0 || range.to === range.from) return void 0;
|
|
3604
|
+
return this.hitOf(matrix, sha, range, query);
|
|
3605
|
+
}
|
|
3606
|
+
hitOf(matrix, sha, range, query) {
|
|
3607
|
+
let bestRow = range.from;
|
|
3608
|
+
let bestScore = Number.NEGATIVE_INFINITY;
|
|
3609
|
+
for (let row = range.from; row < range.to; row += 1) {
|
|
3610
|
+
const score = dot(matrix.data, row, query, matrix.dimensions);
|
|
3611
|
+
if (score > bestScore) {
|
|
3612
|
+
bestScore = score;
|
|
3613
|
+
bestRow = row;
|
|
3614
|
+
}
|
|
3615
|
+
}
|
|
3616
|
+
return {
|
|
3617
|
+
sha,
|
|
3618
|
+
chunk: matrix.chunk[bestRow],
|
|
3619
|
+
start: matrix.start[bestRow],
|
|
3620
|
+
end: matrix.end[bestRow],
|
|
3621
|
+
similarity: bestScore,
|
|
3622
|
+
docs: [...matrix.docsOf.get(sha) ?? []]
|
|
3623
|
+
};
|
|
3624
|
+
}
|
|
3625
|
+
/**
|
|
3626
|
+
* This method compares a question with each stored vector of an embedder (a brute-force cosine).
|
|
3627
|
+
* It gives the best chunk of each text that a document points at, the best first. With `within`,
|
|
3628
|
+
* it compares only the texts of those documents.
|
|
3629
|
+
*/
|
|
3630
|
+
search(embedder, query, options = {}) {
|
|
3631
|
+
const matrix = this.matrix(embedder);
|
|
3632
|
+
if (matrix === void 0) return [];
|
|
3633
|
+
if (query.length !== matrix.dimensions) throw new Error(`the question has a vector of ${String(query.length)} dimensions, and the store holds ${String(matrix.dimensions)} for the embedder ${embedder}`);
|
|
3634
|
+
const shas = /* @__PURE__ */ new Set();
|
|
3635
|
+
if (options.within === void 0) {
|
|
3636
|
+
for (const [sha, docs] of matrix.docsOf) if (docs.size > 0) shas.add(sha);
|
|
3637
|
+
} else for (const doc of options.within) {
|
|
3638
|
+
const sha = matrix.shaOf.get(doc);
|
|
3639
|
+
if (sha !== void 0) shas.add(sha);
|
|
3640
|
+
}
|
|
3641
|
+
const hits = [];
|
|
3642
|
+
for (const sha of shas) {
|
|
3643
|
+
const range = matrix.texts.get(sha);
|
|
3644
|
+
if (range === void 0 || range.to === range.from) continue;
|
|
3645
|
+
hits.push(this.hitOf(matrix, sha, range, query));
|
|
3646
|
+
}
|
|
3647
|
+
return hits.sort((a, b) => b.similarity - a.similarity || (a.sha < b.sha ? -1 : 1));
|
|
3648
|
+
}
|
|
3649
|
+
/** This method gives the counts of the store, for one embedder or for each embedder. */
|
|
3650
|
+
counts(embedder) {
|
|
3651
|
+
this.live();
|
|
3652
|
+
const where = embedder === void 0 ? "" : " WHERE embedder = ?";
|
|
3653
|
+
const params = embedder === void 0 ? [] : [embedder];
|
|
3654
|
+
const one = (sql) => this.stmt(sql).get(...params).n ?? 0;
|
|
3655
|
+
return {
|
|
3656
|
+
texts: one(`SELECT count(*) AS n FROM texts${where}`),
|
|
3657
|
+
chunks: one(`SELECT count(*) AS n FROM chunks${where}`),
|
|
3658
|
+
docs: one(`SELECT count(*) AS n FROM docs${where}`),
|
|
3659
|
+
bytes: one(`SELECT sum(length(vector)) AS n FROM chunks${where}`)
|
|
3660
|
+
};
|
|
3661
|
+
}
|
|
3662
|
+
/** This method closes the file. A second close does nothing. */
|
|
3663
|
+
close() {
|
|
3664
|
+
if (this.closed) return;
|
|
3665
|
+
this.closed = true;
|
|
3666
|
+
this.statements.clear();
|
|
3667
|
+
this.matrices.clear();
|
|
3668
|
+
this.db.close();
|
|
3669
|
+
}
|
|
3670
|
+
};
|
|
3671
|
+
//#endregion
|
|
2335
3672
|
//#region src/watch.ts
|
|
2336
3673
|
/**
|
|
2337
3674
|
* A watch list keeps the pages that the crawler checks again on a schedule. `watch.run` fetches the
|
|
@@ -2349,7 +3686,7 @@ var SqliteHostStateStore = class {
|
|
|
2349
3686
|
* - The list is in `watch.sqlite` in the store, so it stays after a restart. A run claims each entry
|
|
2350
3687
|
* with a lease in that file, so two processes do not check one entry two times.
|
|
2351
3688
|
*
|
|
2352
|
-
*
|
|
3689
|
+
* A scheduler of the operator starts `watch.run`, or the daemon (`startDaemon`) starts it on a schedule.
|
|
2353
3690
|
*/
|
|
2354
3691
|
/** This value is the version of the schema of `watch.sqlite`. */
|
|
2355
3692
|
const WATCH_SCHEMA = 1;
|
|
@@ -2619,7 +3956,7 @@ const REFUSALS = /* @__PURE__ */ new Set([
|
|
|
2619
3956
|
"payment-required"
|
|
2620
3957
|
]);
|
|
2621
3958
|
/** This function gives the fingerprint of a version: a hash of its title, its text and its JSON-LD. */
|
|
2622
|
-
const watchFingerprint = (version) => sha256(stableStringify([
|
|
3959
|
+
const watchFingerprint = (version) => sha256$1(stableStringify([
|
|
2623
3960
|
version.title,
|
|
2624
3961
|
version.text,
|
|
2625
3962
|
version.meta["jsonld"] ?? null
|
|
@@ -2836,6 +4173,10 @@ const runWatch = (runtime, source, options = {}) => {
|
|
|
2836
4173
|
const deadline = startedAt + budgetMs;
|
|
2837
4174
|
for (;;) {
|
|
2838
4175
|
if (stopping === void 0 && abort.signal.aborted) stopping = "cancelled";
|
|
4176
|
+
if (stopping === void 0 && options.drain?.aborted === true) {
|
|
4177
|
+
stopping = "cancelled";
|
|
4178
|
+
reason = "the run stopped for a shutdown: the checks in progress ended first";
|
|
4179
|
+
}
|
|
2839
4180
|
if (stopping === void 0 && now() >= deadline) stopping = "budget";
|
|
2840
4181
|
while (stopping === void 0 && inflight.size < WATCH_LIMITS.hostsAtOnce && started < maxPages) {
|
|
2841
4182
|
const claimed = store.claim(now(), owner, Math.max(now(), deadline) + WATCH_LIMITS.leaseMs, [...inflight.keys()]);
|
|
@@ -2926,7 +4267,8 @@ const STORE_PARTS = [
|
|
|
2926
4267
|
"blobs",
|
|
2927
4268
|
"index.sqlite",
|
|
2928
4269
|
"hosts.sqlite",
|
|
2929
|
-
"watch.sqlite"
|
|
4270
|
+
"watch.sqlite",
|
|
4271
|
+
"vectors.sqlite"
|
|
2930
4272
|
];
|
|
2931
4273
|
/**
|
|
2932
4274
|
* This function makes the folder of a store for the owner only, where the system has modes. It
|
|
@@ -2960,6 +4302,9 @@ const createRuntime = (options) => {
|
|
|
2960
4302
|
if (dir === void 0 && (options.log === void 0 || options.blobs === void 0 || options.index === void 0)) throw new Error("a runtime needs dir, or a log, a blob store and an index");
|
|
2961
4303
|
const contact = options.contact;
|
|
2962
4304
|
if (contact !== void 0 && !/^[\t -~]*$/.test(contact)) throw new Error(`contact "${contact}" has a character that an HTTP header cannot hold: use printable ASCII`);
|
|
4305
|
+
const extractors = new Extractors();
|
|
4306
|
+
for (const [recipe, extractor] of Object.entries(options.extractors ?? {})) extractors.register(recipe, extractor);
|
|
4307
|
+
for (const recipe of options.recipes ?? []) registerRecipe(extractors, recipe);
|
|
2963
4308
|
if (dir !== void 0) guardStore(dir);
|
|
2964
4309
|
const owned = [];
|
|
2965
4310
|
const log = options.log ?? new CaptureLog(join(dir, "captures"));
|
|
@@ -3003,8 +4348,6 @@ const createRuntime = (options) => {
|
|
|
3003
4348
|
onEvent: options.onEvent,
|
|
3004
4349
|
signer: options.signer
|
|
3005
4350
|
});
|
|
3006
|
-
const extractors = new Extractors();
|
|
3007
|
-
for (const [recipe, extractor] of Object.entries(options.extractors ?? {})) extractors.register(recipe, extractor);
|
|
3008
4351
|
let closing;
|
|
3009
4352
|
return {
|
|
3010
4353
|
crawler,
|
|
@@ -3057,6 +4400,26 @@ const openCrawler = (options) => {
|
|
|
3057
4400
|
return watches;
|
|
3058
4401
|
};
|
|
3059
4402
|
const clockNow = () => Date.parse(runtime.now());
|
|
4403
|
+
const embedder = options.embedder;
|
|
4404
|
+
let vectors;
|
|
4405
|
+
let search;
|
|
4406
|
+
const hybridSearch = async () => {
|
|
4407
|
+
await live();
|
|
4408
|
+
if (embedder === void 0) throw new Error(NO_EMBEDDER);
|
|
4409
|
+
vectors ??= new VectorStore(join(options.dir, "vectors.sqlite"));
|
|
4410
|
+
search ??= createHybridSearch(runtime, {
|
|
4411
|
+
embedder,
|
|
4412
|
+
store: vectors
|
|
4413
|
+
});
|
|
4414
|
+
return search;
|
|
4415
|
+
};
|
|
4416
|
+
const hybrid = embedder === void 0 ? void 0 : {
|
|
4417
|
+
embedder,
|
|
4418
|
+
find: async (q, findOptions) => (await hybridSearch()).find(q, findOptions),
|
|
4419
|
+
rankWithin: async (q, docs, rankOptions) => (await hybridSearch()).rankWithin(q, docs, rankOptions),
|
|
4420
|
+
embed: async (embedOptions) => (await hybridSearch()).embed(embedOptions)
|
|
4421
|
+
};
|
|
4422
|
+
let closing;
|
|
3060
4423
|
return {
|
|
3061
4424
|
runtime,
|
|
3062
4425
|
async fetch(url, fetchOptions = {}) {
|
|
@@ -3076,7 +4439,22 @@ const openCrawler = (options) => {
|
|
|
3076
4439
|
},
|
|
3077
4440
|
async find(q, findOptions = {}) {
|
|
3078
4441
|
await live();
|
|
3079
|
-
|
|
4442
|
+
const { rank, ...rest } = findOptions;
|
|
4443
|
+
if (rank === "hybrid") return (await hybridSearch()).find(q, rest);
|
|
4444
|
+
return findDocuments(runtime, q, {
|
|
4445
|
+
...rest,
|
|
4446
|
+
...rank === void 0 ? {} : { rank }
|
|
4447
|
+
});
|
|
4448
|
+
},
|
|
4449
|
+
hybrid,
|
|
4450
|
+
async embed(embedOptions = {}) {
|
|
4451
|
+
return (await hybridSearch()).embed(embedOptions);
|
|
4452
|
+
},
|
|
4453
|
+
async rankWithin(q, docs, rankOptions = {}) {
|
|
4454
|
+
await live();
|
|
4455
|
+
const { rank, ...rest } = rankOptions;
|
|
4456
|
+
if (rank === "hybrid") return (await hybridSearch()).rankWithin(q, docs, rest);
|
|
4457
|
+
return runtime.index.rankWithin(q, docs, rank === void 0 ? {} : { rank });
|
|
3080
4458
|
},
|
|
3081
4459
|
async read(id, readOptions = {}) {
|
|
3082
4460
|
await live();
|
|
@@ -3147,15 +4525,20 @@ const openCrawler = (options) => {
|
|
|
3147
4525
|
});
|
|
3148
4526
|
}
|
|
3149
4527
|
},
|
|
3150
|
-
|
|
4528
|
+
close() {
|
|
3151
4529
|
closed = true;
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
4530
|
+
closing ??= (async () => {
|
|
4531
|
+
await ready;
|
|
4532
|
+
await runtime.close();
|
|
4533
|
+
watches?.close();
|
|
4534
|
+
vectors?.close();
|
|
4535
|
+
await embedder?.close?.();
|
|
4536
|
+
})();
|
|
4537
|
+
return closing;
|
|
3155
4538
|
}
|
|
3156
4539
|
};
|
|
3157
4540
|
};
|
|
3158
4541
|
//#endregion
|
|
3159
|
-
export { readSitemap as _, open_exports as a, WatchStore as c,
|
|
4542
|
+
export { crawlSite as A, chunkText as C, unitVector as D, fuseRanks as E, readSitemap as F, DocIndex as I, FAST_RANK_SHARE as L, discoverSite as M, SITEMAP_LIMITS as N, SqliteHostStateStore as O, feedDate as P, NO_EMBEDDER as R, HYBRID_DEFAULTS as S, estimateTokens as T, registerRecipe as _, open_exports as a, createHybridSearch as b, WatchStore as c, VectorStore as d, RECIPE_LIMITS as f, readJson as g, defineRecipe as h, openCrawler as i, DISCOVER_LIMITS as j, CRAWL_LIMITS as k, runWatch as l, deadlineOf as m, defaultUserAgent as n, WATCH_LIMITS as o, RESERVED_RECIPES as p, isMailbox as r, WATCH_SCHEMA as s, createRuntime as t, VECTORS_SCHEMA as u, runRecipe as v, chunkTokensOf as w, CHUNK_LIMITS as x, runtimeOf as y };
|
|
3160
4543
|
|
|
3161
|
-
//# sourceMappingURL=open-
|
|
4544
|
+
//# sourceMappingURL=open-C4fhXJgD.mjs.map
|