Loren commited on
Commit
f09f2a0
·
verified ·
1 Parent(s): 89025ff

Upload 4 files

Browse files
Files changed (4) hide show
  1. config.py +5 -2
  2. embeddings.py +190 -40
  3. embeddings_v1.py +247 -0
  4. models.py +17 -2
config.py CHANGED
@@ -10,11 +10,14 @@ DATA_DIR: Path = BASE_DIR / "data"
10
  FAQ_JSON_PATH: str = str(DATA_DIR / "260717_FAQ_Miss_Terry_developpeur.json")
11
 
12
  # Configuration des embeddings
13
- MODEL_NAME: str = os.getenv("MODEL_NAME", "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
14
 
15
  # Configuration ChromaDB
16
  CHROMA_COLLECTION_NAME: str = "faq"
17
 
18
  # Configuration de similarité
19
- SIMILARITY_THRESHOLD: float = float(os.getenv("SIMILARITY_THRESHOLD", "0.70"))
20
 
 
 
 
 
10
  FAQ_JSON_PATH: str = str(DATA_DIR / "260717_FAQ_Miss_Terry_developpeur.json")
11
 
12
  # Configuration des embeddings
13
+ MODEL_NAME: str = os.getenv("MODEL_NAME", "intfloat/multilingual-e5-small") #"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
14
 
15
  # Configuration ChromaDB
16
  CHROMA_COLLECTION_NAME: str = "faq"
17
 
18
  # Configuration de similarité
19
+ SIMILARITY_THRESHOLD: float = float(os.getenv("SIMILARITY_THRESHOLD", "0.80"))
20
 
21
+ TOP_K = 3
22
+ CROSS_ENCODER_MODEL_NAME = "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1"
23
+ CROSS_ENCODER_THRESHOLD = 0.5
embeddings.py CHANGED
@@ -1,7 +1,7 @@
1
  """Gestion des embeddings avec ChromaDB et Hugging Face Transformers."""
2
  import os
3
- # Règle d’or : toute variable d’environnement qui influence le cache Hugging Face doit être
4
- # définie avant d’importer datasets ou transformers, sinon elle sera ignorée.
5
  cache_dir = "/tmp"
6
  os.makedirs(cache_dir, exist_ok=True)
7
 
@@ -10,23 +10,30 @@ os.environ["HF_HOME"] = cache_dir
10
  os.environ["HF_DATASETS_CACHE"] = os.path.join(cache_dir, "datasets")
11
  os.environ["TRANSFORMERS_CACHE"] = os.path.join(cache_dir, "transformers")
12
 
13
- import logging
14
- from typing import Optional
15
- from models import QuestionInput, AnswerOutput
 
 
16
 
 
 
 
17
  import chromadb
18
- from sentence_transformers import SentenceTransformer
19
  import torch
20
  from config import (
21
  CHROMA_COLLECTION_NAME,
22
  MODEL_NAME,
23
  SIMILARITY_THRESHOLD,
 
 
 
24
  )
25
  from faq_loader import FAQEntry
26
 
27
  logger = logging.getLogger(__name__)
28
 
29
-
30
  class EmbeddingManager:
31
  """Gestionnaire des embeddings et de ChromaDB."""
32
 
@@ -34,6 +41,7 @@ class EmbeddingManager:
34
  self,
35
  model_name: str = MODEL_NAME,
36
  collection_name: str = CHROMA_COLLECTION_NAME,
 
37
  ) -> None:
38
  """
39
  Initialiser le gestionnaire d'embeddings.
@@ -41,13 +49,34 @@ class EmbeddingManager:
41
  Args:
42
  model_name: Nom du modèle Sentence-Transformers à utiliser
43
  collection_name: Nom de la collection ChromaDB
 
44
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
  # Creation du Sentence transformer model
47
  logger.info(f"Initialisation du modèle d'embeddings: {model_name}")
48
  device = "cuda" if torch.cuda.is_available() else "cpu"
49
  self.model = SentenceTransformer(MODEL_NAME, device=device)
50
 
 
 
 
 
 
51
  # Initialiser ChromaDB en mémoire (EphemeralClient)
52
  # Idéal pour HF Spaces: pas de persistance disque, plus rapide au startup
53
  logger.info("Initialisation ChromaDB en mémoire (EphemeralClient)")
@@ -80,7 +109,7 @@ class EmbeddingManager:
80
  self.collection.delete(ids=existing_ids)
81
 
82
  # Générer les embeddings pour toutes les formulations
83
- formulations: list[str] = [entry.formulation for entry in faq_entries]
84
  logger.info("Génération des embeddings...")
85
  embeddings = self.model.encode(formulations, show_progress_bar=True,
86
  convert_to_numpy=True, normalize_embeddings=True)
@@ -102,29 +131,102 @@ class EmbeddingManager:
102
 
103
  logger.info(f"✓ ChromaDB peuplée avec {len(faq_entries)} entrées")
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  def search_similar_faq(self, payload: QuestionInput) -> AnswerOutput:
106
  """
