Improve sparse and structured catalog matching
This commit is contained in:
1 parent
2982161374
commit
42f8f07207
8 files changed
+739
-20
No files matched your search
+95
-1
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user