File size: 16,036 Bytes
01543cd
333cede
f09f2a0
 
333cede
 
 
 
 
 
 
 
f09f2a0
 
 
 
 
333cede
f09f2a0
6e2e53b
f09f2a0
333cede
6e2e53b
333cede
df0f53d
333cede
b1f5bb4
d604f12
 
333cede
 
 
 
 
 
01543cd
333cede
 
 
 
 
6e2e53b
333cede
 
01543cd
333cede
 
01543cd
 
 
333cede
f09f2a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333cede
 
 
 
6e2e53b
333cede
6e2e53b
 
 
 
f09f2a0
333cede
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
01543cd
333cede
 
01543cd
333cede
 
 
 
 
 
 
 
 
 
 
f09f2a0
333cede
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6e2e53b
 
 
 
 
 
 
 
 
01543cd
6e2e53b
01543cd
 
 
6e2e53b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8300480
 
 
6e2e53b
 
8300480
6e2e53b
 
 
 
 
 
 
 
 
 
f09f2a0
333cede
 
01543cd
f09f2a0
01543cd
 
 
 
333cede
 
01543cd
333cede
 
01543cd
333cede
f09f2a0
333cede
d641174
 
d604f12
 
 
 
 
 
6e2e53b
333cede
 
8300480
f09f2a0
8300480
f09f2a0
 
 
 
6e2e53b
 
 
 
333cede
 
 
6e2e53b
333cede
 
 
 
 
 
 
 
6e2e53b
f09f2a0
 
 
 
 
6e2e53b
 
 
 
 
 
 
 
f09f2a0
6e2e53b
f09f2a0
6e2e53b
0b1e0bf
f09f2a0
 
 
6e2e53b
 
 
 
 
 
 
 
8300480
df0f53d
6e2e53b
df0f53d
 
 
6e2e53b
 
 
 
 
 
 
 
 
 
48b023f
 
 
 
 
 
 
6e2e53b
48b023f
6e2e53b
48b023f
e363dbe
 
48b023f
e363dbe
 
 
 
17ea5aa
e363dbe
 
17ea5aa
48b023f
e363dbe
17ea5aa
e363dbe
2dcfa74
48b023f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6e2e53b
 
 
48b023f
6e2e53b
 
d604f12
6e2e53b
 
 
 
1591cde
6e2e53b
 
df0f53d
6e2e53b
 
333cede
 
b1f5bb4
333cede
6e2e53b
48b023f
333cede
 
 
 
6e2e53b
 
 
 
48b023f
6e2e53b
333cede
 
 
 
 
01543cd
333cede
 
01543cd
333cede
 
f09f2a0
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
"""Embedding management with ChromaDB and Hugging Face Transformers."""
import os
# Règle d'or : toute variable d'environnement qui influence le cache Hugging Face doit être
# définie avant d'importer datasets ou transformers, sinon elle sera ignorée.
cache_dir = "/tmp"
os.makedirs(cache_dir, exist_ok=True)

# Rediriger le cache HF globalement
os.environ["HF_HOME"] = cache_dir
os.environ["HF_DATASETS_CACHE"] = os.path.join(cache_dir, "datasets")
os.environ["TRANSFORMERS_CACHE"] = os.path.join(cache_dir, "transformers")

# Désactiver la télémétrie ChromaDB/PostHog pour éviter les erreurs de signature
# de capture() sur certaines versions de dependent packages.
os.environ.setdefault("CHROMA_DISABLE_TELEMETRY", "1")
os.environ.setdefault("CHROMADB_DISABLE_TELEMETRY", "1")
os.environ.setdefault("POSTHOG_DISABLED", "1")

import logging
from models import QuestionInput, AnswerOutput, SemanticSearchCandidate
import unicodedata
import chromadb
from sentence_transformers import SentenceTransformer, CrossEncoder
import torch
from scipy.special import expit
from config import (
    CHROMA_COLLECTION_NAME, MODEL_NAME, SIMILARITY_THRESHOLD,
    TOP_K, CROSS_ENCODER_MODEL_NAME, CROSS_ENCODEUR_GAP_THRESHOLD,
    CROSS_ENCODEUR_CONFIDENCE_THRESHOLD,
)
from faq_loader import FAQEntry