107
- Rechercher dans la FAQ la formulation laplus similaire à une question donnée.
 
 
 
 
 
 
 
 
108
 
109
  Args:
110
- question: Question à chercher
111
- threshold: Seuil de confiance minimum pour retourner une réponse
112
 
113
  Returns:
114
- Tuple (formulation, theme, response, similarity_score) ou None si aucun match
115
  """
116
- # Récupérer la question et le seuil depuis le payload
117
  question = payload.question
118
- threshold = payload.threshold if payload.threshold is not None else SIMILARITY_THRESHOLD
 
 
 
 
 
 
119
 
120
  # Générer l'embedding de la question
121
- question_embedding = self.model.encode([question], convert_to_numpy=True,
122
- normalize_embeddings=True).tolist()
 
 
 
 
 
 
 
 
123
 
124
- # Rechercher dans ChromaDB
125
  results = self.collection.query(
126
  query_embeddings=question_embedding,
127
- n_results=1,
128
  include=["documents", "metadatas", "distances"]
129
  )
130
 
@@ -133,48 +235,84 @@ class EmbeddingManager:
133
  return AnswerOutput(
134
  question=question,
135
  formulation="unknown",
136
- answer="Je ne dispose pas d'une réponse suffisamment pertinente pour cette question.",
137
  theme="unknown",
138
  similarity_score=0.0,
139
  confidence=False,
140
  )
141
 
142
- # Extraire le meilleur résultat
143
- # ChromaDB retourne les produits scalaires, on convertit en similarité cosinus
144
- distance = results["distances"][0][0]
145
- similarity_score: float = 1 - distance
146
-
147
- # Extraire les métadonnées
148
- metadata = results["metadatas"][0][0]
149
- formulation = results["documents"][0][0]
150
- theme = metadata["theme"]
151
- response = metadata["response"]
152
 
153
- # Vérifier le seuil
154
- if similarity_score < threshold:
155
  logger.info(
156
- f"Score de similarité {similarity_score:.3f} < seuil {threshold}. "
157
  f"Question: {question}"
158
  )
159
  return AnswerOutput(
160
  question=question,
161
- formulation=formulation,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  answer="Je ne dispose pas d'une réponse suffisamment pertinente pour cette question.",
163
- theme=theme,
164
- similarity_score=round(similarity_score, 4),
 
 
165
  confidence=False,
166
  )
167
 
168
  logger.info(
169
- f"Match trouvé - Theme: {theme}, Score: {similarity_score:.3f}"
 
170
  )
171
 
172
  return AnswerOutput(
173
  question=question,
174
- formulation=formulation,
175
- answer=response,
176
- theme=theme,
177
- similarity_score=round(similarity_score, 4),
 
 
178
  confidence=True,
179
  )
180
 
@@ -186,3 +324,15 @@ class EmbeddingManager:
186
  Nombre de documents
187
  """
188
  return self.collection.count()
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """Gestion des embeddings avec ChromaDB et Hugging Face Transformers."""
2
  import os
3
+ # Règle d'or : toute variable d'environnement qui influence le cache Hugging Face doit être
4
+ # définie avant d'importer datasets ou transformers, sinon elle sera ignorée.
5
  cache_dir = "/tmp"
6
  os.makedirs(cache_dir, exist_ok=True)
7
 
 
10
  os.environ["HF_DATASETS_CACHE"] = os.path.join(cache_dir, "datasets")
11
  os.environ["TRANSFORMERS_CACHE"] = os.path.join(cache_dir, "transformers")
12
 
13
+ # Désactiver la télémétrie ChromaDB/PostHog pour éviter les erreurs de signature
14
+ # de capture() sur certaines versions de dependent packages.
15
+ os.environ.setdefault("CHROMA_DISABLE_TELEMETRY", "1")
16
+ os.environ.setdefault("CHROMADB_DISABLE_TELEMETRY", "1")
17
+ os.environ.setdefault("POSTHOG_DISABLED", "1")
18
 
