Improve sparse and structured catalog matching

This commit is contained in:
Fiden committed 2026-08-07 18:31:59 +03:00
1 parent 39c6d2bcbf
commit 7874c79534
7 files changed
+723 -17

No files matched your search

+15 -2
View File
@@ -30,7 +30,11 @@ _VOLTAGE_RE = re.compile(
rf"(?<![a-zа-я0-9-])({_NUMBER})\s*(?:v|в|вольт\w*)\b", re.IGNORECASE
)
_POWER_RE = re.compile(rf"\b({_NUMBER})\s*(?:w|вт|ватт\w*)\b", re.IGNORECASE)
_TEETH_RE = re.compile(r"\b(\d+)\s*(?:зуб(?:ьев|а|ов)?|z)\b", re.IGNORECASE)
_TEETH_RE = re.compile(r"\b(\d+)\s*(?:зуб(?:ьев|а|ов)?|[zt])\b", re.IGNORECASE)
_SAW_COMPACT_RE = re.compile(
rf"(?<![a-zа-я0-9.])({_NUMBER})(?:x|\s+)(\d+)\s*[zt]\b",
re.IGNORECASE,
)
_PACK_SIZE_RE = re.compile(
r"(?:пачк\w*|упаковк\w*|\bуп\.?)(?:\s+(?:по|на))?\s*(\d+)\s*(?:шт\w*)?",
re.IGNORECASE,
@@ -377,8 +381,15 @@ def parse_attributes(
normalized = normalize_text(text)
dimensions = _extract_dimensions(normalized)
compact_saw_match = _SAW_COMPACT_RE.search(normalized)
teeth_match = _TEETH_RE.search(normalized)
teeth = int(teeth_match.group(1)) if teeth_match else None
teeth = (
int(compact_saw_match.group(2))
if compact_saw_match is not None
else int(teeth_match.group(1))
if teeth_match is not None
else None
)
if not dimensions and product_type in _PAIR_DIMENSION_TYPES:
pair_match = _PAIR_WITH_NA_RE.search(normalized)
@@ -395,6 +406,8 @@ def parse_attributes(
mm_values = [_decimal(match.group(1)) for match in _MM_RE.finditer(normalized)]
meter_values = [_decimal(match.group(1)) for match in _METER_RE.finditer(normalized)]
if compact_saw_match is not None:
mm_values.append(_decimal(compact_saw_match.group(1)))
# ``190 на 48 зубьев`` describes diameter and tooth count, not 190x48.
saw_pair = re.search(
+188 -5
View File
@@ -15,6 +15,7 @@ from app.attributes import (
from app.catalog import CatalogItem, build_catalog, load_raw_catalog
from app.intents import detect_service_intent
from app.models import Candidate, MatchResult
from app.normalization import ZERO_WEIGHT_TOKENS, normalize_text, tokenize
from app.search_index import LexicalIndex, LexicalScore
@@ -26,6 +27,43 @@ class _RankedCandidate:
ranking_score: float
_STRUCTURED_QUERY_WORDS = frozenset(
{
"x",
"max",
"plus",
"sds",
"t",
"z",
"v",
"w",
"в",
"ватт",
"ватта",
"ваттов",
"вольт",
"вольта",
"вольтов",
"зуб",
"зуба",
"зубов",
"зубьев",
"м",
"метр",
"метра",
"метров",
"миллиметр",
"миллиметра",
"миллиметров",
"мм",
"размер",
"размера",
"размером",
"вт",
}
)
class CatalogMatcher:
def __init__(self, catalog_path: Path) -> None:
self.alias_index = ProductAliasIndex()
@@ -78,15 +116,46 @@ class CatalogMatcher:
alias="model_code",
token_start=0,
)
if product_match is None and provisional_attributes.teeth is not None:
# A tooth count is type-specific evidence in this domain: only
# saw blades carry it. This safely supports requests such as
# "210 мм 24 зуба" without requiring the word "диск".
product_match = ProductTypeMatch(
canonical="диск",
score=1.0,
exact=True,
alias="tooth_count",
token_start=0,
)
if product_match is None:
brand_result = self._match_brand_only(message, provisional_attributes)
if brand_result is not None:
return brand_result
# Product evidence always has priority. Service intent detection
# is only a diagnostic fallback; both unknown and service
# messages share the required not_found response.
detect_service_intent(message)
return MatchResult(message=message, status="not_found", candidates=[])
structured_result = self._match_structured_only(
message, provisional_attributes
)
if structured_result is not None:
return structured_result
# Trigrams used to rank only inside a type selected by aliases.
# Let them infer that missing type as well, but only when the
# leading catalog rows agree and share strong word-level evidence.
# This handles "удар" -> "ударная дрель" while
# keeping numeric coincidences and arbitrary text out.
if detect_service_intent(message) is None:
lexical_inference = self.lexical_index.infer_product_type(message)
if lexical_inference is not None:
product_match = ProductTypeMatch(
canonical=lexical_inference.product_type,
score=lexical_inference.score,
exact=lexical_inference.query_word
== lexical_inference.catalog_word,
alias=lexical_inference.catalog_word,
token_start=0,
)
if product_match is None:
return MatchResult(
message=message, status="not_found", candidates=[]
)
product_type = product_match.canonical
query_attributes = parse_attributes(
@@ -154,6 +223,7 @@ class CatalogMatcher:
query_attributes.brand is None
or not query_attributes.brand_required
or query_attributes.model_codes
or query_attributes.specificity != 1
):
return None
@@ -207,6 +277,119 @@ class CatalogMatcher:
]
return MatchResult(message=message, status=status, candidates=candidates)
def _match_structured_only(
self, message: str, query_attributes: ParsedAttributes
) -> MatchResult | None:
if not self._is_structured_only_query(message, query_attributes):
return None
compatible: dict[int, Compatibility] = {}
for index, item in enumerate(self.items):
comparison = compare_attributes(
item.product_type,
query_attributes,
item.attributes,
candidate_unit=item.unit,
)
if comparison.compatible and comparison.matched_fields > 0:
compatible[index] = comparison
if not compatible:
return None
lexical_scores = self.lexical_index.rank(
message, "", tuple(compatible.keys())
)
ranked = self._apply_preferences(
lexical_scores,
compatible,
cheap=query_attributes.cheap_preference,
)
if not ranked:
return None
status = "matched" if len(ranked) == 1 else "ambiguous"
selected = self._select_diverse_product_types(ranked, limit=3)
structured_signal = ProductTypeMatch(
canonical="",
score=0.90,
exact=True,
alias="structured_attributes",
token_start=0,
)
candidates = [
Candidate(
sku=candidate.item.sku,
confidence=self._confidence(
candidate,
product_match=structured_signal,
candidate_count=len(ranked),
specificity=query_attributes.specificity,
status=status,
rank=rank,
),
)
for rank, candidate in enumerate(selected)
]
return MatchResult(message=message, status=status, candidates=candidates)
def _is_structured_only_query(
self, message: str, query_attributes: ParsedAttributes
) -> bool:
has_strong_evidence = any(
(
bool(query_attributes.dimensions),
bool(query_attributes.mm_values),
bool(query_attributes.meter_values),
query_attributes.thread_size is not None,
query_attributes.profile is not None,
query_attributes.grit is not None,
query_attributes.voltage_v is not None,
query_attributes.power_w is not None,
query_attributes.teeth is not None,
query_attributes.brand is not None
and query_attributes.brand_required,
bool(query_attributes.model_codes),
query_attributes.sds_type is not None,
)
)
if not has_strong_evidence:
return False
allowed_words = set(ZERO_WEIGHT_TOKENS) | set(_STRUCTURED_QUERY_WORDS)
brand_match = self.brand_index.detect(message)
if brand_match is not None:
allowed_words.update(tokenize(brand_match.alias))
for token in tokenize(normalize_text(message), already_normalized=True):
if any(character.isdigit() for character in token):
continue
if token in allowed_words:
continue
if token.startswith(("бюджет", "дешев", "недорог")):
continue
return False
return True
@staticmethod
def _select_diverse_product_types(
ranked: list[_RankedCandidate], *, limit: int
) -> list[_RankedCandidate]:
if len({candidate.item.product_type for candidate in ranked}) == 1:
return ranked[:limit]
selected: list[_RankedCandidate] = []
product_types: set[str] = set()
for candidate in ranked:
if candidate.item.product_type in product_types:
continue
selected.append(candidate)
product_types.add(candidate.item.product_type)
if len(selected) == limit:
break
return selected
def _model_candidates(self, model_codes: frozenset[str]) -> frozenset[int]:
if not model_codes:
return frozenset()
+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,