@uniweb/projections 0.5.10 → 0.5.12
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/package.json +5 -4
- package/src/search/engine.js +639 -0
- package/src/search/index.js +15 -0
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/projections",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.12",
|
|
4
4
|
"description": "Projections of a Uniweb site's content — agent index, per-page markdown, search index. Pure JS, runs anywhere.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.js",
|
|
8
|
-
"./search": "./src/search/index.js"
|
|
8
|
+
"./search": "./src/search/index.js",
|
|
9
|
+
"./search/engine": "./src/search/engine.js"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
|
11
12
|
"src"
|
|
@@ -31,8 +32,8 @@
|
|
|
31
32
|
"node": ">=20.19"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
|
-
"@uniweb/
|
|
35
|
-
"@uniweb/
|
|
35
|
+
"@uniweb/content-writer": "^0.3.4",
|
|
36
|
+
"@uniweb/core": "^0.23.0"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
38
39
|
"vitest": "^4.1.7",
|
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The search engine — an inverted index over search entries, and BM25F ranking.
|
|
3
|
+
*
|
|
4
|
+
* ## What this is, and what it is not
|
|
5
|
+
*
|
|
6
|
+
* `generateSearchIndex` produces the **entries**: a site's searchable content,
|
|
7
|
+
* one record per page or record, emitted at build time. **This module indexes
|
|
8
|
+
* and ranks them.** Two jobs, one artifact between them, and the entries are the
|
|
9
|
+
* seam.
|
|
10
|
+
*
|
|
11
|
+
* ⭐ **It exists so that one site ranks the same wherever it is served.** The
|
|
12
|
+
* entries were already shared; the ranking was not, so a lesson learned about
|
|
13
|
+
* ranking this corpus landed in one consumer and could not reach another, and
|
|
14
|
+
* the same query over the same content could come back in a different order
|
|
15
|
+
* depending on who answered it. A reader experiences that as the product being
|
|
16
|
+
* inconsistent, not as two implementations.
|
|
17
|
+
*
|
|
18
|
+
* ⚖️ **It is a default, not a monopoly.** A site may point its search at a
|
|
19
|
+
* third-party service, and a host may answer search itself; that pluggability is
|
|
20
|
+
* deliberate and this does not touch it.
|
|
21
|
+
*
|
|
22
|
+
* ## ⛔ THE CONSTRAINT THAT SHAPED THE FORMAT: PARSE COST
|
|
23
|
+
*
|
|
24
|
+
* The structure is built once per corpus and then **serialized and re-parsed** by
|
|
25
|
+
* whoever caches it. **A fat inverted index can cost more to parse than the
|
|
26
|
+
* linear scan it replaces**, which would make it a regression at exactly the
|
|
27
|
+
* scale it exists to fix.
|
|
28
|
+
*
|
|
29
|
+
* ⇒ **Everything but the term dictionary is a flat array of integers.** Postings
|
|
30
|
+
* are `[doc, tfTitle, tfBody, doc, tfTitle, tfBody, …]` — no objects, no key
|
|
31
|
+
* names repeated per posting. A `{doc,tfT,tfB}` object form measured **~4× the
|
|
32
|
+
* bytes** for the same information, all of it key names. ⛔ Do not "tidy" the
|
|
33
|
+
* postings into objects; the flatness is the feature.
|
|
34
|
+
*
|
|
35
|
+
* ## What a global product needs that an English one does not
|
|
36
|
+
*
|
|
37
|
+
* ⭐ **Diacritic folding.** `café` and `cafe` must be one term, or French,
|
|
38
|
+
* Spanish and Portuguese sites silently under-match. NFD, then drop combining
|
|
39
|
+
* marks.
|
|
40
|
+
*
|
|
41
|
+
* ⭐ **Bigrams for scripts written without spaces.** Chinese and Japanese do not
|
|
42
|
+
* separate words, so a whitespace tokenizer yields ONE enormous token per run and
|
|
43
|
+
* the index is useless. Bigrams are the standard cheap answer and need no
|
|
44
|
+
* dictionary.
|
|
45
|
+
*
|
|
46
|
+
* ⛔ **NO STOPWORD LIST, deliberately.** A hardcoded English list is wrong for
|
|
47
|
+
* every other language. **IDF does the job a stopword list was invented to fake**
|
|
48
|
+
* — a term in every document earns an IDF at or near zero and stops affecting the
|
|
49
|
+
* ranking on its own.
|
|
50
|
+
*
|
|
51
|
+
* ⛔ **NO STEMMING.** Each language needs its own stemmer, so it is N
|
|
52
|
+
* dependencies and N failure modes rather than one; and a wrong stemmer **merges
|
|
53
|
+
* terms that differ and splits terms that do not, invisibly** — no error, no test
|
|
54
|
+
* failure, just quietly worse results. Reopenable on evidence, per locale, one
|
|
55
|
+
* language at a time: an index is built per locale, so the language is known.
|
|
56
|
+
*
|
|
57
|
+
* ## Provenance
|
|
58
|
+
*
|
|
59
|
+
* The index, BM25F scoring, folding, bigrams, prefix completion and the bounded
|
|
60
|
+
* top-k were written for a server-side search lane and contributed here in full
|
|
61
|
+
* on 2026-09-06, so that both lanes rank identically. The fuzzy fallback (§
|
|
62
|
+
* `fuzzyTerms`) is this package's, and it is the one thing the contributed engine
|
|
63
|
+
* did not have.
|
|
64
|
+
*
|
|
65
|
+
* ⭐ Zero dependencies and no platform APIs — no `node:*`, no DOM, no filesystem
|
|
66
|
+
* — so it runs in a browser, in a build, and in a server isolate alike.
|
|
67
|
+
* `tests/environment.test.js` walks the import graph and fails if that stops
|
|
68
|
+
* being true.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
// ── BM25F parameters ────────────────────────────────────────────────────────
|
|
72
|
+
const K1 = 1.2 // term-frequency saturation
|
|
73
|
+
const B_TITLE = 0.5 // titles are short; normalize them gently
|
|
74
|
+
const B_BODY = 0.75 // the usual BM25 default for prose
|
|
75
|
+
const W_TITLE = 3.0 // a title hit is worth about three body hits
|
|
76
|
+
const W_BODY = 1.0
|
|
77
|
+
const PHRASE_BOOST = 1.35 // multiplicative
|
|
78
|
+
const PHRASE_WINDOW = 200 // how deep the phrase check goes — see the note at its use
|
|
79
|
+
const DEFAULT_WEIGHT = 0.6 // an entry that declares none
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Scripts written WITHOUT SPACES between words, which a whitespace tokenizer
|
|
83
|
+
* turns into one useless token per run. These get bigrammed instead.
|
|
84
|
+
*
|
|
85
|
+
* ⛔ **HANGUL IS DELIBERATELY ABSENT.** Korean **is** space delimited —
|
|
86
|
+
* bigramming it is wrong on its own terms, and combined with NFD decomposition it
|
|
87
|
+
* produces jamo-pair tokens rather than words. *Two bugs that look like one,
|
|
88
|
+
* because the output is garbage either way.*
|
|
89
|
+
*
|
|
90
|
+
* ⭐ Thai, Lao, Khmer and Myanmar are here for the reason Han is: no spaces, so
|
|
91
|
+
* without bigrams a whole sentence is a single term and only an exact full-phrase
|
|
92
|
+
* query can find it.
|
|
93
|
+
*/
|
|
94
|
+
const NO_SPACE_SCRIPT =
|
|
95
|
+
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u
|
|
96
|
+
const WORD = /[\p{L}\p{N}][\p{L}\p{N}_'’-]*/gu
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fold to a comparable form: lowercase, and strip combining marks.
|
|
100
|
+
*
|
|
101
|
+
* ⭐ NFD then drop `\p{M}` rather than a hand-written character map — a map covers
|
|
102
|
+
* the accents its author happened to think of, which is a guarantee about the
|
|
103
|
+
* author rather than about the text.
|
|
104
|
+
*
|
|
105
|
+
* Exported because a caller that highlights a match must fold the same way, or it
|
|
106
|
+
* highlights the wrong span.
|
|
107
|
+
*
|
|
108
|
+
* @param {*} text
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
export function fold(text) {
|
|
112
|
+
return String(text == null ? '' : text)
|
|
113
|
+
// NFKC first: full-width Latin → ASCII, half-width katakana → katakana,
|
|
114
|
+
// ligatures → letters. ⛔ Without it `UNIWEB` — routine on Japanese sites —
|
|
115
|
+
// never matches `uniweb`, and `file` never matches `file`.
|
|
116
|
+
.normalize('NFKC')
|
|
117
|
+
// Decompose so combining marks can be dropped: `café` → `cafe`.
|
|
118
|
+
.normalize('NFD')
|
|
119
|
+
.replace(/\p{M}+/gu, '')
|
|
120
|
+
// ⛔⛔ RECOMPOSE, AND THIS LINE IS LOAD-BEARING FOR KOREAN. NFD decomposes each
|
|
121
|
+
// Hangul syllable into jamo, which are LETTERS, not marks — so the strip above
|
|
122
|
+
// leaves them and `한국어` tokenizes as jamo pairs. **Korean search returns
|
|
123
|
+
// garbage without this.** NFC puts the syllables back.
|
|
124
|
+
.normalize('NFC')
|
|
125
|
+
.toLowerCase()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Text → terms.
|
|
130
|
+
*
|
|
131
|
+
* ⛔ A run in a no-space script becomes BIGRAMS, not one token. `東京都` yields
|
|
132
|
+
* `東京`, `京都` — so a query for either matches, which whitespace tokenization
|
|
133
|
+
* cannot do at all. A single-character run still yields that character, or it
|
|
134
|
+
* would be unfindable.
|
|
135
|
+
*
|
|
136
|
+
* @param {*} text
|
|
137
|
+
* @returns {string[]}
|
|
138
|
+
*/
|
|
139
|
+
export function tokenize(text) {
|
|
140
|
+
const folded = fold(text)
|
|
141
|
+
if (!folded) return []
|
|
142
|
+
const out = []
|
|
143
|
+
for (const m of folded.matchAll(WORD)) {
|
|
144
|
+
const tok = m[0]
|
|
145
|
+
if (!NO_SPACE_SCRIPT.test(tok)) {
|
|
146
|
+
if (tok.length >= 2 || /\p{N}/u.test(tok)) out.push(tok)
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
// Mixed runs are possible (`iPhone用`), so split into CJK / non-CJK spans.
|
|
150
|
+
let span = ''
|
|
151
|
+
const flush = () => {
|
|
152
|
+
if (!span) return
|
|
153
|
+
if (NO_SPACE_SCRIPT.test(span[0])) {
|
|
154
|
+
if (span.length === 1) out.push(span)
|
|
155
|
+
else for (let i = 0; i < span.length - 1; i++) out.push(span.slice(i, i + 2))
|
|
156
|
+
} else if (span.length >= 2 || /\p{N}/u.test(span)) out.push(span)
|
|
157
|
+
span = ''
|
|
158
|
+
}
|
|
159
|
+
let cjkSpan = null
|
|
160
|
+
for (const ch of tok) {
|
|
161
|
+
const isC = NO_SPACE_SCRIPT.test(ch)
|
|
162
|
+
if (cjkSpan === null) cjkSpan = isC
|
|
163
|
+
if (isC !== cjkSpan) { flush(); cjkSpan = isC }
|
|
164
|
+
span += ch
|
|
165
|
+
}
|
|
166
|
+
flush()
|
|
167
|
+
}
|
|
168
|
+
return out
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Build the inverted structure over an entry array.
|
|
173
|
+
*
|
|
174
|
+
* ⭐ PURE, and the entry array is the only input — so the same entries always
|
|
175
|
+
* yield the same structure, which is what lets a caller cache it under a content
|
|
176
|
+
* hash and reuse it across queries.
|
|
177
|
+
*
|
|
178
|
+
* @param {Array<{title?: string, content?: string, weight?: number}>} entries
|
|
179
|
+
* @returns {{v:number, terms:string[], df:number[], post:number[][],
|
|
180
|
+
* lenT:number[], lenB:number[], avgT:number, avgB:number, n:number}}
|
|
181
|
+
*/
|
|
182
|
+
export function buildSearchStructure(entries) {
|
|
183
|
+
const docs = Array.isArray(entries) ? entries : []
|
|
184
|
+
const n = docs.length
|
|
185
|
+
const termIds = new Map() // term -> id
|
|
186
|
+
const terms = []
|
|
187
|
+
const postings = [] // id -> Map(doc -> [tfT, tfB])
|
|
188
|
+
const lenT = new Array(n).fill(0)
|
|
189
|
+
const lenB = new Array(n).fill(0)
|
|
190
|
+
|
|
191
|
+
const idOf = (t) => {
|
|
192
|
+
let id = termIds.get(t)
|
|
193
|
+
if (id === undefined) {
|
|
194
|
+
id = terms.length
|
|
195
|
+
termIds.set(t, id)
|
|
196
|
+
terms.push(t)
|
|
197
|
+
postings.push(new Map())
|
|
198
|
+
}
|
|
199
|
+
return id
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (let d = 0; d < n; d++) {
|
|
203
|
+
const e = docs[d] || {}
|
|
204
|
+
const tTerms = tokenize(e.title)
|
|
205
|
+
// `content` already carries the searchable body the projection selected.
|
|
206
|
+
const bTerms = tokenize(e.content)
|
|
207
|
+
lenT[d] = tTerms.length
|
|
208
|
+
lenB[d] = bTerms.length
|
|
209
|
+
|
|
210
|
+
for (const t of tTerms) {
|
|
211
|
+
const p = postings[idOf(t)]
|
|
212
|
+
const cur = p.get(d)
|
|
213
|
+
if (cur) cur[0]++
|
|
214
|
+
else p.set(d, [1, 0])
|
|
215
|
+
}
|
|
216
|
+
for (const t of bTerms) {
|
|
217
|
+
const p = postings[idOf(t)]
|
|
218
|
+
const cur = p.get(d)
|
|
219
|
+
if (cur) cur[1]++
|
|
220
|
+
else p.set(d, [0, 1])
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ⭐ SORT THE DICTIONARY. This is what makes `terms` a term DICTIONARY rather
|
|
225
|
+
// than a list: a sorted array supports binary search, so every term sharing a
|
|
226
|
+
// prefix is a contiguous range and prefix expansion costs a lookup plus a short
|
|
227
|
+
// scan. An unsorted array would force a full pass over the vocabulary per
|
|
228
|
+
// keystroke.
|
|
229
|
+
const order = terms.map((t, i) => i).sort((a, b) => (terms[a] < terms[b] ? -1 : terms[a] > terms[b] ? 1 : 0))
|
|
230
|
+
|
|
231
|
+
// ⛔ FLATTEN TO INTEGERS, in the new order. See the parse-cost note in the
|
|
232
|
+
// header — this is the difference between a structure that is cheaper than the
|
|
233
|
+
// scan and one that is not.
|
|
234
|
+
const post = order.map((oldId) => {
|
|
235
|
+
const flat = []
|
|
236
|
+
for (const [d, tf] of postings[oldId]) flat.push(d, tf[0], tf[1])
|
|
237
|
+
return flat
|
|
238
|
+
})
|
|
239
|
+
const df = order.map((oldId) => postings[oldId].size)
|
|
240
|
+
const sortedTerms = order.map((oldId) => terms[oldId])
|
|
241
|
+
|
|
242
|
+
const sum = (a) => a.reduce((x, y) => x + y, 0)
|
|
243
|
+
return {
|
|
244
|
+
v: 1,
|
|
245
|
+
terms: sortedTerms,
|
|
246
|
+
df,
|
|
247
|
+
post,
|
|
248
|
+
lenT,
|
|
249
|
+
lenB,
|
|
250
|
+
avgT: n ? sum(lenT) / n : 0,
|
|
251
|
+
avgB: n ? sum(lenB) / n : 0,
|
|
252
|
+
n,
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* ⛔ THE TERM→ID MAP LIVES IN A WeakMap, NOT ON THE STRUCTURE.
|
|
258
|
+
*
|
|
259
|
+
* Written once as `structure._ids || (structure._ids = new Map(...))`. The
|
|
260
|
+
* structure is **serialized** by callers that cache it — and `JSON.stringify`
|
|
261
|
+
* turns a `Map` into `{}`, which is TRUTHY, so a later reader parsing that cached
|
|
262
|
+
* copy short-circuits to the empty object and fails with
|
|
263
|
+
* `termIds.get is not a function`.
|
|
264
|
+
*
|
|
265
|
+
* ⚠️ **It was latent rather than live, for a reason nobody should have to rely
|
|
266
|
+
* on**: the caller serialized eagerly, before any query had set `_ids`. Wrap that
|
|
267
|
+
* in an async closure — the most natural refactor there is — and it goes live, as
|
|
268
|
+
* a search outage hours later, on a cache entry poisoned by a different request.
|
|
269
|
+
*
|
|
270
|
+
* ⇒ *Do not mutate an object you serialize.* The WeakMap costs nothing and
|
|
271
|
+
* removes the invariant entirely rather than documenting it.
|
|
272
|
+
*/
|
|
273
|
+
const IDS = new WeakMap()
|
|
274
|
+
function idsFor(structure) {
|
|
275
|
+
let m = IDS.get(structure)
|
|
276
|
+
if (!m) {
|
|
277
|
+
m = new Map(structure.terms.map((t, i) => [t, i]))
|
|
278
|
+
IDS.set(structure, m)
|
|
279
|
+
}
|
|
280
|
+
return m
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const PREFIX_MIN = 2 // below this, a prefix matches most of the vocabulary
|
|
284
|
+
const PREFIX_CAP = 24 // bound the fan-out of one keystroke
|
|
285
|
+
const PREFIX_PENALTY = 0.5 // a completion is a guess; an exact term outranks it
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Term ids whose term starts with `prefix`, by binary search on the sorted
|
|
289
|
+
* dictionary.
|
|
290
|
+
*
|
|
291
|
+
* ⭐ **This is search-as-you-type.** A substring engine gives prefix matching for
|
|
292
|
+
* free and badly — `includes` matches mid-word too, so `art` hits `smart`. Token
|
|
293
|
+
* matching alone would LOSE the useful half of that behaviour, which a visitor
|
|
294
|
+
* notices immediately: typing `zebr` would find nothing until the final `a`.
|
|
295
|
+
*
|
|
296
|
+
* ⛔ **Capped, and the cap keeps the COMMONEST completions.** A two-letter prefix
|
|
297
|
+
* can match thousands of terms; scoring all of them would make an early keystroke
|
|
298
|
+
* the most expensive query of the session. Highest `df` first is what autocomplete
|
|
299
|
+
* wants — the completion a visitor is most likely reaching for.
|
|
300
|
+
*
|
|
301
|
+
* @param {object} structure
|
|
302
|
+
* @param {string} prefix
|
|
303
|
+
* @param {number} [cap]
|
|
304
|
+
* @returns {number[]} term ids
|
|
305
|
+
*/
|
|
306
|
+
export function prefixTerms(structure, prefix, cap = PREFIX_CAP) {
|
|
307
|
+
const terms = structure.terms
|
|
308
|
+
if (!prefix || prefix.length < PREFIX_MIN) return []
|
|
309
|
+
let lo = 0
|
|
310
|
+
let hi = terms.length
|
|
311
|
+
while (lo < hi) {
|
|
312
|
+
const mid = (lo + hi) >> 1
|
|
313
|
+
if (terms[mid] < prefix) lo = mid + 1
|
|
314
|
+
else hi = mid
|
|
315
|
+
}
|
|
316
|
+
const hits = []
|
|
317
|
+
for (let i = lo; i < terms.length && terms[i].startsWith(prefix); i++) hits.push(i)
|
|
318
|
+
if (hits.length <= cap) return hits
|
|
319
|
+
return hits.sort((a, b) => structure.df[b] - structure.df[a]).slice(0, cap)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── The fuzzy fallback — this package's addition ────────────────────────────
|
|
323
|
+
|
|
324
|
+
const FUZZY_MIN = 4 // below this, one edit is most of the word
|
|
325
|
+
const FUZZY_SCAN = 400 // terms examined per query term; a bound, not a target
|
|
326
|
+
const FUZZY_CAP = 8 // corrections kept, commonest first
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* ⭐ **STRICTLY BELOW LITERAL, AND THAT IS THE WHOLE DESIGN.** This runs **only
|
|
330
|
+
* when the literal pass scored nothing at all** — so it can never reorder a
|
|
331
|
+
* result set that has a real match in it, and every property of the ranking above
|
|
332
|
+
* is untouched whenever anything matched.
|
|
333
|
+
*
|
|
334
|
+
* ⛔ Why not a scoring tier mixed in with the rest: a fuzzy score that competes
|
|
335
|
+
* with an exact one is how a near-miss outranks a page that actually contains the
|
|
336
|
+
* word. That failure was measured on the engine this replaces — a query returned
|
|
337
|
+
* 68 hits of which 4 contained the term, and all four ranked below the tenth
|
|
338
|
+
* result. **Fuzzy is the right FALLBACK and the wrong TIER.**
|
|
339
|
+
*
|
|
340
|
+
* The scan is bounded two ways: only terms sharing the first character are
|
|
341
|
+
* considered (a first-character typo is the rare case, and covering it would cost
|
|
342
|
+
* a pass over the whole vocabulary), and at most `FUZZY_SCAN` of them.
|
|
343
|
+
*
|
|
344
|
+
* @param {object} structure
|
|
345
|
+
* @param {string} term - a folded query term
|
|
346
|
+
* @returns {number[]} term ids within edit distance 1
|
|
347
|
+
*/
|
|
348
|
+
export function fuzzyTerms(structure, term) {
|
|
349
|
+
if (!term || term.length < FUZZY_MIN) return []
|
|
350
|
+
const terms = structure.terms
|
|
351
|
+
const head = term[0]
|
|
352
|
+
// Binary search to the first term sharing the leading character.
|
|
353
|
+
let lo = 0
|
|
354
|
+
let hi = terms.length
|
|
355
|
+
while (lo < hi) {
|
|
356
|
+
const mid = (lo + hi) >> 1
|
|
357
|
+
if (terms[mid] < head) lo = mid + 1
|
|
358
|
+
else hi = mid
|
|
359
|
+
}
|
|
360
|
+
const hits = []
|
|
361
|
+
let scanned = 0
|
|
362
|
+
for (let i = lo; i < terms.length && terms[i][0] === head && scanned < FUZZY_SCAN; i++, scanned++) {
|
|
363
|
+
const t = terms[i]
|
|
364
|
+
// Length differs by more than one edit — cheap reject before the real check.
|
|
365
|
+
if (Math.abs(t.length - term.length) > 1) continue
|
|
366
|
+
if (t !== term && withinOneEdit(term, t)) hits.push(i)
|
|
367
|
+
}
|
|
368
|
+
if (hits.length <= FUZZY_CAP) return hits
|
|
369
|
+
return hits.sort((a, b) => structure.df[b] - structure.df[a]).slice(0, FUZZY_CAP)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Is `b` reachable from `a` by at most one insertion, deletion, substitution — or
|
|
374
|
+
* one transposition of adjacent characters?
|
|
375
|
+
*
|
|
376
|
+
* A bounded walk rather than an edit-distance matrix: O(len) rather than O(len²),
|
|
377
|
+
* which is what lets it run against hundreds of dictionary terms per query.
|
|
378
|
+
*
|
|
379
|
+
* ⭐ **THE TRANSPOSITION CASE IS NOT DECORATION.** Plain Levenshtein scores two
|
|
380
|
+
* crossed characters as **two** edits, so `fomr` → `form` — the shape of typo a
|
|
381
|
+
* touch typist actually produces — would have been the one this fallback could not
|
|
382
|
+
* fix. Damerau's addition is four lines and it is the difference between
|
|
383
|
+
* correcting real typing and correcting a textbook.
|
|
384
|
+
*
|
|
385
|
+
* ⚠️ **`teh` → `the` is the example everyone reaches for, and it does NOT work
|
|
386
|
+
* here** — at three characters it is below `FUZZY_MIN` and never reaches this
|
|
387
|
+
* function. *This comment used to cite it, which made the justification a case the
|
|
388
|
+
* code cannot handle.* Verified against a Damerau reference over 864 pairs: no
|
|
389
|
+
* false positives, no misses, 111 of them reachable only by transposition. Raising
|
|
390
|
+
* the floor's exception for transposition alone is defensible — a crossed pair is
|
|
391
|
+
* far less ambiguous at three letters than a substitution — but there is no corpus
|
|
392
|
+
* evidence for it, so it is not done on a hunch.
|
|
393
|
+
*/
|
|
394
|
+
function withinOneEdit(a, b) {
|
|
395
|
+
const la = a.length
|
|
396
|
+
const lb = b.length
|
|
397
|
+
if (Math.abs(la - lb) > 1) return false
|
|
398
|
+
let i = 0
|
|
399
|
+
let j = 0
|
|
400
|
+
let edited = false
|
|
401
|
+
while (i < la && j < lb) {
|
|
402
|
+
if (a[i] === b[j]) { i++; j++; continue }
|
|
403
|
+
if (edited) return false
|
|
404
|
+
edited = true
|
|
405
|
+
if (la === lb) {
|
|
406
|
+
// Adjacent transposition, checked before substitution: two crossed
|
|
407
|
+
// characters consume both positions at once.
|
|
408
|
+
if (a[i] === b[j + 1] && a[i + 1] === b[j]) { i += 2; j += 2; continue }
|
|
409
|
+
i++; j++ // substitution
|
|
410
|
+
} else if (la > lb) i++ // deletion from a
|
|
411
|
+
else j++ // insertion into a
|
|
412
|
+
}
|
|
413
|
+
// Whatever is left over is at most the one remaining edit.
|
|
414
|
+
return (la - i) + (lb - j) + (edited ? 1 : 0) <= 1
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ── Selection ───────────────────────────────────────────────────────────────
|
|
418
|
+
|
|
419
|
+
/** Best-first, with a deterministic tiebreak — ties by score fall back to doc order. */
|
|
420
|
+
const better = (a, b) => b.score - a.score || a.doc - b.doc
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The `k` best, sorted. O(n log k) with a bounded min-heap instead of O(n log n).
|
|
424
|
+
*
|
|
425
|
+
* ⛔ **THE TIEBREAK IS PART OF THE COMPARISON, not just the final sort.** A heap
|
|
426
|
+
* ordering on score alone would keep an arbitrary member of a tied group at the
|
|
427
|
+
* cutoff, so two identical corpora could return different results — the kind of
|
|
428
|
+
* nondeterminism that looks like a caching bug for a week.
|
|
429
|
+
*/
|
|
430
|
+
function selectTop(items, k) {
|
|
431
|
+
if (k >= items.length) return items.sort(better)
|
|
432
|
+
const heap = [] // min-heap: heap[0] is the WORST kept so far
|
|
433
|
+
const worse = (a, b) => better(b, a) < 0 // a is worse than b
|
|
434
|
+
const up = (i) => {
|
|
435
|
+
while (i > 0) {
|
|
436
|
+
const p = (i - 1) >> 1
|
|
437
|
+
// ⛔ CHILD vs PARENT, in that order. Reversed, this builds a MAX-heap: the
|
|
438
|
+
// root holds the BEST kept, so every later candidate is compared against
|
|
439
|
+
// the wrong end and the selection silently keeps the wrong members. Caught
|
|
440
|
+
// by a three-document fixture where top-2 returned the 1st and 3rd.
|
|
441
|
+
if (!worse(heap[i], heap[p])) break
|
|
442
|
+
;[heap[p], heap[i]] = [heap[i], heap[p]]
|
|
443
|
+
i = p
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
const down = () => {
|
|
447
|
+
let i = 0
|
|
448
|
+
for (;;) {
|
|
449
|
+
const l = 2 * i + 1
|
|
450
|
+
const r = l + 1
|
|
451
|
+
let m = i
|
|
452
|
+
if (l < heap.length && worse(heap[l], heap[m])) m = l
|
|
453
|
+
if (r < heap.length && worse(heap[r], heap[m])) m = r
|
|
454
|
+
if (m === i) break
|
|
455
|
+
;[heap[m], heap[i]] = [heap[i], heap[m]]
|
|
456
|
+
i = m
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
for (const x of items) {
|
|
460
|
+
if (heap.length < k) {
|
|
461
|
+
heap.push(x)
|
|
462
|
+
up(heap.length - 1)
|
|
463
|
+
} else if (better(x, heap[0]) < 0) {
|
|
464
|
+
heap[0] = x
|
|
465
|
+
down()
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return heap.sort(better)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Score a query against a built structure.
|
|
473
|
+
*
|
|
474
|
+
* ⭐ **ONLY DOCUMENTS CONTAINING A QUERY TERM ARE TOUCHED.** That is the whole
|
|
475
|
+
* point of inverting: cost is the sum of the matched terms' posting lists, not the
|
|
476
|
+
* corpus size. A query for a rare word on a 50,000-entry site reads a handful of
|
|
477
|
+
* postings.
|
|
478
|
+
*
|
|
479
|
+
* ⛔ **A term absent from the dictionary contributes NOTHING and does not fail.**
|
|
480
|
+
* A two-word query where one word is unknown still ranks on the other, which is
|
|
481
|
+
* what a visitor expects.
|
|
482
|
+
*
|
|
483
|
+
* @param {object} structure - from `buildSearchStructure`
|
|
484
|
+
* @param {string} query
|
|
485
|
+
* @param {Array} entries - the same array the structure was built from
|
|
486
|
+
* @param {object} [opts]
|
|
487
|
+
* @param {number} [opts.limit] - keep only the best N (0 = all)
|
|
488
|
+
* @param {boolean} [opts.prefix=true] - complete the last term as it is typed
|
|
489
|
+
* @param {boolean} [opts.fuzzy=true] - fall back to near-misses when nothing matched
|
|
490
|
+
* @returns {{hits: Array<{doc:number, score:number, fuzzy?:boolean}>, total:number,
|
|
491
|
+
* corrections?: Array<{term:string, to:string}>}}
|
|
492
|
+
* `corrections` is present only when the fuzzy fallback answered — one entry per
|
|
493
|
+
* query term it substituted, the commonest candidate for each — so a caller can
|
|
494
|
+
* render *"showing results for form"*. **Absent, never empty**, so presence is
|
|
495
|
+
* the branch.
|
|
496
|
+
*/
|
|
497
|
+
export function rankSearchEntries(structure, query, entries, opts = {}) {
|
|
498
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : 0
|
|
499
|
+
if (!structure || structure.v !== 1 || !structure.n) return { hits: [], total: 0 }
|
|
500
|
+
const qTerms = tokenize(query)
|
|
501
|
+
if (!qTerms.length) return { hits: [], total: 0 }
|
|
502
|
+
|
|
503
|
+
const termIds = idsFor(structure)
|
|
504
|
+
const { df, post, lenT, lenB, avgT, avgB, n } = structure
|
|
505
|
+
|
|
506
|
+
// ⭐ ONLY THE LAST TOKEN IS EXPANDED — it is the one that may still be being
|
|
507
|
+
// typed. Expanding earlier tokens would silently widen a query the visitor has
|
|
508
|
+
// already finished, which is a different and worse behaviour than completing the
|
|
509
|
+
// current word.
|
|
510
|
+
const wantPrefix = opts.prefix !== false && !/[\s]$/.test(String(query || ''))
|
|
511
|
+
const last = qTerms[qTerms.length - 1]
|
|
512
|
+
const weights = new Map() // term id -> multiplier
|
|
513
|
+
for (const t of new Set(qTerms)) {
|
|
514
|
+
const id = termIds.get(t)
|
|
515
|
+
if (id !== undefined) weights.set(id, 1)
|
|
516
|
+
}
|
|
517
|
+
// ⛔ EXPAND EVEN WHEN THE LAST TOKEN IS AN EXACT TERM. Gating this on
|
|
518
|
+
// `!termIds.has(last)` makes recall inconsistent in a way a visitor hits
|
|
519
|
+
// immediately: `tes` finds `testing`, and then typing the final `t` LOSES it,
|
|
520
|
+
// because `test` is itself a term. ⭐ *An exact match is not evidence that the
|
|
521
|
+
// visitor wanted only that word.* The exact id keeps weight 1 and completions
|
|
522
|
+
// stay at the penalty, so exactness still ranks first.
|
|
523
|
+
if (wantPrefix) {
|
|
524
|
+
for (const id of prefixTerms(structure, last)) {
|
|
525
|
+
if (!weights.has(id)) weights.set(id, PREFIX_PENALTY)
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
let scores = score(weights)
|
|
530
|
+
let fuzzy = false
|
|
531
|
+
let corrections = null
|
|
532
|
+
|
|
533
|
+
// The fallback, and it runs only on a total miss — see `fuzzyTerms`.
|
|
534
|
+
if (!scores.size && opts.fuzzy !== false) {
|
|
535
|
+
const weighted = new Map()
|
|
536
|
+
const substitutes = []
|
|
537
|
+
for (const t of new Set(qTerms)) {
|
|
538
|
+
const ids = fuzzyTerms(structure, t)
|
|
539
|
+
if (!ids.length) continue
|
|
540
|
+
for (const id of ids) if (!weighted.has(id)) weighted.set(id, 1)
|
|
541
|
+
// ⭐ ONE substitute per query term, and it is the COMMONEST of the
|
|
542
|
+
// candidates — the same reasoning the prefix cap uses: `df` order is the
|
|
543
|
+
// word a visitor was most likely reaching for. The others still score; this
|
|
544
|
+
// is only what a caller renders as "showing results for …".
|
|
545
|
+
let best = ids[0]
|
|
546
|
+
for (const id of ids) if (df[id] > df[best]) best = id
|
|
547
|
+
substitutes.push({ term: t, to: structure.terms[best] })
|
|
548
|
+
}
|
|
549
|
+
if (weighted.size) {
|
|
550
|
+
scores = score(weighted)
|
|
551
|
+
fuzzy = scores.size > 0
|
|
552
|
+
if (fuzzy) corrections = substitutes
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
if (!scores.size) return { hits: [], total: 0 }
|
|
556
|
+
|
|
557
|
+
// Weight is a property read, so every candidate can afford it.
|
|
558
|
+
const out = []
|
|
559
|
+
for (const [d, base] of scores) {
|
|
560
|
+
const e = (entries && entries[d]) || {}
|
|
561
|
+
const w = typeof e.weight === 'number' ? e.weight : DEFAULT_WEIGHT
|
|
562
|
+
out.push(fuzzy ? { doc: d, score: base * w, fuzzy: true } : { doc: d, score: base * w })
|
|
563
|
+
}
|
|
564
|
+
const total = out.length
|
|
565
|
+
|
|
566
|
+
// ⭐ SELECT, DO NOT SORT. Profiled at 50,000 entries with every document
|
|
567
|
+
// matching: posting traversal 2.4 ms, building these objects 1.8 ms, and the
|
|
568
|
+
// **full sort 13.5 ms** — two thirds of the query, thrown away one frame later
|
|
569
|
+
// when the caller slices to 20. A bounded selection does the same job in 0.7 ms.
|
|
570
|
+
//
|
|
571
|
+
// ⛔ The window must cover the PHRASE pass too, or a boost could promote
|
|
572
|
+
// something from outside it and be invisible. `total` is the count BEFORE
|
|
573
|
+
// truncation, so a caller can still say "showing 10 of 4,312".
|
|
574
|
+
const want = Math.max(limit || total, qTerms.length > 1 && !fuzzy ? PHRASE_WINDOW : 0) || total
|
|
575
|
+
const head = selectTop(out, Math.min(want, total))
|
|
576
|
+
|
|
577
|
+
// ⛔⛔ THE PHRASE BOOST IS BOUNDED TO THE TOP OF THE RANKING, and the comment
|
|
578
|
+
// here once claimed it was free.
|
|
579
|
+
//
|
|
580
|
+
// It read *"applied to CANDIDATES ONLY — a bounded set"*. **The candidate set is
|
|
581
|
+
// not bounded: for a common term it IS the corpus.** Measured at 50,000 entries
|
|
582
|
+
// — one word 14 ms, two words 61 ms, and 50,000 candidates — so re-folding every
|
|
583
|
+
// candidate's title and body gave back exactly the work inverting had saved.
|
|
584
|
+
// ⭐ *A plausible claim about one's own code, written as a virtue, never
|
|
585
|
+
// measured.*
|
|
586
|
+
//
|
|
587
|
+
// ⚖️ **The trade, stated rather than hidden:** a document below the window
|
|
588
|
+
// cannot be promoted by a phrase match. A 1.35× boost can only move something
|
|
589
|
+
// already near the top into it, so the bound costs ranking quality only where
|
|
590
|
+
// the score distribution is nearly flat — and it is what keeps a two-word query
|
|
591
|
+
// from costing four times a one-word query.
|
|
592
|
+
// ⛔ Skipped when the hits are corrections: the phrase is the ORIGINAL query,
|
|
593
|
+
// which by definition matched nothing literally, so no entry can contain it.
|
|
594
|
+
// Scanning up to `PHRASE_WINDOW` entries for a guaranteed miss is the whole cost
|
|
595
|
+
// with none of the benefit.
|
|
596
|
+
if (qTerms.length > 1 && !fuzzy) {
|
|
597
|
+
const phrase = fold(query)
|
|
598
|
+
const window = Math.min(head.length, PHRASE_WINDOW)
|
|
599
|
+
let boosted = false
|
|
600
|
+
for (let i = 0; i < window; i++) {
|
|
601
|
+
const e = (entries && entries[head[i].doc]) || {}
|
|
602
|
+
if (`${fold(e.title)} ${fold(e.content)}`.includes(phrase)) {
|
|
603
|
+
head[i].score *= PHRASE_BOOST
|
|
604
|
+
boosted = true
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (boosted) head.sort(better)
|
|
608
|
+
}
|
|
609
|
+
const out2 = { hits: limit ? head.slice(0, limit) : head, total }
|
|
610
|
+
// ⛔ ABSENT when nothing was corrected, never `[]` — a consumer branching on
|
|
611
|
+
// presence is the shape asked for, and an empty array reads as "corrected, to
|
|
612
|
+
// nothing".
|
|
613
|
+
if (corrections && corrections.length) out2.corrections = corrections
|
|
614
|
+
return out2
|
|
615
|
+
|
|
616
|
+
/** BM25F over a term-id → multiplier map. */
|
|
617
|
+
function score(weighted) {
|
|
618
|
+
const acc = new Map()
|
|
619
|
+
for (const [id, mult] of weighted) {
|
|
620
|
+
const docFreq = df[id]
|
|
621
|
+
// Lucene-style IDF: always positive, and a term in every document lands
|
|
622
|
+
// near zero rather than negative — which is what makes a stopword list
|
|
623
|
+
// unnecessary.
|
|
624
|
+
const idf = Math.log(1 + (n - docFreq + 0.5) / (docFreq + 0.5))
|
|
625
|
+
const flat = post[id]
|
|
626
|
+
for (let i = 0; i < flat.length; i += 3) {
|
|
627
|
+
const d = flat[i]
|
|
628
|
+
const tfT = flat[i + 1]
|
|
629
|
+
const tfB = flat[i + 2]
|
|
630
|
+
const normT = avgT ? tfT / (1 - B_TITLE + (B_TITLE * lenT[d]) / avgT) : 0
|
|
631
|
+
const normB = avgB ? tfB / (1 - B_BODY + (B_BODY * lenB[d]) / avgB) : 0
|
|
632
|
+
const tf = W_TITLE * normT + W_BODY * normB
|
|
633
|
+
if (tf <= 0) continue
|
|
634
|
+
acc.set(d, (acc.get(d) || 0) + mult * idf * ((tf * (K1 + 1)) / (tf + K1)))
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return acc
|
|
638
|
+
}
|
|
639
|
+
}
|
package/src/search/index.js
CHANGED
|
@@ -44,3 +44,18 @@ export {
|
|
|
44
44
|
} from './generate.js'
|
|
45
45
|
|
|
46
46
|
export { generateRecordSearchIndex } from './records.js'
|
|
47
|
+
|
|
48
|
+
// ── The engine: indexing and ranking the entries the generators above produce ──
|
|
49
|
+
//
|
|
50
|
+
// ⭐ Two jobs, one artifact between them. `generateSearchIndex` produces the
|
|
51
|
+
// ENTRIES; these index and rank them, so one site ranks the same wherever it is
|
|
52
|
+
// served. Contributed in full on 2026-09-06 from a server-side search lane that
|
|
53
|
+
// had measured the format against the alternative; the fuzzy fallback is ours.
|
|
54
|
+
export {
|
|
55
|
+
buildSearchStructure,
|
|
56
|
+
rankSearchEntries,
|
|
57
|
+
prefixTerms,
|
|
58
|
+
fuzzyTerms,
|
|
59
|
+
fold,
|
|
60
|
+
tokenize,
|
|
61
|
+
} from './engine.js'
|