SPB Git

spb/trouve-ka Public

Trouve-KA — moteur de recherche web indépendant, Québec-first. Crawler distribué, index OpenSearch, ranking bilingue, galerie d'images. En prod : www.trouve-ka.com

Python 76.8% TypeScript 15.7% SQL 3.9% Shell 1.4% CSS 1.3% Dockerfile 0.7%
5.5 KB · 152 lines python
Raw Blame History
1# Trouve-KA — pipeline de requête et ranking2# Author: Simon-Pierre Boucher3# Contact: contact@spboucher.ai45"""Pipeline de requête : normalisation → langue → lieux → requête OpenSearch.67BM25 (multi_match bilingue) au cœur, function_score par-dessus :8Québec, autorité, fraîcheur, localité. Poids nommés, non figés (§9).9"""1011import re12import unicodedata13from typing import Any1415from trouveka.classifier import QUEBEC_TOPONYMS1617# Poids de ranking — à calibrer avec le dataset d'évaluation (§16)18W_PAGE_QUEBEC = 1.619W_DOMAIN_QUEBEC = 1.020W_AUTHORITY = 0.821W_FRESHNESS = 0.522W_LOCALITY = 2.02324_FR_HINTS = {"le", "la", "les", "des", "une", "un", "du", "de", "et", "ou", "pour",25             "avec", "dans", "sur", "meilleur", "meilleure", "comment", "où", "quel", "quelle"}26_EN_HINTS = {"the", "a", "an", "of", "and", "or", "for", "with", "in", "on", "best", "how", "what", "where"}272829def _fold(text: str) -> str:30    return "".join(c for c in unicodedata.normalize("NFD", text) if unicodedata.category(c) != "Mn")313233def analyze_query(q: str) -> dict[str, Any]:34    """Normalisation, détection de langue heuristique, extraction de lieux québécois."""35    normalized = re.sub(r"\s+", " ", q).strip()[:200]36    tokens = [t.lower() for t in re.findall(r"[\w'-]+", normalized, re.UNICODE)]3738    fr_hits = sum(1 for t in tokens if t in _FR_HINTS)39    en_hits = sum(1 for t in tokens if t in _EN_HINTS)40    has_accents = normalized != _fold(normalized)41    language = "fr" if (fr_hits > en_hits or has_accents) else ("en" if en_hits > fr_hits else None)4243    # Lieux : tokens simples + bigrammes contre le gazetteer44    folded_gazetteer = {_fold(t): t for t in QUEBEC_TOPONYMS}45    locations: list[str] = []46    candidates = tokens + [f"{a}-{b}" for a, b in zip(tokens, tokens[1:])] + [47        f"{a} {b}" for a, b in zip(tokens, tokens[1:])48    ]49    for cand in candidates:50        folded = _fold(cand)51        if folded in folded_gazetteer:52            locations.append(folded_gazetteer[folded])53    return {"query": normalized, "language": language, "locations": sorted(set(locations))}545556def build_search_body(57    q: str,58    *,59    page: int = 1,60    limit: int = 10,61    language: str | None = None,62    category: str | None = None,63    quebec_only: bool = False,64    freshness: str | None = None,65    images_only: bool = False,66) -> tuple[dict[str, Any], dict[str, Any]]:67    """Construit le corps de requête OpenSearch. Retourne (body, analyse)."""68    analysis = analyze_query(q)69    normalized = analysis["query"]7071    # BM25 bilingue : les deux analyzers interrogés, le meilleur champ gagne72    text_query: dict[str, Any] = {73        "multi_match": {74            "query": normalized,75            "type": "most_fields",76            "fields": [77                "title^4", "title.en^4",78                "headings^2", "headings.en^2",79                "description^2", "description.en^2",80                "body", "body.en",81            ],82            "fuzziness": "AUTO",83            "prefix_length": 2,84        }85    }8687    filters: list[dict[str, Any]] = []88    if language in ("fr", "en"):89        filters.append({"term": {"language": language}})90    if category:91        filters.append({"term": {"categories": category}})92    if quebec_only:93        filters.append({94            "bool": {95                "should": [96                    {"range": {"page_quebec_score": {"gte": 0.45}}},97                    {"range": {"domain_quebec_score": {"gte": 0.6}}},98                ],99                "minimum_should_match": 1,100            }101        })102    if freshness in ("day", "week", "month", "year"):103        filters.append({"range": {"crawled_at": {"gte": f"now-1{freshness[0]}/d"}}})104    if images_only:105        # Galerie d'images : seulement les pages avec une image représentative106        filters.append({"exists": {"field": "image_url"}})107108    functions: list[dict[str, Any]] = [109        {"field_value_factor": {"field": "page_quebec_score", "factor": W_PAGE_QUEBEC, "missing": 0}},110        {"field_value_factor": {"field": "domain_quebec_score", "factor": W_DOMAIN_QUEBEC, "missing": 0}},111        {"field_value_factor": {"field": "authority_score", "factor": W_AUTHORITY, "missing": 0}},112        {113            "gauss": {"published_at": {"origin": "now", "scale": "180d", "decay": 0.6}},114            "weight": W_FRESHNESS,115        },116    ]117    # Localité : « plombier Gatineau » booste les documents avec preuve géographique explicite118    if analysis["locations"]:119        functions.append({120            "filter": {"terms": {"locations": analysis["locations"]}},121            "weight": W_LOCALITY,122        })123124    body = {125        "from": max(page - 1, 0) * limit,126        "size": limit,127        "query": {128            "function_score": {129                "query": {"bool": {"must": [text_query], "filter": filters}},130                "functions": functions,131                "score_mode": "sum",132                "boost_mode": "sum",133            }134        },135        "highlight": {136            "pre_tags": ["<em>"],137            "post_tags": ["</em>"],138            "fields": {139                "body": {"fragment_size": 180, "number_of_fragments": 2},140                "description": {"fragment_size": 180, "number_of_fragments": 1},141            },142            "encoder": "html",143        },144        "_source": [145            "url", "canonical_url", "domain", "title", "description", "language",146            "page_quebec_score", "domain_quebec_score", "categories", "published_at",147            "image_url",148        ],149        "track_total_hits": True,150    }151    return body, analysis152