# Trouve-KA — pipeline de requête et ranking # Author: Simon-Pierre Boucher # Contact: contact@spboucher.ai """Pipeline de requête : normalisation → langue → lieux → requête OpenSearch. BM25 (multi_match bilingue) au cœur, function_score par-dessus : Québec, autorité, fraîcheur, localité. Poids nommés, non figés (§9). """ import re import unicodedata from typing import Any from trouveka.classifier import QUEBEC_TOPONYMS # Poids de ranking — à calibrer avec le dataset d'évaluation (§16) W_PAGE_QUEBEC = 1.6 W_DOMAIN_QUEBEC = 1.0 W_AUTHORITY = 0.8 W_FRESHNESS = 0.5 W_LOCALITY = 2.0 _FR_HINTS = {"le", "la", "les", "des", "une", "un", "du", "de", "et", "ou", "pour", "avec", "dans", "sur", "meilleur", "meilleure", "comment", "où", "quel", "quelle"} _EN_HINTS = {"the", "a", "an", "of", "and", "or", "for", "with", "in", "on", "best", "how", "what", "where"} def _fold(text: str) -> str: return "".join(c for c in unicodedata.normalize("NFD", text) if unicodedata.category(c) != "Mn") def analyze_query(q: str) -> dict[str, Any]: """Normalisation, détection de langue heuristique, extraction de lieux québécois.""" normalized = re.sub(r"\s+", " ", q).strip()[:200] tokens = [t.lower() for t in re.findall(r"[\w'-]+", normalized, re.UNICODE)] fr_hits = sum(1 for t in tokens if t in _FR_HINTS) en_hits = sum(1 for t in tokens if t in _EN_HINTS) has_accents = normalized != _fold(normalized) language = "fr" if (fr_hits > en_hits or has_accents) else ("en" if en_hits > fr_hits else None) # Lieux : tokens simples + bigrammes contre le gazetteer folded_gazetteer = {_fold(t): t for t in QUEBEC_TOPONYMS} locations: list[str] = [] candidates = tokens + [f"{a}-{b}" for a, b in zip(tokens, tokens[1:])] + [ f"{a} {b}" for a, b in zip(tokens, tokens[1:]) ] for cand in candidates: folded = _fold(cand) if folded in folded_gazetteer: locations.append(folded_gazetteer[folded]) return {"query": normalized, "language": language, "locations": sorted(set(locations))} def build_search_body( q: str, *, page: int = 1, limit: int = 10, language: str | None = None, category: str | None = None, quebec_only: bool = False, freshness: str | None = None, ) -> tuple[dict[str, Any], dict[str, Any]]: """Construit le corps de requête OpenSearch. Retourne (body, analyse).""" analysis = analyze_query(q) normalized = analysis["query"] # BM25 bilingue : les deux analyzers interrogés, le meilleur champ gagne text_query: dict[str, Any] = { "multi_match": { "query": normalized, "type": "most_fields", "fields": [ "title^4", "title.en^4", "headings^2", "headings.en^2", "description^2", "description.en^2", "body", "body.en", ], "fuzziness": "AUTO", "prefix_length": 2, } } filters: list[dict[str, Any]] = [] if language in ("fr", "en"): filters.append({"term": {"language": language}}) if category: filters.append({"term": {"categories": category}}) if quebec_only: filters.append({ "bool": { "should": [ {"range": {"page_quebec_score": {"gte": 0.45}}}, {"range": {"domain_quebec_score": {"gte": 0.6}}}, ], "minimum_should_match": 1, } }) if freshness in ("day", "week", "month", "year"): filters.append({"range": {"crawled_at": {"gte": f"now-1{freshness[0]}/d"}}}) functions: list[dict[str, Any]] = [ {"field_value_factor": {"field": "page_quebec_score", "factor": W_PAGE_QUEBEC, "missing": 0}}, {"field_value_factor": {"field": "domain_quebec_score", "factor": W_DOMAIN_QUEBEC, "missing": 0}}, {"field_value_factor": {"field": "authority_score", "factor": W_AUTHORITY, "missing": 0}}, { "gauss": {"published_at": {"origin": "now", "scale": "180d", "decay": 0.6}}, "weight": W_FRESHNESS, }, ] # Localité : « plombier Gatineau » booste les documents avec preuve géographique explicite if analysis["locations"]: functions.append({ "filter": {"terms": {"locations": analysis["locations"]}}, "weight": W_LOCALITY, }) body = { "from": max(page - 1, 0) * limit, "size": limit, "query": { "function_score": { "query": {"bool": {"must": [text_query], "filter": filters}}, "functions": functions, "score_mode": "sum", "boost_mode": "sum", } }, "highlight": { "pre_tags": [""], "post_tags": [""], "fields": { "body": {"fragment_size": 180, "number_of_fragments": 2}, "description": {"fragment_size": 180, "number_of_fragments": 1}, }, "encoder": "html", }, "_source": [ "url", "canonical_url", "domain", "title", "description", "language", "page_quebec_score", "domain_quebec_score", "categories", "published_at", ], "track_total_hits": True, } return body, analysis