19
+ import logging
20
+ from models import QuestionInput, AnswerOutput, SemanticSearchCandidate
21
+ import unicodedata
22
  import chromadb
23
+ from sentence_transformers import SentenceTransformer, CrossEncoder
24
  import torch
25
  from config import (
26
  CHROMA_COLLECTION_NAME,
27
  MODEL_NAME,
28
  SIMILARITY_THRESHOLD,
29
+ TOP_K,
30
+ CROSS_ENCODER_MODEL_NAME,
31
+ CROSS_ENCODER_THRESHOLD,
32
  )
33
  from faq_loader import FAQEntry
34
 
35
  logger = logging.getLogger(__name__)
36
 
 
37
  class EmbeddingManager:
38
  """Gestionnaire des embeddings et de ChromaDB."""
39
 
 
41
  self,
42
  model_name: str = MODEL_NAME,
43
  collection_name: str = CHROMA_COLLECTION_NAME,
44
+ cross_encoder_model_name: str = CROSS_ENCODER_MODEL_NAME,
45
  ) -> None:
46
  """
47
  Initialiser le gestionnaire d'embeddings.
 
49
  Args:
50
  model_name: Nom du modèle Sentence-Transformers à utiliser
51
  collection_name: Nom de la collection ChromaDB
52
+ cross_encoder_model_name: Nom du modèle Cross-Encoder utilisé pour le re-ranking
53
  """
54
+ # Table de traduction pré-compilée une seule fois (au chargement du module)
55
+ self._TRANSLATION_TABLE = str.maketrans({
56
+ "’": "'", "‘": "'",
57
+ "«": '"', "»": '"',
58
+ "“": '"', "”": '"',
59
+ "–": "-", "—": "-",
60
+ "?": ""
61
+ })
62
+ if "-e5-" in model_name:
63
+ logger.info(f"Modèle {model_name} détecté comme modèle E5.")
64
+ self._prefix_p = "passage: "
65
+ self._prefix_q = "query: "
66
+ else:
67
+ self._prefix_p = ""
68
+ self._prefix_q = ""
69
 
70
  # Creation du Sentence transformer model
71
  logger.info(f"Initialisation du modèle d'embeddings: {model_name}")
72
  device = "cuda" if torch.cuda.is_available() else "cpu"
73
  self.model = SentenceTransformer(MODEL_NAME, device=device)
74
 
75
+ # Création du modèle Cross-Encoder utilisé pour le re-ranking
76
+ logger.info(f"Initialisation du modèle Cross-Encoder: {cross_encoder_model_name}")
77
+ self.cross_encoder = CrossEncoder(cross_encoder_model_name, device=device)
78
+ self.cross_encoder_model_name: str = cross_encoder_model_name
79
+
80
  # Initialiser ChromaDB en mémoire (EphemeralClient)
81
  # Idéal pour HF Spaces: pas de persistance disque, plus rapide au startup
82
  logger.info("Initialisation ChromaDB en mémoire (EphemeralClient)")
 
109
  self.collection.delete(ids=existing_ids)
110
 
111
  # Générer les embeddings pour toutes les formulations
112
+ formulations: list[str] = [self._prefix_p + self._normalize_text(entry.formulation) for entry in faq_entries]
113
  logger.info("Génération des embeddings...")
114
  embeddings = self.model.encode(formulations, show_progress_bar=True,
115
  convert_to_numpy=True, normalize_embeddings=True)
 
131
 
132
  logger.info(f"✓ ChromaDB peuplée avec {len(faq_entries)} entrées")
133
 
