Skip to content

Commit e8c92db

Browse files
authored
Merge pull request #330 from rdhyee/feat/171-substrate-query
#171: FTS Track 4 — substrate search path behind ?fts=v1
2 parents 9108b61 + 15fd2ce commit e8c92db

6 files changed

Lines changed: 717 additions & 7 deletions

File tree

_quarto.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ project:
55
- assets/js/source-palette.js
66
- assets/js/sql-builders.js
77
- assets/js/explorer-utils.js
8+
# #171: the substrate search engine + tokenizer are ES modules imported
9+
# by explorer.qmd at page runtime — without these resource entries the
10+
# import 404s and the OJS dependency cascade silently kills the entire
11+
# search cell (boot fine, search dead: exactly what the smoke's 150s
12+
# search timeout looks like).
13+
- assets/js/search_substrate.js
14+
- assets/js/search_tokenizer.js
815
# #295: quarto.js unconditionally fetches /listings.json on every page
916
# whose frontmatter declares `categories:` (explorer.qmd and others do,
1017
# for the tag chips under the title). Quarto only ever GENERATES that

assets/js/search_substrate.js

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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

Comments
 (0)