|
| 1 | +// Browser-side query engine for the v1 search substrate (#171, SEARCH_INDEX_V1.md). |
| 2 | +// |
| 3 | +// PURE functions only — no DuckDB, no fetch, no DOM. The explorer's flag |
| 4 | +// path feeds this module data it fetched via db.query; the split keeps every |
| 5 | +// piece of query logic unit-testable in Node |
| 6 | +// (tests/unit/search-substrate.test.mjs) and the explorer wiring thin. |
| 7 | +// |
| 8 | +// Pipeline (contract §3, §5, §6 two-tier rule): |
| 9 | +// tokenize (search_tokenizer.js) → drop query-time stopwords → dedupe → |
| 10 | +// two-tier hot policy (fetchable hot joins the AND; non-fetchable common |
| 11 | +// terms are dropped-with-disclosure, or an all-common query serves from |
| 12 | +// the hot_topk sidecar) → resolve substrate files → BM25 per posting → |
| 13 | +// field-weighted per-pid sums → AND across tokens → top-K. |
| 14 | + |
| 15 | +import { tokenize } from './search_tokenizer.js'; |
| 16 | + |
| 17 | +// FNV-1a 32-bit over UTF-8 bytes. MUST match tools/build_search_index.py's |
| 18 | +// fnv1a32 exactly — parity pinned by tests/search_fnv1a_regression.json |
| 19 | +// (values generated from the Python implementation). |
| 20 | +export function fnv1a32(str) { |
| 21 | + let h = 0x811c9dc5; |
| 22 | + const bytes = new TextEncoder().encode(str); |
| 23 | + for (const b of bytes) { |
| 24 | + h ^= b; |
| 25 | + // 32-bit multiply by the FNV prime 0x01000193 without BigInt: |
| 26 | + h = (h + (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) >>> 0; |
| 27 | + } |
| 28 | + return h >>> 0; |
| 29 | +} |
| 30 | + |
| 31 | +// Curated query-time stopword list (contract §3). Build-time indexes |
| 32 | +// EVERYTHING; dropping happens here only, so the policy stays reversible. |
| 33 | +export const QUERY_STOPWORDS = new Set([ |
| 34 | + 'a', 'an', 'the', 'of', 'from', 'for', 'to', 'in', 'on', 'at', |
| 35 | + 'is', 'was', 'with', 'and', 'or', |
| 36 | +]); |
| 37 | + |
| 38 | +// Field weights (contract §5) + BM25 constants. |
| 39 | +export const FIELD_WEIGHTS = { |
| 40 | + 'sample.label': 3.0, |
| 41 | + 'concept.label': 2.5, |
| 42 | + 'sample.place_name': 2.0, |
| 43 | + 'sample.description': 1.0, |
| 44 | +}; |
| 45 | +export const BM25_K1 = 1.2; |
| 46 | +export const BM25_B = 0.75; |
| 47 | +export const TOP_K = 50; |
| 48 | + |
| 49 | +const shardFile = (token, shardCount) => |
| 50 | + `shard_${String(fnv1a32(token) % shardCount).padStart(3, '0')}.parquet`; |
| 51 | + |
| 52 | +/** |
| 53 | + * Plan a query under the §6 two-tier hot rule. |
| 54 | + * |
| 55 | + * @param term raw user input |
| 56 | + * @param manifest { shardCount, hotTokens } — shardCount from |
| 57 | + * build_stats.json; hotTokens = hot_tokens.json's `tokens` |
| 58 | + * ({token: {key, sub_files, postings, total_bytes, |
| 59 | + * fetchable}}). |
| 60 | + * @param shardSizes optional shard_sizes.json object ({file: bytes}); when |
| 61 | + * given, the plan carries expectedBytes so callers can |
| 62 | + * report transfer up-front (§7). |
| 63 | + * @returns {{ |
| 64 | + * mode: 'empty'|'allStopwords'|'normal'|'topk', |
| 65 | + * tokens: string[], // tokens participating in the AND |
| 66 | + * ignoredCommon: string[], // non-fetchable hot terms dropped (§3 — |
| 67 | + * // the UI MUST disclose these) |
| 68 | + * files: string[], // deduped relative substrate paths to fetch |
| 69 | + * filesByToken: Map<string, string[]>, |
| 70 | + * expectedBytes: number|null, |
| 71 | + * }} |
| 72 | + */ |
| 73 | +export function planQuery(term, manifest, shardSizes = null) { |
| 74 | + const raw = tokenize(term); |
| 75 | + const survivors = []; |
| 76 | + for (const t of raw) { |
| 77 | + if (QUERY_STOPWORDS.has(t)) continue; |
| 78 | + if (!survivors.includes(t)) survivors.push(t); // duplicate-term dedup |
| 79 | + } |
| 80 | + const base = { ignoredCommon: [], files: [], filesByToken: new Map(), expectedBytes: null }; |
| 81 | + if (raw.length === 0) return { ...base, mode: 'empty', tokens: [] }; |
| 82 | + if (survivors.length === 0) return { ...base, mode: 'allStopwords', tokens: [] }; |
| 83 | + |
| 84 | + const hot = manifest.hotTokens || {}; |
| 85 | + const participating = []; |
| 86 | + const common = []; |
| 87 | + for (const t of survivors) { |
| 88 | + const h = hot[t]; |
| 89 | + if (h && !h.fetchable) common.push(t); |
| 90 | + else participating.push(t); |
| 91 | + } |
| 92 | + |
| 93 | + if (participating.length === 0) { |
| 94 | + // Every surviving term is a common term: rank via the sidecar. |
| 95 | + return { |
| 96 | + mode: 'topk', tokens: common, ignoredCommon: [], |
| 97 | + files: ['hot_topk.parquet'], |
| 98 | + filesByToken: new Map(common.map(t => [t, ['hot_topk.parquet']])), |
| 99 | + expectedBytes: null, |
| 100 | + }; |
| 101 | + } |
| 102 | + |
| 103 | + const filesByToken = new Map(); |
| 104 | + const files = []; |
| 105 | + for (const t of participating) { |
| 106 | + const h = hot[t]; |
| 107 | + const tokenFiles = (h && h.fetchable) |
| 108 | + ? Array.from({ length: h.sub_files }, |
| 109 | + (_, m) => `hot/${h.key}_p${m}.parquet`) |
| 110 | + : [shardFile(t, manifest.shardCount)]; |
| 111 | + filesByToken.set(t, tokenFiles); |
| 112 | + for (const f of tokenFiles) if (!files.includes(f)) files.push(f); |
| 113 | + } |
| 114 | + let expectedBytes = null; |
| 115 | + if (shardSizes) { |
| 116 | + expectedBytes = 0; |
| 117 | + const counted = new Set(); |
| 118 | + for (const t of participating) { |
| 119 | + const h = hot[t]; |
| 120 | + if (h && h.fetchable) { |
| 121 | + if (!counted.has(h.key)) { expectedBytes += h.total_bytes; counted.add(h.key); } |
| 122 | + } else { |
| 123 | + const f = shardFile(t, manifest.shardCount); |
| 124 | + if (!counted.has(f)) { expectedBytes += shardSizes[f] ?? 0; counted.add(f); } |
| 125 | + } |
| 126 | + } |
| 127 | + } |
| 128 | + return { |
| 129 | + mode: 'normal', tokens: participating, ignoredCommon: common, |
| 130 | + files, filesByToken, expectedBytes, |
| 131 | + }; |
| 132 | +} |
| 133 | + |
| 134 | +/** |
| 135 | + * BM25 contribution of one posting row (contract §5). |
| 136 | + * @param row { field, tf, doc_len, df } — df is EMBEDDED in shipped rows |
| 137 | + * (round-5 amendment; df.parquet is offline-only). |
| 138 | + * @param stats { totalDocs, avgDocLenByField } — from build_stats.json: |
| 139 | + * totalDocs = total_documents (distinct (pid, field) docs); |
| 140 | + * avgDocLenByField[field] = fields[field].avg_doc_len |
| 141 | + * (PER-FIELD corpus averages, matching the builder's |
| 142 | + * hot_topk scoring). |
| 143 | + */ |
| 144 | +export function bm25Contribution(row, stats) { |
| 145 | + const idf = Math.log((stats.totalDocs - row.df + 0.5) / (row.df + 0.5) + 1); |
| 146 | + const avgDl = stats.avgDocLenByField[row.field]; |
| 147 | + const norm = row.tf * (BM25_K1 + 1) |
| 148 | + / (row.tf + BM25_K1 * (1 - BM25_B + BM25_B * row.doc_len / avgDl)); |
| 149 | + const weight = FIELD_WEIGHTS[row.field] ?? 1.0; |
| 150 | + return weight * idf * norm; |
| 151 | +} |
| 152 | + |
| 153 | +/** |
| 154 | + * Combine postings into the final AND-ranked top-K. |
| 155 | + * |
| 156 | + * @param postingsByToken Map<token, Array<{pid, field, tf, doc_len, df}>> |
| 157 | + * @param stats { totalDocs, avgDocLenByField } |
| 158 | + * @param k top-K cap (default contract TOP_K = 50) |
| 159 | + * @returns Array<{pid, score}> sorted score desc, pid asc for stable ties. |
| 160 | + */ |
| 161 | +export function combineAndRank(postingsByToken, stats, k = TOP_K) { |
| 162 | + const scores = new Map(); // pid -> summed score |
| 163 | + const matched = new Map(); // pid -> Set<token> |
| 164 | + for (const [token, rows] of postingsByToken) { |
| 165 | + for (const row of rows) { |
| 166 | + scores.set(row.pid, (scores.get(row.pid) ?? 0) + bm25Contribution(row, stats)); |
| 167 | + if (!matched.has(row.pid)) matched.set(row.pid, new Set()); |
| 168 | + matched.get(row.pid).add(token); |
| 169 | + } |
| 170 | + } |
| 171 | + const need = postingsByToken.size; // AND semantics: every token matches |
| 172 | + const out = []; |
| 173 | + for (const [pid, tokens] of matched) { |
| 174 | + if (tokens.size === need) out.push({ pid, score: scores.get(pid) }); |
| 175 | + } |
| 176 | + out.sort((a, b) => (b.score - a.score) || (a.pid < b.pid ? -1 : 1)); |
| 177 | + return out.slice(0, k); |
| 178 | +} |
0 commit comments