134
+ def _filter_and_dedup_by_theme(
135
+ self,
136
+ documents: list[str],
137
+ metadatas: list[dict],
138
+ distances: list[float],
139
+ similarity_threshold: float,
140
+ top_k: int,
141
+ ) -> list[SemanticSearchCandidate]:
142
+ """
143
+ Filtre les candidats Chroma par seuil de similarité cosinus, puis conserve
144
+ une seule formulation (la meilleure) par thème, jusqu'à obtenir `top_k`
145
+ thèmes distincts.
146
+
147
+ Les résultats de Chroma étant triés par similarité décroissante, la
148
+ fonction s'arrête dès que le seuil n'est plus atteint ou que `top_k`
149
+ thèmes distincts ont été retenus.
150
+ """
151
+ seen_themes: set[str] = set()
152
+ candidates: list[SemanticSearchCandidate] = []
153
+
154
+ for document, metadata, distance in zip(
155
+ documents,
156
+ metadatas,
157
+ distances,
158
+ strict=True,
159
+ ):
160
+ similarity = 1.0 - distance
161
+
162
+ # Les résultats étant triés par similarité décroissante,
163
+ # tous les suivants seront également sous le seuil.
164
+ if similarity < similarity_threshold:
165
+ break
166
+
167
+ theme = metadata["theme"]
168
+
169
+ if theme not in seen_themes:
170
+ seen_themes.add(theme)
171
+
172
+ candidates.append(
173
+ SemanticSearchCandidate(
174
+ formulation=document,
175
+ theme=theme,
176
+ response=metadata["response"],
177
+ similarity_score=similarity,
178
+ )
179
+ )
180
+
181
+ if len(candidates) == top_k:
182
+ break
183
+
184
+ return candidates
185
+
186
  def search_similar_faq(self, payload: QuestionInput) -> AnswerOutput:
187
  """
188
+ Rechercher dans la FAQ la formulation la plus pertinente pour une question donnée.
189
+
190
+ Étapes :
191
+ 1. Récupération des candidats dans ChromaDB (similarité cosinus), filtrés
192
+ par SIMILARITY_THRESHOLD et dédupliqués pour ne garder qu'une
193
+ formulation par thème, limités à top_k.
194
+ 2. Re-ranking de ces top_k candidats avec un Cross-Encoder (question, formulation).
195
+ 3. Retour de la réponse associée au meilleur score Cross-Encoder si celui-ci
196
+ dépasse le seuil fourni, sinon message de repli.
197
 
198
  Args:
199
+ payload: QuestionInput contenant la question, et éventuellement
200
+ threshold (cosinus), top_k et le seuil cross-encoder.
201
 
202
  Returns:
203
+ AnswerOutput
204
  """
205
+ # Récupérer la question et les paramètres depuis le payload
206
  question = payload.question
207
+ similarity_threshold = payload.similarity_threshold if payload.similarity_threshold is not None else SIMILARITY_THRESHOLD
208
+ top_k = getattr(payload, "top_k", None) or TOP_K
209
+ cross_encoder_threshold = (
210
+ getattr(payload, "cross_encoder_threshold", None)
211
+ if getattr(payload, "cross_encoder_threshold", None) is not None
212
+ else CROSS_ENCODER_THRESHOLD
213
+ )
214
 
215
  # Générer l'embedding de la question
216
+ question_embedding = self.model.encode(
217
+ [self._prefix_q + self._normalize_text(question)],
218
+ convert_to_numpy=True,
219
+ normalize_embeddings=True,
220
+ ).tolist()
221
+
222
+ # On récupère un pool de candidats plus large que top_k auprès de Chroma,
223
+ # car plusieurs formulations peuvent partager le même thème et doivent être
224
+ # dédupliquées avant d'atteindre top_k thèmes distincts.
225
+ n_candidates = max(top_k * 5, 20)
226
 
 
227
  results = self.collection.query(
228
  query_embeddings=question_embedding,
229
+ n_results=n_candidates,
230
  include=["documents", "metadatas", "distances"]
231
  )
232
 
 
235
  return AnswerOutput(
236
  question=question,
237
  formulation="unknown",
238
+ answer="Je ne dispose pas d'éléments de réponse pour cette question.",
239
  theme="unknown",
240
  similarity_score=0.0,
241
  confidence=False,
242
  )
243
 
244
+ # Filtrer par seuil cosinus et dédupliquer par thème (top_k thèmes distincts)
245
+ candidates = self._filter_and_dedup_by_theme(
246
+ documents=results["documents"][0],
247
+ metadatas=results["metadatas"][0],
248
+ distances=results["distances"][0],
249
+ similarity_threshold=similarity_threshold,
250
+ top_k=top_k,
251
+ )
 
 
252
 
253
+ if not candidates:
 
254
  logger.info(
255
+ f"Aucun candidat au-dessus du seuil cosinus {similarity_threshold}. "
256
  f"Question: {question}"
257
  )
