Files
website/src/lib/server/groceries/search.ts
Stan Runge 60370d4572
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
groceries: relevance-ranked search (plain/basic products first)
- search.ts: score on the core name (brand/size stripped): exact or query+base suffix
  (kipdij+filet), head-final compounds, prefix, inside; penalties per extra word and per
  flavour/processing word (search-words.ts, editable); bonuses for store brands, the
  query's dominant category, store count, favourites; trigram tiebreak
- groceries.synonyms table (seeded: kipdij/kippendij, pasta → spaghetti, …) expands queries
- list rows show all pack sizes of a group; group picker uses the same ranking
- scripts/groceries-search-eval.ts prints top results + latency; preflight now 8 tables
2026-10-25 20:20:34 +00:00

275 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Relevance ranking for grocery search: plain/basic products first, flavoured and specialised
// ones further down. Pure scoring (tested in search.test.ts) plus a small DB layer that
// fetches candidates and the synonyms table.
//
// Score = text match on the core name (brand and size stripped)
// - complexity (extra words, flavour/processing words; lists in search-words.ts)
// + bonuses (store brand, the query's dominant category, sold in more stores, favourite)
// + trigram similarity as a tiebreak.
import type { PendingQuery, Row, Sql } from 'postgres';
import { fold, nameTokens, stem } from './normalize.js';
import { BASE_SUFFIXES, MODIFIERS, STORE_BRAND_PREMIUM, STORE_BRANDS } from './search-words.js';
export type Candidate = {
id: number;
name: string;
brand: string | null;
categoryId: number | null;
storeCount: number;
itemCount: number;
favorite: boolean;
promoCount?: number;
/** pg_trgm word similarity of the query to the name, 0..1 */
sim?: number;
};
export type Scored = Candidate & { score: number; text: number; penalty: number };
/** term → extra search terms (folded). One-directional; seed both ways for spelling variants. */
export type Synonyms = Map<string, string[]>;
const SUFFIXES = new Map(Object.entries(BASE_SUFFIXES).map(([w, v]) => [stem(fold(w)), v]));
const MODS = new Map(Object.entries(MODIFIERS).map(([w, v]) => [stem(fold(w)), v]));
const EXTRA_WORD = 10;
const MOD_WORD = 22;
const SYN_FACTOR = 0.92;
/** Query → folded words (max 6). */
export function queryWords(q: string): string[] {
return fold(q)
.replace(/['’]/g, '')
.split(/[^a-z0-9%+]+/)
.filter(Boolean)
.slice(0, 6);
}
/** Each query word with its synonyms: [[word, syn, …], …] (folded, deduped). */
export function expand(words: string[], synonyms: Synonyms): string[][] {
return words.map((w) => [...new Set([w, ...(synonyms.get(w) ?? [])])]);
}
/** Cost of a glued run of base suffixes ("filet" 0, "filetblokje" 5, "" 0); null if not one. */
function suffixCost(r: string, depth = 0): number | null {
if (!r) return 0;
if (depth > 3) return null;
let best: number | null = null;
for (const [s, v] of SUFFIXES)
if (r.startsWith(s)) {
const rest = suffixCost(r.slice(s.length), depth + 1);
if (rest != null && (best == null || v + rest < best)) best = v + rest;
}
return best;
}
/** Penalty for a leftover word or compound part (small or 0 for base suffixes). */
export function wordPenalty(w: string): number {
if (!w || w.length < 2 || /^\d+[%+]?$/.test(w)) return 0;
const s = stem(w);
const sc = suffixCost(s) ?? suffixCost(w);
if (sc != null) return sc;
const m = MODS.get(s) ?? MODS.get(w);
if (m != null) return m * MOD_WORD;
// Compounds containing a flavour/processing word (madrasfilet, chocoladereep, grillworst).
let m2 = 0;
for (const [k, v] of MODS) if (v >= 0.5 && k.length > 3 && s.includes(k)) m2 = Math.max(m2, v);
return m2 ? Math.max(m2 * MOD_WORD, EXTRA_WORD) : EXTRA_WORD;
}
type Hit = { level: number; token: number; leftover: string; cost: number };
/** Best match of one term against the core tokens (stemmed). */
function matchTerm(term: string, tokens: string[]): Hit | null {
let best: Hit | null = null;
const take = (h: Hit) => {
if (!best || h.level - h.cost > best.level - best.cost) best = h;
};
tokens.forEach((t, i) => {
for (const e of new Set([term, stem(term)])) {
if (e.length < 2) continue;
const at = t.indexOf(e);
if (at < 0) continue;
const after = t.slice(at + e.length);
const cost = suffixCost(after);
if (at === 0 && cost != null) take({ level: 100, token: i, leftover: '', cost });
else if (at > 0 && cost != null)
// Dutch compounds are head-final: zilvervliesrijst is rijst, scharrelkipdijfilet is kipdij.
take({ level: 80, token: i, leftover: t.slice(0, at), cost });
else if (at === 0)
// kipdijspiesjes: starts with the query but is something else.
take({ level: 60, token: i, leftover: after, cost: 0 });
else take({ level: 40, token: i, leftover: t.slice(0, at) + ' ' + after, cost: 0 });
}
});
return best;
}
/** Text score (0..106: 100 exact or query+suffix, 80 compound end, 60 prefix, 40 inside, +6 first word) and complexity penalty. */
export function textScore(c: Pick<Candidate, 'name' | 'brand'>, words: string[][]) {
const tokens = nameTokens(c.name, c.brand);
const brandToks = new Set(
fold(c.brand ?? '')
.split(/[^a-z0-9]+/)
.filter(Boolean)
);
const used = new Set<number>();
let total = 0;
let penalty = 0;
let first = false;
for (const alts of words) {
let best: (Hit & { score: number }) | null = null;
alts.forEach((term, k) => {
const h = matchTerm(term, tokens);
if (!h) return;
const score = h.level * (k === 0 ? 1 : SYN_FACTOR);
if (!best || score - h.cost > best.score - best.cost) best = { ...h, score };
});
if (best) {
const b = best as Hit & { score: number };
total += b.score;
used.add(b.token);
if (b.token === 0) first = true;
penalty += b.cost;
for (const part of b.leftover.split(' ')) penalty += wordPenalty(part);
} else if (alts.some((a) => brandToks.has(a))) total += 70;
}
tokens.forEach((t, i) => {
if (!used.has(i)) penalty += wordPenalty(t);
});
const text = words.length ? total / words.length : 0;
return { text: text + (first && text > 0 ? 6 : 0), penalty: Math.min(penalty, 70) };
}
/** Store/private-label bonus from the group's brand label. */
export function brandBonus(brand: string | null): number {
const b = fold(brand ?? '').trim();
if (!b) return 12; // loose produce
if (STORE_BRAND_PREMIUM.includes(b)) return 6;
if (b === 'huismerk' || b === 'huismerk basic') return 15;
if (b.startsWith('huismerk')) return 10;
if (STORE_BRANDS.some((p) => b === p || b.startsWith(p + ' '))) return 12;
return 0;
}
export type Ctx = {
words: string[][];
/** category id → its top-level id */
topOf: (id: number | null) => number | null;
};
/** Score and sort candidates (best first). */
export function rank(cands: Candidate[], ctx: Ctx, opts: { promoFirst?: boolean } = {}): Scored[] {
const pre = cands.map((c) => ({ c, ...textScore(c, ctx.words) }));
// Dominant category: the most common one among strong name matches; plain products (few
// extra words) and products sold in more stores decide what the query is about.
const dominant = (min: number) => {
const w = new Map<number, number>();
for (const p of pre)
if (p.text >= min && p.c.categoryId != null)
w.set(
p.c.categoryId,
(w.get(p.c.categoryId) ?? 0) + (1 + p.c.storeCount) / (1 + p.penalty / 5) ** 2
);
return [...w].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
};
const dom = dominant(90) ?? dominant(75) ?? dominant(55);
const domTop = ctx.topOf(dom);
const out = pre.map(({ c, text, penalty }) => {
let score = text - penalty;
if (text === 0) score = (c.sim ?? 0) * 30 - 40; // matched only via brand/category/other names
score += brandBonus(c.brand);
if (dom != null) {
if (c.categoryId === dom) score += 12;
else if (domTop != null && ctx.topOf(c.categoryId) === domTop) score += 5;
else score -= 10;
}
score += Math.min(Math.max(c.storeCount - 1, 0), 5) * 4;
score += Math.min(Math.log2(1 + c.itemCount), 4);
if (c.favorite) score += 10;
score += (c.sim ?? 0) * 5;
return {
...c,
score: Math.round(score * 100) / 100,
text,
penalty: Math.round(penalty * 10) / 10
};
});
return out.sort(
(a, b) =>
(opts.promoFirst ? Number((b.promoCount ?? 0) > 0) - Number((a.promoCount ?? 0) > 0) : 0) ||
b.score - a.score ||
b.storeCount - a.storeCount ||
b.itemCount - a.itemCount ||
a.name.length - b.name.length ||
a.id - b.id
);
}
// ---- DB --------------------------------------------------------------------------------------
type SearchCtx = { at: number; synonyms: Synonyms; parent: Map<number, number | null> };
let cache: SearchCtx | null = null;
const TTL = 5 * 60_000;
/** Synonyms table + category parents, cached for 5 minutes. */
export async function searchContext(s: Sql): Promise<SearchCtx> {
if (cache && Date.now() - cache.at < TTL) return cache;
const [syn, cats] = await Promise.all([
s<{ term: string; synonym: string }[]>`select term, synonym from groceries.synonyms`,
s<{ id: number; parent_id: number | null }[]>`select id, parent_id from groceries.categories`
]);
cache = {
at: Date.now(),
synonyms: synonymMap(syn),
parent: new Map(cats.map((c) => [c.id, c.parent_id]))
};
return cache;
}
export function synonymMap(rows: { term: string; synonym: string }[]): Synonyms {
const m: Synonyms = new Map();
for (const r of rows) {
const t = fold(r.term).trim();
m.set(t, [...(m.get(t) ?? []), fold(r.synonym).trim()]);
}
return m;
}
const like = (w: string) => '%' + w.replace(/[%_\\]/g, '\\$&') + '%';
/** WHERE fragments: every query word (or one of its synonyms) appears in g.search_text. */
export function searchConds(s: Sql, words: string[][]) {
return words.map((alts) =>
alts.length === 1
? s`g.search_text like ${like(alts[0])}`
: s`g.search_text like any(${alts.map(like)}::text[])`
);
}
const MAX_CANDIDATES = 4000;
/**
* Rank the groups matching `where` (alias g) for query `q`, best first. Up to MAX_CANDIDATES;
* groups whose name contains a query word are fetched first so broad queries keep them.
*/
export async function rankedGroups(
s: Sql,
where: PendingQuery<Row[]>,
q: string,
words: string[][],
opts: { promoFirst?: boolean; ctx?: SearchCtx } = {}
): Promise<Scored[]> {
const ctx = opts.ctx ?? (await searchContext(s));
const nameLike = words.flat().map(like);
const cands = await s<Candidate[]>`
select g.id, g.name, g.brand, g.category_id as "categoryId", g.store_count as "storeCount",
g.item_count as "itemCount", g.favorite, g.promo_count as "promoCount",
public.word_similarity(${q}::text, lower(g.name))::float8 as sim
from groceries.groups g where ${where}
order by (lower(g.name) like any(${nameLike}::text[])) desc, g.store_count desc, g.item_count desc
limit ${MAX_CANDIDATES}`;
const topOf = (id: number | null) => (id == null ? null : (ctx.parent.get(id) ?? id));
return rank(cands, { words, topOf }, opts);
}
/** rankedGroups(), ids only. */
export const rankedIds = async (...a: Parameters<typeof rankedGroups>) =>
(await rankedGroups(...a)).map((c) => c.id);