Improve sparse and structured catalog matching

This commit is contained in:
Fiden committed 2026-08-07 18:31:59 +03:00
1 parent 2982161374
commit 42f8f07207
8 files changed
+739 -20

No files matched your search

+95 -1
View File
@@ -6,7 +6,12 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING
from app.normalization import meaningful_tokens, normalize_text, tokenize, word_tokens
from app.typo import character_trigrams, trigram_dice, word_similarity
from app.typo import (
character_trigrams,
damerau_levenshtein_distance,
trigram_dice,
word_similarity,
)
if TYPE_CHECKING:
from app.catalog import CatalogItem
@@ -22,6 +27,14 @@ class LexicalScore:
exact_coverage: float
@dataclass(frozen=True, slots=True)
class ProductTypeInference:
product_type: str
score: float
query_word: str
catalog_word: str
class LexicalIndex:
"""Small in-memory lexical index: BM25 + char trigrams + typo alignment."""
@@ -69,6 +82,87 @@ class LexicalIndex:
counts.update(self._trigram_postings.get(trigram, ()))
return tuple(index for index, _ in counts.most_common(limit))
def infer_product_type(self, query_text: str) -> ProductTypeInference | None:
"""Infer a missing product type from stable lexical evidence.
Trigrams provide the shortlist, but are deliberately not sufficient on
their own: sizes and random strings can share a few character triples
with unrelated catalog rows. The leading rows must agree on one type
and share a catalog word that is an exact match, a morphological prefix,
or a tightly bounded typo of a query word.
"""
candidate_indices = self.trigram_candidates(query_text, limit=60)
if len(candidate_indices) < 3:
return None
leaders = self.rank(query_text, "", candidate_indices)[:3]
if len(leaders) < 3:
return None
product_types = {
self._items[leader.item_index].product_type for leader in leaders
}
if len(product_types) != 1:
return None
common_words = set(self._document_words[leaders[0].item_index])
for leader in leaders[1:]:
common_words.intersection_update(self._document_words[leader.item_index])
if not common_words:
return None
normalized = normalize_text(query_text)
query_words = word_tokens(
meaningful_tokens(tokenize(normalized, already_normalized=True))
)
best: tuple[float, str, str] | None = None
for query_word in query_words:
if len(query_word) < 4:
continue
for catalog_word in common_words:
score = self._type_inference_word_score(query_word, catalog_word)
if score <= 0:
continue
evidence = (score, query_word, catalog_word)
if best is None or evidence > best:
best = evidence
if best is None:
return None
score, query_word, catalog_word = best
return ProductTypeInference(
product_type=next(iter(product_types)),
score=score,
query_word=query_word,
catalog_word=catalog_word,
)
@staticmethod
def _type_inference_word_score(query_word: str, catalog_word: str) -> float:
if query_word == catalog_word:
return 1.0
shorter_length = min(len(query_word), len(catalog_word))
longer_length = max(len(query_word), len(catalog_word))
if shorter_length >= 4 and (
query_word.startswith(catalog_word)
or catalog_word.startswith(query_word)
):
length_ratio = shorter_length / longer_length
if length_ratio >= 0.55:
return 0.72 + length_ratio * 0.18
distance = damerau_levenshtein_distance(query_word, catalog_word)
if shorter_length >= 5 and distance <= 1:
return 0.90
if longer_length >= 8 and distance <= 2:
edit_similarity = 1.0 - distance / longer_length
if edit_similarity >= 0.75:
return 0.82
return 0.0
def rank(
self,
query_text: str,