258
  return AnswerOutput(
259
  question=question,
260
+ formulation="unknown",
261
+ answer="Je ne dispose pas d'éléments de réponse pour cette question.",
262
+ theme="unknown",
263
+ similarity_score=0.0,
264
+ confidence=False,
265
+ )
266
+
267
+ # Appliquer le Cross-Encoder entre la question et chaque formulation candidate
268
+ pairs = [(question, candidate.formulation) for candidate in candidates]
269
+ cross_scores = self.cross_encoder.predict(pairs)
270
+
271
+ for candidate, cross_score in zip(candidates, cross_scores):
272
+ candidate.cross_encoder_score = float(cross_score)
273
+
274
+ # Sélectionner le meilleur score cross-encoder
275
+ best_candidate = max(
276
+ candidates,
277
+ key=lambda c: c.cross_encoder_score if c.cross_encoder_score is not None else float("-inf"),
278
+ )
279
+
280
+ logger.info(
281
+ "Candidats re-classés par cross-encoder: "
282
+ + ", ".join(
283
+ f"[{c.theme}: {c.cross_encoder_score:.3f}]" for c in candidates
284
+ )
285
+ )
286
+
287
+ if best_candidate.cross_encoder_score is None or best_candidate.cross_encoder_score < cross_encoder_threshold:
288
+ logger.info(
289
+ f"Meilleur score cross-encoder {best_candidate.cross_encoder_score:.3f} "
290
+ f"< seuil {cross_encoder_threshold}. Question: {question}"
291
+ )
292
+ return AnswerOutput(
293
+ question=question,
294
+ formulation=best_candidate.formulation,
295
  answer="Je ne dispose pas d'une réponse suffisamment pertinente pour cette question.",
296
+ theme=best_candidate.theme,
297
+ similarity_score=round(best_candidate.similarity_score or 0.0, 4),
298
+ cross_encoder_score=round(best_candidate.cross_encoder_score or 0.0, 4),
299
+ list_candidates=candidates,
300
  confidence=False,
301
  )
302
 
303
  logger.info(
304
+ f"Match trouvé - Theme: {best_candidate.theme}, "
305
+ f"Score cross-encoder: {best_candidate.cross_encoder_score:.3f}"
306
  )
307
 
308
  return AnswerOutput(
309
  question=question,
310
+ formulation=best_candidate.formulation,
311
+ answer=best_candidate.response,
312
+ theme=best_candidate.theme,
313
+ similarity_score=round(best_candidate.similarity_score or 0.0, 4),
314
+ cross_encoder_score=round(best_candidate.cross_encoder_score or 0.0, 4),
315
+ list_candidates=candidates,
316
  confidence=True,
317
  )
318
 
 
324
  Nombre de documents