logger = logging.getLogger(__name__)

class EmbeddingManager:
    """Manage embeddings and ChromaDB indexing for the FAQ service."""

    def __init__(

        self,

        model_name: str = MODEL_NAME,

        collection_name: str = CHROMA_COLLECTION_NAME,

        cross_encoder_model_name: str = CROSS_ENCODER_MODEL_NAME,

    ) -> None:
        """

        Initialize the embedding manager.



        Args:

            model_name: The Sentence-Transformers model to use.

            collection_name: The ChromaDB collection name.

            cross_encoder_model_name: The Cross-Encoder model used for re-ranking.

        """
        # Table de traduction pré-compilée une seule fois (au chargement du module)
        self._TRANSLATION_TABLE = str.maketrans({
            "’": "'", "‘": "'",
            "«": '"', "»": '"',
            "“": '"', "”": '"',
            "–": "-", "—": "-",
            "?": ""
        })
        if "-e5-" in model_name:
            logger.info(f"Modèle {model_name} détecté comme modèle E5.")
            self._prefix_p = "passage: "
            self._prefix_q = "query: "
        else:
            self._prefix_p = ""
            self._prefix_q = ""

        # Creation du Sentence transformer model
        logger.info(f"Initialisation du modèle d'embeddings: {model_name}")
        device = "cuda" if torch.cuda.is_available() else "cpu"
        self.model = SentenceTransformer(MODEL_NAME, device=device)

        # Création du modèle Cross-Encoder utilisé pour le re-ranking
        logger.info(f"Initialisation du modèle Cross-Encoder: {cross_encoder_model_name}")
        self.cross_encoder = CrossEncoder(cross_encoder_model_name, device=device)
        self.cross_encoder_model_name: str = cross_encoder_model_name

        # Initialiser ChromaDB en mémoire (EphemeralClient)
        # Idéal pour HF Spaces: pas de persistance disque, plus rapide au startup
        logger.info("Initialisation ChromaDB en mémoire (EphemeralClient)")
        self.client: chromadb.Client = chromadb.EphemeralClient()

        # Créer ou récupérer la collection
        self.collection = self.client.get_or_create_collection(
            name=collection_name,
            embedding_function=None,
            metadata={"hnsw:space": "ip"}  # Utiliser produit scalaire => normaliser les embeddings
        )

        self.collection_name: str = collection_name
        logger.info(f"✓ EmbeddingManager initialisé (collection: {collection_name})")

    def populate_collection(self, faq_entries: list[FAQEntry]) -> None:
        """

        Populate the ChromaDB collection with FAQ embeddings.



        Args:

            faq_entries: The FAQ entries to index.

        """
        logger.info(f"Population de ChromaDB avec {len(faq_entries)} entrées...")

        # Vider la collection existante (optionnel, mais plus propre)
        # Récupérer l'ID de collection pour supprimer les anciens documents
        existing_ids = self.collection.get()["ids"]
        if existing_ids:
            logger.info(f"Suppression de {len(existing_ids)} anciens documents...")
            self.collection.delete(ids=existing_ids)

        # Générer les embeddings pour toutes les formulations
        formulations: list[str] = [self._prefix_p + self._normalize_text(entry.formulation) for entry in faq_entries]
        logger.info("Génération des embeddings...")
        embeddings = self.model.encode(formulations, show_progress_bar=True,
                                         convert_to_numpy=True, normalize_embeddings=True)

        # Ajouter à ChromaDB
        logger.info("Ajout des documents à ChromaDB...")
        self.collection.add(
            ids=[f"faq_{i}" for i in range(len(faq_entries))],
            embeddings=embeddings.tolist(),
            documents=formulations,
            metadatas=[
                {
                    "theme": entry.theme,
                    "response": entry.response,
                }
                for entry in faq_entries
            ],
        )

        logger.info(f"✓ ChromaDB peuplée avec {len(faq_entries)} entrées")

    def _filter_and_dedup_by_theme(

        self,

        documents: list[str],

        metadatas: list[dict],

        distances: list[float],

        similarity_threshold: float,

        top_k: int,

    ) -> list[SemanticSearchCandidate]:
        """

        Filter Chroma candidates by cosine similarity and keep one formulation per theme.



        Chroma results are sorted by descending similarity, so the function stops

        as soon as the threshold is no longer met or the desired number of distinct

        themes has been reached.

        """
        seen_themes: set[str] = set()
        candidates: list[SemanticSearchCandidate] = []

        for document, metadata, distance in zip(
            documents,
            metadatas,
            distances,
            strict=True,
        ):
            similarity = 1.0 - distance

            # Les résultats étant triés par similarité décroissante,
            # tous les suivants seront également sous le seuil.
            if similarity < similarity_threshold:
                break

            theme = metadata["theme"]

            if theme not in seen_themes:
                seen_themes.add(theme)
                normalized_document = self._normalize_text(document)
                if self._prefix_p and normalized_document.startswith(self._prefix_p):
                    normalized_document = normalized_document[len(self._prefix_p):]
                candidates.append(
                    SemanticSearchCandidate(
                        formulation=normalized_document,
                        theme=theme,
                        response=metadata["response"],
                        similarity_score=similarity,
                    )
                )

                if len(candidates) == top_k:
                    break

        return candidates

    def search_similar_faq(self, payload: QuestionInput) -> AnswerOutput:
        """

        Retrieve the most relevant FAQ formulation for a given question.



        Steps:

        1. Fetch and filter candidates from ChromaDB using cosine similarity.

        2. Deduplicate by theme and keep up to top_k distinct themes.

        3. Re-rank these candidates with a Cross-Encoder before returning the best answer.



        Args:

            payload: A QuestionInput containing the question and optional thresholds.



        Returns:

            An AnswerOutput object.

        """
        # Récupérer la question et les paramètres depuis le payload
        question = payload.question
        similarity_threshold = payload.similarity_threshold \
                if payload.similarity_threshold is not None else SIMILARITY_THRESHOLD
        cross_encodeur_gap_threshold = payload.cross_encodeur_gap_threshold \
                if payload.cross_encodeur_gap_threshold is not None \
                else CROSS_ENCODEUR_GAP_THRESHOLD
        cross_encodeur_confidence_threshold = payload.cross_encodeur_confidence_threshold \
                if payload.cross_encodeur_confidence_threshold is not None \
                else CROSS_ENCODEUR_CONFIDENCE_THRESHOLD
        top_k = getattr(payload, "top_k", None) or TOP_K

        # Générer l'embedding de la question
        normalized_question = self._normalize_text(question)
        question_embedding = self.model.encode(
            [self._prefix_q + normalized_question],
            convert_to_numpy=True,
            normalize_embeddings=True,
        ).tolist()

        # On récupère un pool de candidats plus large que top_k auprès de Chroma,
        # car plusieurs formulations peuvent partager le même thème et doivent être
        # dédupliquées avant d'atteindre top_k thèmes distincts.
        n_candidates = max(top_k * 5, 20)

        results = self.collection.query(
            query_embeddings=question_embedding,
            n_results=n_candidates,
            include=["documents", "metadatas", "distances"]
        )

        if not results["ids"] or not results["ids"][0]:
            logger.warning(f"Aucun résultat trouvé pour: {question}")
            return AnswerOutput(
                question=question,
                formulation="unknown",
                answer="Je ne dispose pas d'éléments de réponse pour cette question.",
                theme="unknown",
                similarity_score=0.0,
                confidence=False,
            )

        # Filtrer par seuil cosinus et dédupliquer par thème (top_k thèmes distincts)
        candidates = self._filter_and_dedup_by_theme(
            documents=results["documents"][0],
            metadatas=results["metadatas"][0],
            distances=results["distances"][0],
            similarity_threshold=similarity_threshold,
            top_k=top_k,
        )

        if not candidates:
            logger.info(
                f"Aucun candidat au-dessus du seuil cosinus {similarity_threshold}. "
                f"Question: {question}"
            )
            return AnswerOutput(
                question=question,
                formulation="unknown",
                answer="Je ne dispose pas d'éléments de réponse pour cette question.",
                theme="unknown",
                similarity_score=0.0,
                confidence=False,
            )

        # Appliquer le Cross-Encoder entre la question et chaque formulation candidate
        pairs = [(normalized_question, candidate.formulation) for candidate in candidates]
        cross_logits = self.cross_encoder.predict(pairs)

        for candidate, cross_logit in zip(candidates, cross_logits):
            candidate.cross_encoder_logit = float(cross_logit)
            candidate.cross_encoder_score = float(expit(cross_logit))  # Sigmoid pour obtenir un score entre 0 et 1

        # Sélectionner le meilleur score cross-encoder et le second meilleur pour calculer l'écart entre les 2
        ranked_candidates = sorted(
            candidates,
            key=lambda c: c.cross_encoder_score
            if c.cross_encoder_score is not None
            else float("-inf"),
            reverse=True,
        )

        logger.info(
            "Candidats re-classés par cross-encoder: "
            + ", ".join(
                f"[{c.theme}: {c.cross_encoder_score:.3f}]" for c in ranked_candidates
            )
        )

        best_candidate = ranked_candidates[0]
        best_score = best_candidate.cross_encoder_score or 0.0

        if best_score < cross_encodeur_confidence_threshold:
            logger.info(
                "Score cross-encoder suffisant : "
                f"{best_score:.3f}"
            )
            return AnswerOutput(
                question=question,
                formulation=best_candidate.formulation,
                answer="Je ne dispose pas d'une réponse suffisamment pertinente pour cette question.",
                theme=best_candidate.theme,
                similarity_score=round(best_candidate.similarity_score or 0.0, 4),
                cross_encoder_logit=round(best_candidate.cross_encoder_logit or 0.0, 4),
                cross_encoder_score=round(best_score or 0.0, 4),
                list_candidates=candidates,
                confidence=False,
            )

        if len(ranked_candidates) == 1:
            logger.info(
                "Un seul candidat trouvé avec un score cross-encoder suffisant."
            )
            return AnswerOutput(
                question=question,
                formulation=best_candidate.formulation,
                answer=best_candidate.response,
                theme=best_candidate.theme,
                similarity_score=round(best_candidate.similarity_score or 0.0, 4),
                cross_encoder_logit=round(best_candidate.cross_encoder_logit or 0.0, 4),
                cross_encoder_score=round(best_score or 0.0, 4),
                list_candidates=candidates,
                confidence=True,
            )

        second_candidate = ranked_candidates[1] if len(ranked_candidates) > 1 else None
        relative_gap = abs((best_score - second_candidate.cross_encoder_score) / best_score) \
                       if second_candidate and best_score != 0 else 0

        if relative_gap < cross_encodeur_gap_threshold:
            logger.info(
                f"GAP entre 1er et 2ème meilleurs scores: {relative_gap:.3f} "
                f"< seuil {cross_encodeur_gap_threshold}. Question: {question}"
            )
            return AnswerOutput(
                question=question,
                formulation=best_candidate.formulation,
                answer="J'ai besoin de plus d'éléments pour répondre à cette question.",
                theme=best_candidate.theme,
                similarity_score=round(best_candidate.similarity_score or 0.0, 4),
                cross_encoder_logit=round(best_candidate.cross_encoder_logit or 0.0, 4),
                cross_encoder_score=round(best_candidate.cross_encoder_score or 0.0, 4),
                list_candidates=candidates,
                confidence=False,
            )

        logger.info(
            f"Match trouvé - Theme: {best_candidate.theme}, "
            f"Score cross-encoder: {best_score:.3f}"
        )

        return AnswerOutput(
            question=question,
            formulation=best_candidate.formulation,
            answer=best_candidate.response,
            theme=best_candidate.theme,
            similarity_score=round(best_candidate.similarity_score or 0.0, 4),
            cross_encoder_score=round(best_score or 0.0, 4),
            list_candidates=candidates,
            confidence=True,
        )

    def get_collection_size(self) -> int:
        """

        Get the number of documents in the collection.



        Returns:

            The collection size.

        """
        return self.collection.count()

    def _normalize_text(self, text: str) -> str:
        # Uniformise les apostrophes et guillemets typographiques
        text = text.translate(self._TRANSLATION_TABLE)

        # Normalise les caractères unicode équivalents (NFC)
        text = unicodedata.normalize("NFC", text)

        # Espaces multiples, trim (déjà optimal : split()+join en C)
        text = " ".join(text.split())

        return text