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

@@ -4,6 +4,13 @@ import { refreshGroupStats } from '#lib/server/groceries/import.js';
import { parseEuros } from '#lib/groceries.js';
import { displayName, fold, parseSize } from '#lib/server/groceries/normalize.js';
import { HOMEWARE_TOPS } from '#lib/server/groceries/taxonomy.js';
import {
expand,
queryWords,
rankedIds,
searchConds,
searchContext
} from '#lib/server/groceries/search.js';
import { deleteObject, groceryImageKey, uploadImage } from '#lib/server/r2.js';
import { fail } from '@sveltejs/kit';
import type { Sql } from 'postgres';
@@ -112,7 +119,7 @@ export const load: PageServerLoad = async (event) => {
const s = sql();
const p = event.url.searchParams;
const q = fold(p.get('q')?.trim() ?? '').slice(0, 100);
const words = q.split(/\s+/).filter(Boolean).slice(0, 6);
const words = q ? expand(queryWords(q), (await searchContext(s)).synonyms) : [];
const storeSlug = p.get('store') ?? '';
const catSlug = p.get('cat') ?? '';
const sortParam = p.get('sort') ?? '';
@@ -148,8 +155,7 @@ export const load: PageServerLoad = async (event) => {
const showNonfood = nonfood || (cat ? homewareIds.includes(cat.id) : false);
const conds = [s`g.merged_into_id is null`, s`(g.item_count > 0 or g.favorite)`];
for (const w of words)
conds.push(s`g.search_text like ${'%' + w.replace(/[%_\\]/g, '\\$&') + '%'}`);
conds.push(...searchConds(s, words));
if (store)
conds.push(s`(g.store_ids @> array[${store.id}]::int[] or exists (
select 1 from groceries.manual_prices m where m.group_id = g.id and m.store_id = ${store.id}))`);
@@ -178,8 +184,20 @@ export const load: PageServerLoad = async (event) => {
? s`g.store_count desc, g.item_count desc`
: s`g.favorite desc, ${relevance} g.store_count desc, g.item_count desc`;
// With a search query the default and promo sorts use relevance ranking (search.ts):
// plain/basic products first, flavoured and specialised ones further down.
const relevant = words.length > 0 && (sort === '' || sort === 'promo');
const [groups, [{ total }]] = await Promise.all([
s<GroupRow[]>`
relevant
? rankedIds(s, where, q, words, { promoFirst: sort === 'promo' }).then(async (all) => {
const ids = all.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
if (!ids.length) return [] as GroupRow[];
const rows = await s<GroupRow[]>`
select ${GROUP_COLS(s)} from groceries.groups g where g.id = any(${ids}::int[])`;
const pos = new Map(ids.map((id, i) => [id, i]));
return rows.sort((a, b) => pos.get(a.id)! - pos.get(b.id)!);
})
: s<GroupRow[]>`
select ${GROUP_COLS(s)} from groceries.groups g where ${where}
order by ${order}, g.name, g.id limit ${PAGE_SIZE} offset ${(page - 1) * PAGE_SIZE}`,
s<{ total: number }[]>`select count(*)::int as total from groceries.groups g where ${where}`

View File

@@ -54,6 +54,7 @@
formatAmount,
formatCents,
formatUnitPrice,
packSizes,
parseEuros,
sortByPrice,
toOffers,
@@ -178,6 +179,7 @@
);
return {
g,
sizes: packSizes(itemsByGroup.get(g.id) ?? []),
...bestPerStore(offers, mode),
anyPromo: offers.some((o) => o.promoLabel || o.regularCents)
};
@@ -657,7 +659,11 @@
<div class="row-title">{g.name}</div>
<div class="meta flex flex-wrap items-center gap-x-1.5">
{#if g.brand}<span>{g.brand}</span>{/if}
{#if r.best?.sizeText}<span>· {r.best.sizeText}</span>{/if}
{#if r.sizes.length > 1}<span class="sizes" title="Pack sizes"
>· {r.sizes.slice(0, 4).join(' / ')}{r.sizes.length > 4
? ` +${r.sizes.length - 4}`
: ''}</span
>{:else if r.best?.sizeText}<span>· {r.best.sizeText}</span>{/if}
{#if r.stores.length > 1}<span>· {r.stores.length} stores</span>{/if}
{#if r.spread && r.spread.pct > 0}<span
class="spread"

View File

@@ -1,6 +1,13 @@
import { db } from '#lib/server/db/index.js';
import { requireOwner } from '#lib/server/owner.js';
import { fold } from '#lib/server/groceries/normalize.js';
import {
expand,
queryWords,
rankedIds,
searchConds,
searchContext
} from '#lib/server/groceries/search.js';
import { json } from '@sveltejs/kit';
import type { Sql } from 'postgres';
import type { RequestHandler } from './$types';
@@ -9,16 +16,20 @@ import type { RequestHandler } from './$types';
export const GET: RequestHandler = async (event) => {
requireOwner(event);
const q = fold(event.url.searchParams.get('q')?.trim() ?? '').slice(0, 100);
const words = q.split(/\s+/).filter(Boolean).slice(0, 6);
if (!words.length) return json([]);
const s = db.$client as unknown as Sql;
const conds = words.map((w) => s`search_text like ${'%' + w.replace(/[%_\\]/g, '\\$&') + '%'}`);
const rows = await s`
const words = expand(queryWords(q), (await searchContext(s)).synonyms);
if (!words.length) return json([]);
const where = [s`merged_into_id is null`, ...searchConds(s, words)].reduce(
(a, c) => s`${a} and ${c}`
);
const ids = (await rankedIds(s, where, q, words)).slice(0, 15);
const rows = ids.length
? await s<{ id: number }[]>`
select id, name, brand, unit, store_count as "storeCount", item_count as "itemCount",
image_url as "imageUrl", min_price_cents as "minPriceCents"
from groceries.groups
where merged_into_id is null and ${conds.reduce((a, c) => s`${a} and ${c}`)}
order by public.word_similarity(${q}::text, lower(name)) desc, store_count desc, item_count desc, id
limit 15`;
from groceries.groups g where id = any(${ids}::int[])`
: [];
const pos = new Map(ids.map((id, i) => [id, i]));
rows.sort((a, b) => pos.get(a.id)! - pos.get(b.id)!);
return json(rows, { headers: { 'cache-control': 'private, no-store' } });
};