325
  """
326
  return self.collection.count()
327
+
328
+ def _normalize_text(self, text: str) -> str:
329
+ # Uniformise les apostrophes et guillemets typographiques
330
+ text = text.translate(self._TRANSLATION_TABLE)
331
+
332
+ # Normalise les caractères unicode équivalents (NFC)
333
+ text = unicodedata.normalize("NFC", text)
334
+
335
+ # Espaces multiples, trim (déjà optimal : split()+join en C)
336
+ text = " ".join(text.split())
337
+
338
+ return text
embeddings_v1.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gestion des embeddings avec ChromaDB et Hugging Face Transformers."""
2
+ import os
3
+ # Règle d’or : toute variable d’environnement qui influence le cache Hugging Face doit être
4
+ # définie avant d’importer datasets ou transformers, sinon elle sera ignorée.
5
+ cache_dir = "/tmp"
6
+ os.makedirs(cache_dir, exist_ok=True)
7
+
8
+ # Rediriger le cache HF globalement
9
+ os.environ["HF_HOME"] = cache_dir
10
+ os.environ["HF_DATASETS_CACHE"] = os.path.join(cache_dir, "datasets")
11
+ os.environ["TRANSFORMERS_CACHE"] = os.path.join(cache_dir, "transformers")
12
+
13
+ # Désactiver la télémétrie ChromaDB/PostHog pour éviter les erreurs de signature
14
+ # de capture() sur certaines versions de dependent packages.
15
+ os.environ.setdefault("CHROMA_DISABLE_TELEMETRY", "1")
16
+ os.environ.setdefault("CHROMADB_DISABLE_TELEMETRY", "1")
17
+ os.environ.setdefault("POSTHOG_DISABLED", "1")
18
+
19
+ import logging
20
+ from typing import Optional
21
+ from models import QuestionInput, AnswerOutput
22
+ import unicodedata
23
+ import chromadb
24
+ from sentence_transformers import SentenceTransformer
25
+ import torch
26
+ from config import (
27
+ CHROMA_COLLECTION_NAME,
28
+ MODEL_NAME,
29
+ SIMILARITY_THRESHOLD,
30
+ )
31
+ from faq_loader import FAQEntry
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class EmbeddingManager:
37
+ """Gestionnaire des embeddings et de ChromaDB."""
38
+
39
+ def __init__(
40
+ self,
41
+ model_name: str = MODEL_NAME,
42
+ collection_name: str = CHROMA_COLLECTION_NAME,
43
+ ) -> None:
44
+ """
45
+ Initialiser le gestionnaire d'embeddings.
46
+
47
+ Args:
48
+ model_name: Nom du modèle Sentence-Transformers à utiliser
49
+ collection_name: Nom de la collection ChromaDB
50
+ """
51
+ # Table de traduction pré-compilée une seule fois (au chargement du module)
52
+ self._TRANSLATION_TABLE = str.maketrans({
53
+ "’": "'", "‘": "'",
54
+ "«": '"', "»": '"',
55
+ "“": '"', "”": '"',
56
+ "–": "-", "—": "-",
57
+ "?": ""
58
+ })
59
+ if "-e5-" in model_name:
60
+ logger.info(f"Modèle {model_name} détecté comme modèle E5.")
61
+ self._prefix_p = "passage: "
62
+ self._prefix_q = "query: "
63
+ else:
64
+ self._prefix_p = ""
65
+ self._prefix_q = ""
66
+
67
+ # Creation du Sentence transformer model
68
+ logger.info(f"Initialisation du modèle d'embeddings: {model_name}")
69
+ device = "cuda" if torch.cuda.is_available() else "cpu"
70
+ self.model = SentenceTransformer(MODEL_NAME, device=device)
71
+
72
+ # Initialiser ChromaDB en mémoire (EphemeralClient)
73
+ # Idéal pour HF Spaces: pas de persistance disque, plus rapide au startup
74
+ logger.info("Initialisation ChromaDB en mémoire (EphemeralClient)")
75
+ self.client: chromadb.Client = chromadb.EphemeralClient()
76
+
77
+ # Créer ou récupérer la collection
78
+ self.collection = self.client.get_or_create_collection(
79
+ name=collection_name,
80
+ embedding_function=None,
81
+ metadata={"hnsw:space": "ip"} # Utiliser produit scalaire => normaliser les embeddings
82
+ )
83
+
84
+ self.collection_name: str = collection_name
85
+ logger.info(f"✓ EmbeddingManager initialisé (collection: {collection_name})")
86
+
87
+ def populate_collection(self, faq_entries: list[FAQEntry]) -> None:
88
+ """
89
+ Remplir la collection ChromaDB avec les embeddings des FAQ.
90
+
91
+ Args:
92
+ faq_entries: Liste d'objets FAQEntry à indexer
93
+ """
94
+ logger.info(f"Population de ChromaDB avec {len(faq_entries)} entrées...")
95
+
96
+ # Vider la collection existante (optionnel, mais plus propre)
97
+ # Récupérer l'ID de collection pour supprimer les anciens documents
98
+ existing_ids = self.collection.get()["ids"]
99
+ if existing_ids:
100
+ logger.info(f"Suppression de {len(existing_ids)} anciens documents...")
101
+ self.collection.delete(ids=existing_ids)
102
+
103
+ # Générer les embeddings pour toutes les formulations
104
+ formulations: list[str] = [self._prefix_p + self._normalize_text(entry.formulation) for entry in faq_entries]
105
+ logger.info("Génération des embeddings...")
106
+ embeddings = self.model.encode(formulations, show_progress_bar=True,
107
+ convert_to_numpy=True, normalize_embeddings=True)
108
+
109
+ # Ajouter à ChromaDB
110
+ logger.info("Ajout des documents à ChromaDB...")
111
+ self.collection.add(
112
+ ids=[f"faq_{i}" for i in range(len(faq_entries))],
113
+ embeddings=embeddings.tolist(),
114
+ documents=formulations,
115
+ metadatas=[
116
+ {
117
+ "theme": entry.theme,
118
+ "response": entry.response,
119
+ }
120
+ for entry in faq_entries
121
+ ],
122
+ )
123
+
124
+ logger.info(f"✓ ChromaDB peuplée avec {len(faq_entries)} entrées")
125
+
126
+ def search_similar_faq(self, payload: QuestionInput) -> AnswerOutput:
127
+ """
128
+ Rechercher dans la FAQ la formulation laplus similaire à une question donnée.
129
+
130
+ Args:
131
+ question: Question à chercher
132
+ threshold: Seuil de confiance minimum pour retourner une réponse
133
+
134
+ Returns:
135
+ Tuple (formulation, theme, response, similarity_score) ou None si aucun match
136
+ """
137
+ # Récupérer la question, le seuil et le nombre de résultats à considérer
138
+ question = payload.question
139
+ threshold = payload.threshold if payload.threshold is not None else SIMILARITY_THRESHOLD
140
+ top_k = payload.top_k if payload.top_k is not None else 1
141
+ top_k = max(1, top_k)
142
+
143
+ # Générer l'embedding de la question
144
+ question_embedding = self.model.encode([self._prefix_q + self._normalize_text(question)], convert_to_numpy=True,
145
+ normalize_embeddings=True).tolist()
146
+
147
+ # Rechercher dans ChromaDB avec plusieurs résultats potentiels
148
+ results = self.collection.query(
149
+ query_embeddings=question_embedding,
150
+ n_results=top_k,
151
+ include=["documents", "metadatas", "distances"]
152
+ )
153
+
154
+ if not results["ids"] or not results["ids"][0]:
155
+ logger.warning(f"Aucun résultat trouvé pour: {question}")
156
+ return AnswerOutput(
157
+ question=question,
158
+ formulation="unknown",
159
+ answer="Je ne dispose pas d'une réponse suffisamment pertinente pour cette question.",
160
+ theme="unknown",
161
+ similarity_score=0.0,
162
+ confidence=False,
163
+ )
164
+
165
+ # Extraire le meilleur résultat parmi les candidats retournés par Chroma
166
+ # ChromaDB retourne les produits scalaires, on convertit en similarité cosinus
167
+ best_match = None
168
+ for index in range(len(results["ids"][0])):
169
+ distance = results["distances"][0][index]
170
+ similarity_score: float = 1 - distance
171
+
172
+ metadata = results["metadatas"][0][index]
173
+ formulation = results["documents"][0][index]
174
+ theme = metadata["theme"]
175
+ response = metadata["response"]
176
+
177
+ candidate = {
178
+ "formulation": formulation,
179
+ "theme": theme,
180
+ "response": response,
181
+ "similarity_score": similarity_score,
182
+ }
183
+
184
+ if best_match is None or candidate["similarity_score"] > best_match["similarity_score"]:
185
+ best_match = candidate
186
+
187
+ if best_match is None:
188
+ logger.warning(f"Aucun candidat exploitable trouvé pour: {question}")
189
+ return AnswerOutput(
190
+ question=question,
191
+ formulation="unknown",
192
+ answer="Je ne dispose pas d'une réponse suffisamment pertinente pour cette question.",
193
+ theme="unknown",
194
+ similarity_score=0.0,
195
+ confidence=False,
196
+ )
197
+
198
+ similarity_score = round(best_match["similarity_score"], 4)
199
+
200
+ # Vérifier le seuil
201
+ if similarity_score < threshold:
202
+ logger.info(
203
+ f"Score de similarité {similarity_score:.3f} < seuil {threshold}. "
204
+ f"Question: {question}"
205
+ )
206
+ return AnswerOutput(
207
+ question=question,
208
+ formulation=best_match["formulation"],
209
+ answer="Je ne dispose pas d'une réponse suffisamment pertinente pour cette question.",
210
+ theme=best_match["theme"],
211
+ similarity_score=similarity_score,
212
+ confidence=False,
213
+ )
214
+
215
+ logger.info(
216
+ f"Match trouvé - Theme: {best_match['theme']}, Score: {similarity_score:.3f}"
217
+ )
218
+
219
+ return AnswerOutput(
220
+ question=question,
221
+ formulation=best_match["formulation"],
222
+ answer=best_match["response"],
223
+ theme=best_match["theme"],
224
+ similarity_score=similarity_score,
225
+ confidence=True,
226
+ )
227
+
228
+ def get_collection_size(self) -> int:
229
+ """
230
+ Obtenir le nombre de documents dans la collection.
231
+
232
+ Returns:
233
+ Nombre de documents
234
+ """
235
+ return self.collection.count()
236
+
237
+ def _normalize_text(self, text: str) -> str:
238
+ # Uniformise les apostrophes et guillemets typographiques
239
+ text = text.translate(self._TRANSLATION_TABLE)
240
+
241
+ # Normalise les caractères unicode équivalents (NFC)
242
+ text = unicodedata.normalize("NFC", text)
243
+
244
+ # Espaces multiples, trim (déjà optimal : split()+join en C)
245
+ text = " ".join(text.split())
246
+
247
+ return text
models.py CHANGED
@@ -1,4 +1,5 @@
1
  """Modèles Pydantic pour les requêtes et réponses API."""
 
