- Opublikowano
Wyszukiwarka, która sprawia wrażenie AI: techniczny deep dive
- Autorzy
Zdarzyło ci się kiedyś trafić na wyszukiwarkę, która po prostu rozumie? Wpisujesz „biała umywalka łazienkowa nowoczesna”, a ona jakimś cudem wie, że chodzi o to samo co „nowoczesna biała umywalka do łazienki”, albo nawet co „modern white bathroom sink” po angielsku. To nie magia – to dobrze zaprojektowana inżynieria.
Dziś odsłaniam kulisy systemu wyszukiwania, który zbudowałem i który codziennie obsługuje tysiące zapytań produktowych w 9 językach. Nie stoi za nim ani ChatGPT, ani Claude – to czysty PostgreSQL, TypeScript i kilka naprawdę sprytnych algorytmów, które sprawiają, że całość wygląda, jakby napędzało ją AI.
Dlaczego większość wyszukiwarek jest beznadziejna
Powiedzmy sobie szczerze – wyszukiwanie w e-commerce zwykle jest tragiczne. Szukasz „umywalka łazienkowa biała” i nie dostajesz nic, bo produkt siedzi w bazie jako „Biała misa umywalkowa do łazienki”. Zrobiłeś literówkę? Powodzenia. Szukasz w innym języku? Zapomnij.
Problem polega na tym, że tradycyjne wyszukiwarki myślą jak komputery, a nie jak ludzie. Dopasowują słowa kluczowe dokładnie i na tym kończą. A ludzie są chaotyczni:
- Robimy literówki: „umywalak łazienkowa” → „umywalka łazienkowa”
- Używamy różnych słów: „umywalka” vs „misa umywalkowa” vs „umywalka nablatowa”
- Dorzucamy szum: „umywalka do nowoczesnej łazienki” vs „nowoczesna umywalka łazienkowa”
- Szukamy w różnych językach: „łazienka” → „bathroom”
Nasz system wyszukiwania radzi sobie z tym wszystkim. Zobaczmy jak.
Architektura złożona z pięciu warstw
Warstwa 1: sprytny preprocessing zapytania
Pierwszy krok to zrozumienie, co człowiek naprawdę ma na myśli, kiedy szuka. Zaczyna się od usunięcia szumu semantycznego – tych wszystkich krótkich słów, które nic nie wnoszą do wyszukiwania.
const PREPOSITIONS: { [key in Language]: string[] } = {
pl: ['bez', 'dla', 'do', 'na', 'nad', 'pod', 'przy', 'w', 'z', 'za' /* ...150+ more */],
de: ['an', 'auf', 'bei', 'durch', 'für', 'gegen', 'in', 'mit' /* ...40+ more */],
en: ['about', 'above', 'across', 'after', 'against', 'along' /* ...40+ more */],
// ...6 more languages (Russian, Hungarian, Romanian, French, Italian, Ukrainian, Slovenian, Spanish)
}
const q = removeDiacritics(removeWordsBetweenFirstAndLast(qRaw, PREPOSITIONS[lang]))
Dlaczego to działa: ludzie szukają tak, jak mówią. „umywalka do łazienki” i „umywalka łazienkowa” powinny zwrócić identyczne wyniki. Nasza funkcja removeWordsBetweenFirstAndLast zostawia pierwsze i ostatnie słowo (zwykle najważniejsze), a przyimki ze środka odfiltrowuje.
Normalizacja znaków diakrytycznych ogarnia znaki międzynarodowe – „ñ” staje się „n”, „ş” staje się „s”, dzięki czemu wyszukiwanie działa niezależnie od języka.
Ale najciekawszy jest ten fragment – generowanie kodów złożonych:
const perfectCodes = splitQ.reduce((previous, current, index) => {
if (!splitQ[index + 1]) {
return [...previous, current]
}
return [
...previous,
current,
`${current}_${splitQ[index + 1]}`, // Creates compound codes
]
}, [] as string[])
Jeśli ktoś wpisze „ABC DEF”, powstanie z tego ["ABC", "ABC_DEF", "DEF"]. Idealne dla produktów z kodami złożonymi albo gdy użytkownik wpisuje fragment kodu produktu.
Warstwa 2: budowa ważonego wektora
Tutaj robi się naprawdę ciekawie. Każdy produkt dostaje przeszukiwalny „wektor nazwy” zbudowany z kilku komponentów o różnych wagach:
export const buildNameVector = (
language: Language,
product: productBuildNameVectorSelect
): string => {
const nameCore = addWeightToString(
product.nameCore?.translations?.[language] ?? '',
13 // Highest weight - main product name
)
const namePost = addWeightToString(
product.namePost?.translations?.[language] ?? '',
8 // Secondary name component
)
const nameFeature1 = addWeightToString(
product.nameFeature1?.translations?.[language] ?? '',
5 // Feature descriptions
)
const nameFeature2 = addWeightToString(product.nameFeature2?.translations?.[language] ?? '', 5)
const collection = addWeightToString(
product.collection?.name ?? '',
3 // Collection/series name
)
const tags = (product.tags ?? []).flatMap((tag) => {
const tags = tag.name?.[language] ?? ''
return tags.split(',').map((value) => addWeightToString(value, 2)) // Product tags
})
const productFinish = (product.productFinish ?? []).flatMap((finish) => {
const finishes = finish.translations?.[language] ?? ''
return finishes.split(',').map((value) => addWeightToString(value, 1)) // Lowest weight - finishes
})
return [nameCore, namePost, nameFeature1, nameFeature2, collection, ...tags, ...productFinish]
.filter((text) => !!text)
.map((text) => removeDiacritics(text.trim()).toUpperCase())
.join(' ')
}
Funkcja addWeightToString to majstersztyk:
const addWeightToString = (value: string, weight: number) => {
if (!value) return value
return value
.split(' ')
.filter((value) => value)
.map((value, index) => `${weight - index > 0 ? weight - index : 1}:${value}`)
.join(' ')
}
Powstają z tego ważone termy w rodzaju "13:MODERN 12:BATHROOM 11:SINK 8:WHITE 7:CERAMIC". Kiedy funkcje podobieństwa w PostgreSQL je porównują, słowa z wyższą wagą mają większy wpływ na wynik.
Dlaczego to podejście jest tak dobre:
- Główne nazwy produktów mają maksymalny wpływ (waga 13)
- Cechy drugorzędne mają umiarkowany wpływ (waga 5-8)
- Wykończenia i tagi dodają kontekst (waga 1-3)
- Każde kolejne słowo w polu dostaje niższą wagę (chroni przed upychaniem słów kluczowych)
Warstwa 3: trzypoziomowa strategia wyszukiwania
Sercem systemu jest przemyślane podejście na trzech poziomach:
Poziom 1: trafienia dokładne (priorytet 2)
WHEN ean = ANY(${splitQ}) OR code = ANY(${perfectCodes}) THEN 2
Idealne dla dokładnych kodów EAN albo kodów produktowych. Te dostają absolutny priorytet.
Poziom 2: trafienia częściowe (priorytet 1)
WHEN code LIKE ANY(${codes}) OR EXISTS (
SELECT 1 FROM public."Translation" AS nv
WHERE nv."id" = nameVectorId
AND to_tsvector('simple', nv.${langField}) @@ to_tsquery('simple', ${tsQuery})
) THEN 1
Tu obsługujemy częściowe dopasowania kodów i full-text search na systemie tsvector z PostgreSQL.
Przygotowanie tsQuery jest piękne:
const tsQuery = splitQ
.map((q) => q.replace(/\W/g, '')) // Sanitize: remove non-word characters
.filter((term) => term.length > 0) // Remove empty terms
.map((term) => (term.length > 3 ? `${term.slice(0, term.length - 1)}:*` : `${term}:*`))
.join(' | ')
Dla słów dłuższych niż 3 znaki obcinamy ostatni znak i dokładamy :*, żeby dopasowywać po prefiksie. To właśnie łapie literówki – „umywalak” trafia w „umywalka”.
Poziom 3: trafienia po podobieństwie
WHERE h.has_exact = 0 AND h.has_partial = 0 AND (
(ahs.min_similarity > ${PRODUCTS_QUERY_SENSITIVITY})
OR (ahs.min_similarity_without_worst > ${PRODUCTS_QUERY_SENSITIVITY})
)
Korzysta z trigramowego podobieństwa PostgreSQL z konfigurowalnym progiem:
const PRODUCTS_QUERY_SENSITIVITY = 0.35 // 35% similarity threshold
Na czym polega finezja: jeśli są trafienia dokładne, pokazujemy tylko je. Jeśli są trafienia częściowe, pokazujemy tylko je. Do rozmytego dopasowania schodzimy dopiero wtedy, gdy nic innego nie zadziałało.
Warstwa 4: zaawansowany scoring podobieństwa
W systemie siedzi własna funkcja PostgreSQL average_highest_similarity, która jest po prostu genialna:
-- This function calculates similarity between query words and product name vectors
-- But with a twist: it can exclude the worst-matching words from the average
FOREACH query_word IN ARRAY query_words LOOP
max_similarity := 0;
-- Find the highest similarity for each query word against all product words
FOREACH tsvector_entry IN ARRAY tsvector_entries LOOP
current_similarity := similarity(query_word, tsvector_word) + tsvector_weight / 100;
IF current_similarity > max_similarity THEN
max_similarity := current_similarity;
max_similarity_word := tsvector_word;
END IF;
END LOOP;
-- Remove the matched word so it can't match again
tsvector_entries := array_remove(tsvector_entries, max_similarity_word);
END LOOP;
Dlaczego to takie ważne: jeśli ktoś szuka „red bathroom sink luxury”, a produkt dobrze pasuje do „red”, „bathroom” i „sink”, ale nie ma w nazwie żadnego „luxury”, klasyczne liczenie punktów mocno by go ukarało. Ta funkcja potrafi zignorować najgorsze dopasowanie („luxury”) i mimo wszystko wysoko ustawić produkt w rankingu.
Warstwa 5: integracja z logiką biznesową
Wyszukiwanie to nie tylko trafność – w środek wplecione są kluczowe reguły biznesowe:
Sprytne filtrowanie po stanie magazynowym
WHERE (isSearchBarEligibleByStock = true OR exactMatchPriority = 2)
Produkty niedostępne nie pojawiają się w wynikach, CHYBA ŻE ktoś szuka po dokładnym kodzie.
Promowanie wybranych produktów
const PROMOTED_CODES = ['NQS_F4GM']
// In the ORDER BY:
CASE WHEN fp.code = ANY(${PROMOTED_CODES}) THEN 1 ELSE 0 END DESC,
Wybrane produkty dostają podbicie z powodów biznesowych.
Ranking wieloczynnikowy
ORDER BY
promoted_products DESC, -- Business priority
avg_similarity DESC, -- Relevance score
avg_similarity_without_worst DESC, -- Fallback relevance
categoryPriority DESC, -- Category importance
plcRank DESC -- Product lifecycle stage
Pełna implementacja
Poniżej cały endpoint wyszukiwania, który spina to wszystko w całość:
import { z } from 'zod'
import { getProductsSearchSchema } from '~/schemas/search.schema'
import { Prisma, ProductLine } from '@prisma/client'
import { removeWordsBetweenFirstAndLast } from '~/utils/global.utils'
import type { Language } from '~/schemas/language.schema'
import { prepareStockToResponse } from '~/utils/product.utils'
import { getFinishIdFromQueryParam } from '~/utils/finish.utils'
import _ from 'lodash'
const PRODUCTS_QUERY_SENSITIVITY = 0.35
const PROMOTED_CODES = ['NQS_F4GM']
type QueryResult = {
result: {
data: any[] | null
total_count: number
zones: string[] | null
finishes: { id: string; translations: { [key in Language]: string } }[] | null
}
}[]
const PREPOSITIONS: { [key in Language]: string[] } = {
pl: ['bez', 'dla', 'do', 'na', 'nad', 'pod' /* ...150+ more Polish prepositions */],
de: ['an', 'auf', 'bei', 'durch', 'für' /* ...40+ German prepositions */],
en: ['about', 'above', 'across', 'after' /* ...40+ English prepositions */],
ru: ['в', 'на', 'к', 'по', 'с' /* ...30+ Russian prepositions */],
hu: ['alatt', 'által', 'ellen' /* ...25+ Hungarian prepositions */],
ro: ['cu', 'de', 'din', 'în' /* ...30+ Romanian prepositions */],
fr: ['à', 'après', 'avant', 'avec' /* ...30+ French prepositions */],
it: ['a', 'con', 'da', 'di' /* ...25+ Italian prepositions */],
uk: ['в', 'на', 'до', 'з' /* ...30+ Ukrainian prepositions */],
sl: ['v', 'na', 'pri', 'za' /* ...30+ Slovenian prepositions */],
es: ['a', 'ante', 'bajo', 'con' /* ...25+ Spanish prepositions */],
}
export default defineEventHandler(async (event) => {
const {
q: qRaw = '',
page,
limit,
zone,
finish,
} = await getValidatedQuery(event, getProductsSearchSchema.parse)
const lang = getLocale(event)
const langField = Prisma.sql([`"${lang}"`])
const skip = (page - 1) * limit
// Smart preprocessing
const q = removeDiacritics(removeWordsBetweenFirstAndLast(qRaw, PREPOSITIONS[lang]))
const splitQ = q.split(/,\s*|\s+/)
const perfectCodes = splitQ.reduce((previous, current, index) => {
if (!splitQ[index + 1]) {
return [...previous, current]
}
return [...previous, current, `${current}_${splitQ[index + 1]}`]
}, [] as string[])
// Similarity query with typo tolerance
const similarityQ = q
.split(' ')
.map((term) => (term.length > 3 ? term.slice(0, term.length - 1) : term))
.join(' ')
// Full-text search query
const tsQuery = splitQ
.map((q) => q.replace(/\W/g, ''))
.filter((term) => term.length > 0)
.map((term) => (term.length > 3 ? `${term.slice(0, term.length - 1)}:*` : `${term}:*`))
.join(' | ')
const codes = perfectCodes.map((option) => `%${option}%`)
const [
{
result: { data, total_count, zones, finishes },
},
] = await prisma.$queryRaw<QueryResult>`
WITH PreloadedQuery AS (
SELECT
product."id" AS id,
product."ean" AS ean,
product."code" AS code,
product."mainPhotoId" AS mainPhotoId,
product."isSearchBarEligibleByStock" AS isSearchBarEligibleByStock,
category."priority" AS categoryPriority,
product."nameVectorId" AS nameVectorId,
category."zone" AS categoryZone,
CASE
WHEN product."plc" = 'Nowość' THEN 1
WHEN product."plc" = 'Normalny' THEN 0.5
ELSE 0
END AS plcRank
FROM public."Product" AS product
LEFT JOIN public."Category" AS category ON product."mainCategoryId" = category."id"
WHERE product."isSearchBarEligibleByData" = true
GROUP BY product."id", product."ean", product."code", category."priority", category."zone"
),
FilteredQueryBeforeCheck AS (
SELECT *,
CASE
WHEN ean = ANY(${splitQ}) OR code = ANY(${perfectCodes}) THEN 2 -- Exact match
WHEN code LIKE ANY(${codes}) OR EXISTS (
SELECT 1 FROM public."Translation" AS nv
WHERE nv."id" = nameVectorId
AND to_tsvector('simple', nv.${langField}) @@ to_tsquery('simple', ${tsQuery})
) THEN 1 -- Partial match
ELSE 0
END AS exactMatchPriority
FROM PreloadedQuery
),
FilteredQuery AS (
SELECT * FROM FilteredQueryBeforeCheck
WHERE (isSearchBarEligibleByStock = true OR exactMatchPriority = 2)
),
HasExactMatch AS (
SELECT
MAX(CASE WHEN exactMatchPriority = 2 THEN 1 ELSE 0 END) AS has_exact,
MAX(CASE WHEN exactMatchPriority = 1 THEN 1 ELSE 0 END) AS has_partial
FROM FilteredQuery
),
ExactAndPartialMatches AS (
SELECT fq.*, ahs.avg_similarity, ahs.avg_similarity_without_worst
FROM FilteredQuery fq, HasExactMatch h
LEFT JOIN LATERAL (
SELECT * FROM average_highest_similarity(
(SELECT nameVector.${langField}
FROM public."Translation" AS nameVector
WHERE nameVector."id" = fq.nameVectorId),
${similarityQ}
)
) AS ahs ON TRUE
WHERE (h.has_exact = 1 AND fq.exactMatchPriority = 2)
OR (h.has_exact = 0 AND h.has_partial = 1 AND fq.exactMatchPriority = 1)
),
SimilarityMatches AS (
SELECT fq.*, ahs.avg_similarity, ahs.avg_similarity_without_worst
FROM FilteredQuery fq, HasExactMatch h
LEFT JOIN LATERAL (
SELECT * FROM average_highest_similarity(
(SELECT nameVector.${langField}
FROM public."Translation" AS nameVector
WHERE nameVector."id" = fq.nameVectorId),
${q}
)
) AS ahs ON TRUE
WHERE h.has_exact = 0 AND h.has_partial = 0 AND (
(ahs.min_similarity > ${PRODUCTS_QUERY_SENSITIVITY})
OR (ahs.min_similarity_without_worst > ${PRODUCTS_QUERY_SENSITIVITY})
)
),
RankedResults AS (
SELECT * FROM ExactAndPartialMatches
UNION ALL
SELECT * FROM SimilarityMatches
),
ProductsPool AS (
SELECT * FROM RankedResults
${
zone
? Prisma.sql`
WHERE categoryZone IS NOT NULL
AND categoryZone && ARRAY[${Prisma.join(_.castArray(zone))}]`
: Prisma.empty
}
),
FinalProductsPool AS (
SELECT rp.* FROM ProductsPool rp
${
finish
? Prisma.sql`
WHERE EXISTS (
SELECT 1 FROM public."_productFinish" AS pf
WHERE pf."B" = rp.id
AND pf."A" = ANY(ARRAY[${Prisma.join(_.castArray(finish).map(getFinishIdFromQueryParam))}]::text[])
)`
: Prisma.empty
}
),
DataResults AS (
SELECT
fp.id, fp.ean, fp.code, fp.avg_similarity, fp.avg_similarity_without_worst,
product."plc" AS plc, product."line" AS lineRaw, product."stock" AS stock,
product."promoGrossPrice" AS promoGrossPrice, product."grossPrice" AS grossPrice,
product."isPromotion" AS isPromotion, product."dimensions" AS dimensions,
product."plannedDeliveryDate" AS plannedDeliveryDate,
product."v2srcTimeStamp" AS v2srcTimeStamp,
translation.${langField} AS nameCore,
postTranslation.${langField} AS namePost,
feature1Translation.${langField} AS nameFeature1,
feature2Translation.${langField} AS nameFeature2,
productCollection."name" AS collectionName,
mainPhoto."fullpath" AS mainPhotoFullPath
FROM FinalProductsPool fp
JOIN public."Product" AS product ON product."id" = fp.id
LEFT JOIN public."DictionaryEntry" AS nameCoreEntry ON product."nameCoreId" = nameCoreEntry."id"
LEFT JOIN public."Translation" AS translation ON nameCoreEntry."translationId" = translation."id"
LEFT JOIN public."DictionaryEntry" AS namePostEntry ON product."namePostId" = namePostEntry."id"
LEFT JOIN public."Translation" AS postTranslation ON namePostEntry."translationId" = postTranslation."id"
LEFT JOIN public."DictionaryEntry" AS nameFeature1Entry ON product."nameFeature1Id" = nameFeature1Entry."id"
LEFT JOIN public."Translation" AS feature1Translation ON nameFeature1Entry."translationId" = feature1Translation."id"
LEFT JOIN public."DictionaryEntry" AS nameFeature2Entry ON product."nameFeature2Id" = nameFeature2Entry."id"
LEFT JOIN public."Translation" AS feature2Translation ON nameFeature2Entry."translationId" = feature2Translation."id"
LEFT JOIN public."Collection" AS productCollection ON product."collectionId" = productCollection."id"
LEFT JOIN public."Asset" AS mainPhoto ON fp.mainPhotoId = mainPhoto."id"
ORDER BY
CASE WHEN fp.code = ANY(${PROMOTED_CODES}) THEN 1 ELSE 0 END DESC,
fp.avg_similarity DESC,
fp.avg_similarity_without_worst DESC,
fp.categoryPriority DESC,
fp.plcRank DESC
OFFSET ${skip} LIMIT ${limit}
),
Finishes AS (
SELECT pf."A" AS finish_id, translationProductFinish.${langField} AS translation
FROM public."_productFinish" AS pf
JOIN ProductsPool rr ON pf."B" = rr.id
JOIN public."DictionaryEntry" AS nameProductFinish ON nameProductFinish."id" = pf."A"
LEFT JOIN public."Translation" AS translationProductFinish ON nameProductFinish."translationId" = translationProductFinish."id"
GROUP BY pf."A", translationProductFinish.${langField}
),
FlattenedCategoryZones AS (
SELECT DISTINCT unnest(categoryZone) AS category_zone FROM RankedResults
),
UniqueCategoryZones AS (
SELECT json_agg(category_zone) AS category_zones FROM FlattenedCategoryZones
)
SELECT json_build_object(
'data', json_agg(json_build_object(
'id', id, 'ean', ean, 'code', code, 'plc', plc, 'lineRaw', lineRaw,
'stock', stock, 'plannedDeliveryDate', plannedDeliveryDate,
'v2srcTimeStamp', v2srcTimeStamp, 'dimensions', dimensions,
'grossPrice', grossPrice, 'promoGrossPrice', promoGrossPrice,
'isPromotion', isPromotion, 'nameCore', nameCore, 'namePost', namePost,
'nameFeature1', nameFeature1, 'nameFeature2', nameFeature2,
'collectionName', collectionName, 'mainPhotoFullPath', mainPhotoFullPath
)),
'total_count', (SELECT COUNT(*) FROM FinalProductsPool),
'zones', (SELECT category_zones FROM UniqueCategoryZones),
'finishes', (
SELECT json_agg(json_build_object(
'id', finish_id,
'translations', json_build_object(${Prisma.raw(`'${lang}'`)}, translation)
))
FROM Finishes
)
) AS result
FROM DataResults;
`
const pagesCount = Math.ceil(total_count / limit)
return {
products: z
.array(
z.object({
id: z.number(),
ean: z.string().nullable(),
code: z.string(),
plc: z.string(),
line: z
.string()
.transform((value) => Object.values(ProductLine).find((line) => line === value))
.nullable(),
grossPrice: z.union([z.string(), z.number()]).transform((option) => option.toString()),
promoGrossPrice: z
.union([z.string(), z.number()])
.transform((option) => option.toString()),
inStock: z.boolean(),
version: z.number().optional(),
versionDate: z.any(),
versionTimeStamp: z.number(),
dimensions: z.string().nullable().optional(),
isPromotion: z.boolean(),
nameCore: z.string().nullable(),
namePost: z.string().nullable(),
nameFeature1: z.string().nullable(),
nameFeature2: z.string().nullable(),
collectionName: z.string().nullable(),
mainPhotoFullPath: z.string().nullable(),
})
)
.parse(
(data ?? [])
.map(({ lineRaw, ...option }) => ({ ...option, line: lineRaw }))
.map(prepareStockToResponse)
),
lang,
page,
limit,
skip,
pagesCount,
finishes,
zones,
}
})
Kwestie wydajnościowe
CTE dla złożonej logiki
To ogromne zapytanie oparte na Common Table Expression (CTE) może wyglądać przytłaczająco, ale dla wydajności jest znakomite. Każdy krok jest jasno zdefiniowany i optymalizowany przez planer zapytań PostgreSQL.
Lateral joiny dla dynamicznego podobieństwa
LEFT JOIN LATERAL (
SELECT * FROM average_highest_similarity(...)
) AS ahs ON TRUE
Lateral joiny pozwalają policzyć podobieństwo per wiersz, korzystając z wektora nazwy konkretnego produktu. Dużo wydajniej niż przetwarzanie po stronie aplikacji.
Jak to dostosować pod siebie
1. Dostrój czułość
Zacznij od PRODUCTS_QUERY_SENSITIVITY = 0.5 dla konserwatywnego dopasowania albo zejdź do 0.25 dla bardzo liberalnego. Obserwuj zapytania kończące się zerem wyników, żeby znaleźć złoty środek.
2. Rozbuduj listy przyimków
Dorzuć branżowe określenia, nazwy marek albo typowe zwroty, które nie wnoszą żadnej wartości semantycznej.
3. Ulepsz ranking
Obecny ranking mógłby uwzględniać:
- Historię zakupów użytkownika
- Popularność produktu (click-through rate)
- Sezonowość
- Marżę
- Trafność geograficzną
4. Dodaj rozumienie semantyczne
Rozważ dołożenie embeddingów i prawdziwie semantycznego wyszukiwania na bazach wektorowych w rodzaju pgvector.
5. Ucz się z zachowań użytkowników
Śledź, w co użytkownicy klikają po wyszukaniu. Buduj mapy synonimów na podstawie wzorców zachowań.
Dlaczego to podejście działa
To nie jest zwykła wyszukiwarka – to doświadczenie wyszukiwania. Obsługuje:
- Ludzką niedoskonałość (literówki, zbędne słowa)
- Wymagania biznesowe (promocje, stany magazynowe, kategorie)
- Wiele języków (kluczowe w handlu międzynarodowym)
- Różne intencje (szukanie konkretu vs. przeglądanie)
- Wydajność w skali (efektywne zapytania w PostgreSQL)
Kluczowy wniosek: dobre wyszukiwanie nie polega na idealnym dopasowaniu – polega na zrozumieniu intencji i podaniu trafnych wyników nawet wtedy, gdy wejście jest chaotyczne.
Efekty
Ten system napędza codziennie wyszukiwanie tysięcy produktów łazienkowych i kuchennych. Użytkownicy dostają trafne wyniki niezależnie od tego, czy szukają po polsku, niemiecku, angielsku, czy robią literówki. Wielopoziomowe podejście gwarantuje, że dokładne trafienia mają priorytet, a przy tym wciąż zostaje opcja awaryjna dla wyszukiwań eksploracyjnych.
Budowanie takiego wyszukiwania wymaga myślenia jednocześnie jak developer i jak użytkownik. Każdy krok preprocessingu, każdy czynnik rankingowy i każdy próg opiera się na realnym zachowaniu użytkowników.
Piękno tkwi w graceful degradation – jeśli dokładne dopasowanie zawiedzie, próbujemy częściowego. Jeśli i to zawiedzie, schodzimy do podobieństwa. Jeśli zapytania są zbyt szerokie, logika biznesowa pomaga zawęzić wyniki.
Tak buduje się wyszukiwanie, które nie tylko działa – ono zachwyca. Użytkownicy dostają to, czego chcą, nawet jeśli nie umieją o to idealnie poprosić.
Chcesz zbudować coś podobnego? Zacznij prosto, od dokładnego dopasowania, dokładaj po jednej warstwie i zawsze obserwuj swoje search analytics. Dane same podpowiedzą, co zbudować dalej.
Czasem najlepsze AI to po prostu bardzo dobrze przemyślany SQL.