@dogsbay/search 0.2.0-beta.133 → 0.2.0-beta.134
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 +2 -0
- package/dist/index.d.ts +31 -4
- package/dist/index.js +92 -15
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -54,6 +54,8 @@ if (page) index.add(page.doc, page.parts);
|
|
|
54
54
|
for (const [file, content] of index.emitFiles()) write(`_search/${file}`, content);
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
On a large site, call `index.takeReadyFiles()` after each `add` and write what it yields: each fragment file is handed over as soon as it is complete, so the builder does not hold every page's excerpt to the end. `emitFiles()` then writes the rest. The builder keeps its postings in typed arrays (about 4 bytes a number): dogsbay-ssg measured OpenShift's 1,799 pages building with search on in a 128 MB Worker heap, which the per-term arrays did not allow. Add every page before calling `emitFiles()`: `add()` after it throws.
|
|
58
|
+
|
|
57
59
|
- `@dogsbay/search/index`: `SearchIndexBuilder`, and `extractText()` for a dogsbay document tree. It reads API reference nodes too: an OpenAPI operation's method and path, summary, description, parameters and schema properties (nested ones included); autodoc symbol names (as headings) and docstrings; and an embedded spec's descriptions. Code (signatures, source, samples) is skipped, as code blocks are.
|
|
58
60
|
- `@dogsbay/search/html`: `extractHtml()`.
|
|
59
61
|
- `@dogsbay/search/query`: `search()`, `parseQuery()`, `scriptData()`, `memoryData()`.
|
package/dist/index.d.ts
CHANGED
|
@@ -54,17 +54,41 @@ declare class SearchIndexBuilder {
|
|
|
54
54
|
/** Token counts per document and field (title, headings, body), for length normalisation. */
|
|
55
55
|
private lengths;
|
|
56
56
|
/**
|
|
57
|
-
*
|
|
58
|
-
* each
|
|
59
|
-
*
|
|
57
|
+
* The postings as one append-only log of (term id, counts) pairs in Int32Array chunks, in
|
|
58
|
+
* document order, and where each document's pairs start. An array per term cost about 16
|
|
59
|
+
* bytes a number with its header, Map entry and growth slack: on OpenShift (1,799 pages,
|
|
60
|
+
* 49,014 terms) 20.7 MB of a 128 MB Worker, which search-on could not build in. The log is
|
|
61
|
+
* 4 bytes a number; `emitFiles` groups it by term once (request from dogsbay-ssg, 2026-10-22).
|
|
60
62
|
*/
|
|
61
|
-
private
|
|
63
|
+
private chunks;
|
|
64
|
+
/** Numbers written to the log. */
|
|
65
|
+
private used;
|
|
66
|
+
/** Where each document's pairs start in the log. */
|
|
67
|
+
private docStart;
|
|
68
|
+
/** term → id, in first-seen order (shard grouping and key order follow it). */
|
|
69
|
+
private termIds;
|
|
70
|
+
/** Postings per term id. */
|
|
71
|
+
private counts;
|
|
72
|
+
/** Fragment blocks already handed over by `takeReadyFiles`. */
|
|
73
|
+
private fragmentsOut;
|
|
74
|
+
/** Set once `emitFiles` starts: the log is grouped then, and pages can no longer be added. */
|
|
75
|
+
private emitted;
|
|
76
|
+
/** The log grouped by term ([doc, counts, …] per term id from `offset[id]`), kept until every shard is written. */
|
|
77
|
+
private grouped?;
|
|
62
78
|
add(doc: SearchDoc, parts: {
|
|
63
79
|
title: string;
|
|
64
80
|
headings: string[];
|
|
65
81
|
body: string;
|
|
66
82
|
keywords?: string[];
|
|
67
83
|
}): void;
|
|
84
|
+
/**
|
|
85
|
+
* The fragment files complete so far (every BLOCK documents make one), each handed over once
|
|
86
|
+
* and then let go: a caller that drains this after each `add` does not hold every page's
|
|
87
|
+
* fragment to the end of the build (2.5M characters on OpenShift). `emitFiles` writes the rest.
|
|
88
|
+
*/
|
|
89
|
+
takeReadyFiles(): Generator<[string, string]>;
|
|
90
|
+
/** Fragment block `b`. */
|
|
91
|
+
private fragmentFile;
|
|
68
92
|
/** The stored score of one posting: saturated, length-normalised field scores, weighted and summed. */
|
|
69
93
|
private scorer;
|
|
70
94
|
get size(): number;
|
|
@@ -82,6 +106,9 @@ declare class SearchIndexBuilder {
|
|
|
82
106
|
* one shard per term prefix. Each shard is built, serialised and handed
|
|
83
107
|
* over before the next, and its postings are dropped, so writing the index
|
|
84
108
|
* needs memory for one shard rather than for every shard and its JSON.
|
|
109
|
+
* The postings log is grouped by term first, each chunk let go once read.
|
|
110
|
+
* A call stopped part way leaves the shards not yet written to the next call;
|
|
111
|
+
* no page can be added once it has started.
|
|
85
112
|
*/
|
|
86
113
|
emitFiles(): Generator<[string, string]>;
|
|
87
114
|
}
|
package/dist/index.js
CHANGED
|
@@ -18,6 +18,7 @@ const MAX_DEPTH = 3;
|
|
|
18
18
|
function keyDepth(k) {
|
|
19
19
|
return k.replace(/_[0-9a-f]+_/g, "x").length;
|
|
20
20
|
}
|
|
21
|
+
const CHUNK = 1 << 18;
|
|
21
22
|
class SearchIndexBuilder {
|
|
22
23
|
constructor(opts = {}) {
|
|
23
24
|
this.opts = opts;
|
|
@@ -27,12 +28,29 @@ class SearchIndexBuilder {
|
|
|
27
28
|
/** Token counts per document and field (title, headings, body), for length normalisation. */
|
|
28
29
|
lengths = [];
|
|
29
30
|
/**
|
|
30
|
-
*
|
|
31
|
-
* each
|
|
32
|
-
*
|
|
31
|
+
* The postings as one append-only log of (term id, counts) pairs in Int32Array chunks, in
|
|
32
|
+
* document order, and where each document's pairs start. An array per term cost about 16
|
|
33
|
+
* bytes a number with its header, Map entry and growth slack: on OpenShift (1,799 pages,
|
|
34
|
+
* 49,014 terms) 20.7 MB of a 128 MB Worker, which search-on could not build in. The log is
|
|
35
|
+
* 4 bytes a number; `emitFiles` groups it by term once (request from dogsbay-ssg, 2026-10-22).
|
|
33
36
|
*/
|
|
34
|
-
|
|
37
|
+
chunks = [];
|
|
38
|
+
/** Numbers written to the log. */
|
|
39
|
+
used = 0;
|
|
40
|
+
/** Where each document's pairs start in the log. */
|
|
41
|
+
docStart = [];
|
|
42
|
+
/** term → id, in first-seen order (shard grouping and key order follow it). */
|
|
43
|
+
termIds = /* @__PURE__ */ new Map();
|
|
44
|
+
/** Postings per term id. */
|
|
45
|
+
counts = new Int32Array(1024);
|
|
46
|
+
/** Fragment blocks already handed over by `takeReadyFiles`. */
|
|
47
|
+
fragmentsOut = 0;
|
|
48
|
+
/** Set once `emitFiles` starts: the log is grouped then, and pages can no longer be added. */
|
|
49
|
+
emitted = false;
|
|
50
|
+
/** The log grouped by term ([doc, counts, …] per term id from `offset[id]`), kept until every shard is written. */
|
|
51
|
+
grouped;
|
|
35
52
|
add(doc, parts) {
|
|
53
|
+
if (this.emitted) throw new Error("SearchIndexBuilder: add() after emitFiles(): the index has been written");
|
|
36
54
|
const id = this.docs.length;
|
|
37
55
|
this.docs.push({
|
|
38
56
|
title: detach(doc.title),
|
|
@@ -57,12 +75,45 @@ class SearchIndexBuilder {
|
|
|
57
75
|
for (const h of parts.headings) count(h, B_HEAD, 15, 1);
|
|
58
76
|
count(parts.body, 1, 4095, 2);
|
|
59
77
|
this.lengths.push(len[0], len[1], len[2]);
|
|
78
|
+
this.docStart.push(this.used);
|
|
60
79
|
for (const [t, v] of counts) {
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
|
|
80
|
+
let term = this.termIds.get(t);
|
|
81
|
+
if (term === void 0) {
|
|
82
|
+
term = this.termIds.size;
|
|
83
|
+
this.termIds.set(detach(t), term);
|
|
84
|
+
if (term === this.counts.length) {
|
|
85
|
+
const grown = new Int32Array(this.counts.length * 2);
|
|
86
|
+
grown.set(this.counts);
|
|
87
|
+
this.counts = grown;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
this.counts[term]++;
|
|
91
|
+
const at = this.used % CHUNK;
|
|
92
|
+
if (at === 0) this.chunks.push(new Int32Array(CHUNK));
|
|
93
|
+
const chunk = this.chunks[this.chunks.length - 1];
|
|
94
|
+
chunk[at] = term;
|
|
95
|
+
chunk[at + 1] = v;
|
|
96
|
+
this.used += 2;
|
|
64
97
|
}
|
|
65
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* The fragment files complete so far (every BLOCK documents make one), each handed over once
|
|
101
|
+
* and then let go: a caller that drains this after each `add` does not hold every page's
|
|
102
|
+
* fragment to the end of the build (2.5M characters on OpenShift). `emitFiles` writes the rest.
|
|
103
|
+
*/
|
|
104
|
+
*takeReadyFiles() {
|
|
105
|
+
if (this.emitted) return;
|
|
106
|
+
while ((this.fragmentsOut + 1) * BLOCK <= this.docs.length) {
|
|
107
|
+
const b = this.fragmentsOut++;
|
|
108
|
+
yield this.fragmentFile(b);
|
|
109
|
+
for (let i = b * BLOCK; i < (b + 1) * BLOCK; i++) this.docs[i].fragment = "";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Fragment block `b`. */
|
|
113
|
+
fragmentFile(b) {
|
|
114
|
+
const part = this.docs.slice(b * BLOCK, (b + 1) * BLOCK);
|
|
115
|
+
return [file.fragment(b), wrapJson(key.fragment(b), `[${part.map((d) => d.fragment).join(",")}]`)];
|
|
116
|
+
}
|
|
66
117
|
/** The stored score of one posting: saturated, length-normalised field scores, weighted and summed. */
|
|
67
118
|
scorer() {
|
|
68
119
|
const n = this.docs.length || 1;
|
|
@@ -96,7 +147,7 @@ class SearchIndexBuilder {
|
|
|
96
147
|
*/
|
|
97
148
|
shardTerms() {
|
|
98
149
|
const out = /* @__PURE__ */ new Map();
|
|
99
|
-
const size = (terms) => terms.reduce((n, t) => n + t.length + 4 + this.
|
|
150
|
+
const size = (terms) => terms.reduce((n, t) => n + t.length + 4 + this.counts[this.termIds.get(t)] * 2 * 4, 0);
|
|
100
151
|
const place = (terms, depth) => {
|
|
101
152
|
const groups = /* @__PURE__ */ new Map();
|
|
102
153
|
for (const t of terms) {
|
|
@@ -113,7 +164,7 @@ class SearchIndexBuilder {
|
|
|
113
164
|
} else out.set(k, g);
|
|
114
165
|
}
|
|
115
166
|
};
|
|
116
|
-
place([...this.
|
|
167
|
+
place([...this.termIds.keys()], 1);
|
|
117
168
|
return out;
|
|
118
169
|
}
|
|
119
170
|
/** Emit the index files, keyed by path relative to the unit's `_dogsbay/search/` dir. */
|
|
@@ -125,8 +176,12 @@ class SearchIndexBuilder {
|
|
|
125
176
|
* one shard per term prefix. Each shard is built, serialised and handed
|
|
126
177
|
* over before the next, and its postings are dropped, so writing the index
|
|
127
178
|
* needs memory for one shard rather than for every shard and its JSON.
|
|
179
|
+
* The postings log is grouped by term first, each chunk let go once read.
|
|
180
|
+
* A call stopped part way leaves the shards not yet written to the next call;
|
|
181
|
+
* no page can be added once it has started.
|
|
128
182
|
*/
|
|
129
183
|
*emitFiles() {
|
|
184
|
+
this.emitted = true;
|
|
130
185
|
const byShard = this.shardTerms();
|
|
131
186
|
const depth = Math.max(1, ...[...byShard.keys()].map((k) => keyDepth(k)));
|
|
132
187
|
const score = this.scorer();
|
|
@@ -147,24 +202,46 @@ class SearchIndexBuilder {
|
|
|
147
202
|
});
|
|
148
203
|
const meta = { format: FORMAT, depth, count: this.docs.length, shards: [...byShard.keys()].sort(), sections, tags, block: BLOCK, lang: this.opts.lang, docs };
|
|
149
204
|
yield [file.docs, wrap(key.docs, meta)];
|
|
150
|
-
for (let b =
|
|
151
|
-
|
|
152
|
-
|
|
205
|
+
for (let b = this.fragmentsOut; b * BLOCK < this.docs.length; b++) yield this.fragmentFile(b);
|
|
206
|
+
if (!this.grouped) {
|
|
207
|
+
const n = this.termIds.size;
|
|
208
|
+
const offset2 = new Int32Array(n + 1);
|
|
209
|
+
for (let i = 0; i < n; i++) offset2[i + 1] = offset2[i] + this.counts[i] * 2;
|
|
210
|
+
const flat2 = new Int32Array(this.used);
|
|
211
|
+
const fill = offset2.slice(0, n);
|
|
212
|
+
let doc = -1;
|
|
213
|
+
for (let p = 0; p < this.used; p += 2) {
|
|
214
|
+
while (doc + 1 < this.docStart.length && this.docStart[doc + 1] <= p) doc++;
|
|
215
|
+
const c = p / CHUNK | 0;
|
|
216
|
+
const at = p % CHUNK;
|
|
217
|
+
const chunk = this.chunks[c];
|
|
218
|
+
const term = chunk[at];
|
|
219
|
+
flat2[fill[term]] = doc;
|
|
220
|
+
flat2[fill[term] + 1] = chunk[at + 1];
|
|
221
|
+
fill[term] += 2;
|
|
222
|
+
if (at + 2 === CHUNK) this.chunks[c] = void 0;
|
|
223
|
+
}
|
|
224
|
+
this.chunks = [];
|
|
225
|
+
this.docStart = [];
|
|
226
|
+
this.used = 0;
|
|
227
|
+
this.grouped = { flat: flat2, offset: offset2 };
|
|
153
228
|
}
|
|
229
|
+
const { flat, offset } = this.grouped;
|
|
154
230
|
for (const [k, terms] of byShard) {
|
|
155
231
|
const data = {};
|
|
156
232
|
for (const term of terms) {
|
|
157
|
-
const
|
|
233
|
+
const id = this.termIds.get(term);
|
|
158
234
|
const pairs = [];
|
|
159
|
-
for (let i =
|
|
235
|
+
for (let i = offset[id]; i < offset[id + 1]; i += 2) pairs.push([flat[i], score(flat[i], flat[i + 1])]);
|
|
160
236
|
pairs.sort((a, b) => b[1] - a[1]);
|
|
161
237
|
const out = [];
|
|
162
238
|
for (const [d, sc] of pairs) out.push(d, sc);
|
|
163
239
|
data[term] = out;
|
|
164
|
-
this.
|
|
240
|
+
this.termIds.delete(term);
|
|
165
241
|
}
|
|
166
242
|
yield [file.shard(k), wrap(key.shard(k), data)];
|
|
167
243
|
}
|
|
244
|
+
this.grouped = void 0;
|
|
168
245
|
}
|
|
169
246
|
}
|
|
170
247
|
const str = (v) => typeof v === "string" ? v : "";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dogsbay/search",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.134",
|
|
4
4
|
"description": "Static search for docs sites: builds in a Worker, queries from file://",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/entry.js",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
],
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"stemmer": "^2.0.1",
|
|
45
|
-
"@dogsbay/types": "0.2.0-beta.
|
|
45
|
+
"@dogsbay/types": "0.2.0-beta.134"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@playwright/test": "1.61.1",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"tsup": "^8.5.1",
|
|
53
53
|
"typescript": "^5.9.3",
|
|
54
54
|
"vitest": "^4.1.10",
|
|
55
|
-
"@dogsbay/format-openapi": "0.2.0-beta.
|
|
55
|
+
"@dogsbay/format-openapi": "0.2.0-beta.134"
|
|
56
56
|
},
|
|
57
57
|
"license": "MIT",
|
|
58
58
|
"repository": {
|