2
  from typing import Optional
3
 
4
  from pydantic import BaseModel, Field, ConfigDict
@@ -11,8 +12,10 @@ class QuestionInput(BaseModel):
11
  None,
12
  ge=0.0,
13
  le=1.0,
14
- description="Seuil de similarité cosinus souhaité entre 0.0 et 1.0. Si omis, la valeur par défaut du service est utilisée.",
15
  )
 
 
16
 
17
  model_config = ConfigDict(
18
  json_schema_extra={
@@ -23,6 +26,14 @@ class QuestionInput(BaseModel):
23
  }
24
  )
25
 
 
 
 
 
 
 
 
 
26
 
27
  class AnswerOutput(BaseModel):
28
  """Modèle pour une réponse de sortie."""
@@ -31,7 +42,9 @@ class AnswerOutput(BaseModel):
31
  answer: str = Field(..., description="Réponse trouvée dans la base FAQ")
32
  theme: str = Field(..., description="Thème/catégorie de la réponse")
33
  similarity_score: float = Field(..., ge=0.0, le=1.0, description="Score de similarité cosinus [0, 1]")
 
34
  confidence: bool = Field(..., description="True si score >= seuil de confiance, False sinon")
 
35
 
36
  model_config = ConfigDict(
37
  json_schema_extra={
@@ -41,7 +54,9 @@ class AnswerOutput(BaseModel):
41
  "answer": "Tu peux soit 'tinscrire, soit commencer ta candidature depuis le bouton « Inscription » du site. Tu renseigneras ton profil, tes compétences et les domaines qui t’intéressent afin que l’équipe puisse étudier ta candidature.",
42
  "theme": "comment_rejoindre_guild_open_tech",
43
  "similarity_score": 0.889,
44
- "confidence": True
 
 
45
  }
46
  }
