groceries: relevance-ranked search (plain/basic products first)
All checks were successful
Deploy production / deploy (push) Successful in 1m13s

- 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
This commit is contained in:
2026-10-25 20:20:34 +00:00
parent 5028524537
commit 60370d4572
14 changed files with 31221 additions and 16 deletions

View File

@@ -0,0 +1,274 @@
// 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);