groceries: relevance-ranked search (plain/basic products first)
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
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:
@@ -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}`
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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' } });
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user