47
  )
 
1
  """Modèles Pydantic pour les requêtes et réponses API."""
2
+ from dataclasses import dataclass
3
  from typing import Optional
4
 
5
  from pydantic import BaseModel, Field, ConfigDict
 
12
  None,
13
  ge=0.0,
14
  le=1.0,
15
+ description="Seuil de similarité cosinus souhaité entre 0.0 et 1.0.",
16
  )
17
+ top_k: Optional[int] = None
18
+ cross_encoder_threshold: Optional[float] = None
19
 
20
  model_config = ConfigDict(
21
  json_schema_extra={
 
26
  }
27
  )
28
 
29
+ @dataclass(slots=True)
30
+ class SemanticSearchCandidate:
31
+ """Candidat issu de la recherche sémantique."""
32
+ formulation: str
33
+ theme: str
34
+ response: str
35
+ similarity_score: float
36
+ cross_encoder_score: float | None = None
37
 
38
  class AnswerOutput(BaseModel):
39
  """Modèle pour une réponse de sortie."""
 
42
  answer: str = Field(..., description="Réponse trouvée dans la base FAQ")
43
  theme: str = Field(..., description="Thème/catégorie de la réponse")
44
  similarity_score: float = Field(..., ge=0.0, le=1.0, description="Score de similarité cosinus [0, 1]")
45
+ cross_encoder_score: Optional[float] = Field(None, ge=0.0, le=1.0, description="Score du cross-encoder [0, 1]")
46
  confidence: bool = Field(..., description="True si score >= seuil de confiance, False sinon")
47
+ list_candidates: Optional[list[SemanticSearchCandidate]] = Field(None, description="Liste des candidats sémantiques")
48
 
49
  model_config = ConfigDict(
50
  json_schema_extra={
 
54
  "answer": "Tu peux soit 'tinscrire, soit commencer ta candidature depuis le bouton « Inscription » du site. Tu renseigneras ton profil, tes compétences et les domaines qui t’intéressent afin que l’équipe puisse étudier ta candidature.",
55
  "theme": "comment_rejoindre_guild_open_tech",
56
  "similarity_score": 0.889,
57
+ "cross_encoder_score": 0.95,
58
+ "confidence": True,
59
+ "list_candidates": []
60
  }
61
  }
62
  )