Spaces:
Sleeping
Sleeping
| """ | |
| Ingest Training Data Script — Version parallélisée | |
| ===================================================== | |
| Trois phases : | |
| 1. Extraction + chunking de tous les fichiers (ThreadPoolExecutor, I/O + CPU) | |
| 2. Embedding via Azure OpenAI (ThreadPoolExecutor, I/O pur → gros gain) | |
| 3. Insertion bulk dans ChromaDB (1 seul appel par tranche de 5000) | |
| Usage: | |
| python ingest_train_data.py # ingestion normale (skip si déjà fait) | |
| python ingest_train_data.py --reset # supprime la collection et recommence | |
| Paramètres de parallélisme (ajuster selon les limites de l'API Azure) : | |
| EXTRACTION_WORKERS : workers pour la lecture/parsing des PDFs | |
| EMBEDDING_WORKERS : workers pour les appels HTTP à l'API d'embedding | |
| EMBEDDING_BATCH_SIZE: chunks par requête (max ~2048 pour Azure OpenAI) | |
| """ | |
| import argparse | |
| import json | |
| import logging | |
| import os | |
| import re | |
| import sys | |
| import time | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| from pathlib import Path | |
| import numpy as np | |
| os.environ.setdefault("ANONYMIZED_TELEMETRY", "False") | |
| import requests as http_requests | |
| import chromadb | |
| from chromadb.config import Settings | |
| from langchain_text_splitters import RecursiveCharacterTextSplitter | |
| from pypdf import PdfReader | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") | |
| logger = logging.getLogger(__name__) | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| # Configuration test de la configuration | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| PROJECT_ROOT = Path(__file__).parent | |
| DATA_DIR = Path("/data_sementic") if Path("/data_sementic").is_dir() else PROJECT_ROOT / "data_sementic" | |
| TRAIN_DATA_DIR = Path("/train_data") if Path("/train_data").is_dir() else PROJECT_ROOT / "train_data" | |
| CHROMA_PERSIST_DIR = str(DATA_DIR / "chroma_db") | |
| COLLECTION_NAME = "rag_documents" | |
| # ── Méthode de chunking ──────────────────────────────────────────────────────── | |
| # "semantic" : coupe là où le sens change (recommandé, nécessite des appels embedding) | |
| # "recursive" : coupe par taille fixe (fallback, sans appel API supplémentaire) | |
| CHUNKING_METHOD = "semantic" | |
| # Paramètres du semantic chunking | |
| SEMANTIC_BREAKPOINT_PERCENTILE = 95 # coupe où la distance dépasse le 95e percentile | |
| SEMANTIC_BUFFER_SIZE = 1 # phrases de contexte de chaque côté pour l'embedding | |
| SEMANTIC_MIN_CHUNK_CHARS = 150 # fusionne les chunks trop petits | |
| SEMANTIC_MAX_CHUNK_CHARS = 2000 # re-découpe les chunks trop grands | |
| # Paramètres du chunking récursif (fallback ou méthode choisie) | |
| CHUNK_SIZE = 1000 | |
| CHUNK_OVERLAP = 150 | |
| # Parallélisme | |
| EMBEDDING_BATCH_SIZE = 64 # chunks par requête API (était 16) | |
| EMBEDDING_WORKERS = 6 # appels API simultanés — réduire si rate-limit Azure | |
| EXTRACTION_WORKERS = 8 # workers pour l'extraction/parsing PDF | |
| # Insertion ChromaDB par tranches pour éviter les problèmes mémoire | |
| CHROMA_INSERT_BATCH = 5_000 | |
| # Retry sur les appels embedding | |
| EMBEDDING_MAX_RETRIES = 3 | |
| EMBEDDING_RETRY_DELAY = 2.0 # secondes entre deux tentatives | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| # Chargement de la config | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| _CONFIG_PATH = DATA_DIR / "config.json" | |
| if not _CONFIG_PATH.exists(): | |
| _CONFIG_PATH = PROJECT_ROOT / "config.json" | |
| logger.warning(f"Pas de config.json dans {DATA_DIR} — utilisation de root config.json.") | |
| with open(_CONFIG_PATH, encoding="utf-8") as _f: | |
| _config = json.load(_f) | |
| EMBEDDING_ENDPOINT_URL = _config["embedding"]["endpoint_url"] | |
| EMBEDDING_MODEL_NAME = _config["embedding"]["model"] | |
| AZURE_API_KEY = os.environ.get("AZURE_API_KEY") | |
| if not AZURE_API_KEY: | |
| logger.error("AZURE_API_KEY n'est pas défini. Définissez la variable d'environnement avant de lancer.") | |
| sys.exit(1) | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| # Helpers | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| def extract_text_from_pdf(pdf_path: Path) -> str: | |
| reader = PdfReader(str(pdf_path)) | |
| pages_text = [] | |
| for page_num, page in enumerate(reader.pages, start=1): | |
| text = page.extract_text() | |
| if text and text.strip(): | |
| pages_text.append(f"[Page {page_num}]\n{text.strip()}") | |
| return "\n\n".join(pages_text) | |
| def chunk_text(text: str, source: str) -> list[dict]: | |
| if CHUNKING_METHOD == "semantic": | |
| return _semantic_chunk(text, source) | |
| return _recursive_chunk(text, source) | |
| def _recursive_chunk(text: str, source: str) -> list[dict]: | |
| splitter = RecursiveCharacterTextSplitter( | |
| chunk_size=CHUNK_SIZE, | |
| chunk_overlap=CHUNK_OVERLAP, | |
| separators=["\n\n", "\n", ". ", " ", ""], | |
| ) | |
| chunks = splitter.split_text(text) | |
| return [{"text": c, "source": source, "chunk_index": i} for i, c in enumerate(chunks)] | |
| def _split_sentences(text: str) -> list[str]: | |
| """Découpe le texte en phrases sur la ponctuation de fin.""" | |
| parts = re.split(r'(?<=[.!?])\s+', text.strip()) | |
| return [p.strip() for p in parts if len(p.strip()) > 10] | |
| def _embed_in_batches(texts: list[str]) -> list[list[float]]: | |
| """Embedding par batches avec retry, compatible avec les gros documents.""" | |
| all_embeddings: list = [] | |
| for i in range(0, len(texts), EMBEDDING_BATCH_SIZE): | |
| batch = texts[i : i + EMBEDDING_BATCH_SIZE] | |
| for attempt in range(1, EMBEDDING_MAX_RETRIES + 1): | |
| try: | |
| all_embeddings.extend(generate_embeddings(batch)) | |
| break | |
| except Exception as e: | |
| if attempt == EMBEDDING_MAX_RETRIES: | |
| raise | |
| time.sleep(EMBEDDING_RETRY_DELAY) | |
| return all_embeddings | |
| def _cosine_distance(a: list[float], b: list[float]) -> float: | |
| va, vb = np.array(a, dtype=np.float32), np.array(b, dtype=np.float32) | |
| return 1.0 - float(np.dot(va, vb) / (np.linalg.norm(va) * np.linalg.norm(vb) + 1e-10)) | |
| def _enforce_size_limits(chunks: list[str]) -> list[str]: | |
| """Fusionne les chunks trop courts, re-découpe les trop longs.""" | |
| # Fusion des chunks sous le minimum | |
| merged: list[str] = [] | |
| buf = "" | |
| for chunk in chunks: | |
| if buf and len(buf) + len(chunk) < SEMANTIC_MIN_CHUNK_CHARS: | |
| buf += " " + chunk | |
| else: | |
| if buf: | |
| merged.append(buf) | |
| buf = chunk | |
| if buf: | |
| merged.append(buf) | |
| # Re-découpage des chunks dépassant le maximum | |
| splitter = RecursiveCharacterTextSplitter( | |
| chunk_size=SEMANTIC_MAX_CHUNK_CHARS, | |
| chunk_overlap=50, | |
| separators=["\n\n", "\n", ". ", " ", ""], | |
| ) | |
| result: list[str] = [] | |
| for chunk in merged: | |
| if len(chunk) > SEMANTIC_MAX_CHUNK_CHARS: | |
| result.extend(splitter.split_text(chunk)) | |
| else: | |
| result.append(chunk) | |
| return result | |
| def _semantic_chunk(text: str, source: str) -> list[dict]: | |
| """ | |
| Semantic chunking : | |
| 1. Découpe en phrases | |
| 2. Crée des fenêtres de contexte (buffer de phrases adjacentes) | |
| 3. Embed les fenêtres pour mesurer les ruptures sémantiques | |
| 4. Coupe aux distances > percentile de seuil | |
| """ | |
| sentences = _split_sentences(text) | |
| if len(sentences) <= 2: | |
| return _recursive_chunk(text, source) | |
| # Fenêtres : chaque phrase + BUFFER phrases de contexte de chaque côté | |
| windows = [] | |
| for i in range(len(sentences)): | |
| start = max(0, i - SEMANTIC_BUFFER_SIZE) | |
| end = min(len(sentences), i + SEMANTIC_BUFFER_SIZE + 1) | |
| windows.append(" ".join(sentences[start:end])) | |
| try: | |
| embeddings = _embed_in_batches(windows) | |
| except Exception as e: | |
| logger.warning(f"Semantic chunking échoué pour {source} → fallback récursif : {e}") | |
| return _recursive_chunk(text, source) | |
| # Distance cosine entre fenêtres consécutives | |
| distances = [ | |
| _cosine_distance(embeddings[i], embeddings[i + 1]) | |
| for i in range(len(embeddings) - 1) | |
| ] | |
| # Points de coupure au-dessus du seuil percentile | |
| threshold = float(np.percentile(distances, SEMANTIC_BREAKPOINT_PERCENTILE)) | |
| breakpoints = {i + 1 for i, d in enumerate(distances) if d > threshold} | |
| # Regroupement des phrases en chunks | |
| raw_chunks: list[str] = [] | |
| current: list[str] = [] | |
| for i, sentence in enumerate(sentences): | |
| if i in breakpoints and current: | |
| raw_chunks.append(" ".join(current)) | |
| current = [sentence] | |
| else: | |
| current.append(sentence) | |
| if current: | |
| raw_chunks.append(" ".join(current)) | |
| final_chunks = _enforce_size_limits(raw_chunks) | |
| return [{"text": c, "source": source, "chunk_index": i} for i, c in enumerate(final_chunks)] | |
| def generate_embeddings(texts: list[str]) -> list[list[float]]: | |
| """Un seul appel HTTP — appelé en parallèle par plusieurs workers.""" | |
| headers = {"api-key": AZURE_API_KEY, "Content-Type": "application/json"} | |
| payload = {"input": texts, "model": EMBEDDING_MODEL_NAME} | |
| resp = http_requests.post(EMBEDDING_ENDPOINT_URL, headers=headers, json=payload, timeout=120) | |
| resp.raise_for_status() | |
| return [item["embedding"] for item in resp.json()["data"]] | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| # Workers (appelés depuis les ThreadPoolExecutors) | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| def _process_file(file_path: Path) -> tuple[str, list[dict]]: | |
| """Phase 1 — Extrait et chunke un fichier. Retourne (source, chunks).""" | |
| source = str(file_path.relative_to(TRAIN_DATA_DIR)) | |
| try: | |
| text = extract_text_from_pdf(file_path) if file_path.suffix.lower() == ".pdf" \ | |
| else file_path.read_text(encoding="utf-8") | |
| except Exception as e: | |
| logger.warning(f" Ignoré (erreur lecture) {source}: {e}") | |
| return source, [] | |
| if not text.strip(): | |
| logger.warning(f" Ignoré (pas de texte) {source}") | |
| return source, [] | |
| chunks = chunk_text(text, source=source) | |
| return source, chunks | |
| def _embed_batch(batch_idx: int, texts: list[str]) -> tuple[int, list[list[float]]]: | |
| """Phase 2 — Embedde un batch avec retry. Retourne (batch_idx, embeddings).""" | |
| for attempt in range(1, EMBEDDING_MAX_RETRIES + 1): | |
| try: | |
| return batch_idx, generate_embeddings(texts) | |
| except Exception as e: | |
| if attempt == EMBEDDING_MAX_RETRIES: | |
| logger.error(f" Batch {batch_idx} échoué après {EMBEDDING_MAX_RETRIES} tentatives : {e}") | |
| raise | |
| logger.warning(f" Batch {batch_idx} — tentative {attempt} échouée ({e}), retry dans {EMBEDDING_RETRY_DELAY}s...") | |
| time.sleep(EMBEDDING_RETRY_DELAY) | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| # Main | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| def main(): | |
| parser = argparse.ArgumentParser(description="Ingestion parallélisée des documents d'entraînement") | |
| parser.add_argument( | |
| "--reset", action="store_true", | |
| help="Supprime la collection ChromaDB existante avant de réingérer (nécessaire après changement de chunking)", | |
| ) | |
| args = parser.parse_args() | |
| if not TRAIN_DATA_DIR.exists(): | |
| logger.error(f"Dossier train_data introuvable : {TRAIN_DATA_DIR}") | |
| sys.exit(1) | |
| # ── ChromaDB ────────────────────────────────────────────────────────────── | |
| chroma_client = chromadb.PersistentClient( | |
| path=CHROMA_PERSIST_DIR, | |
| settings=Settings(anonymized_telemetry=False), | |
| ) | |
| if args.reset: | |
| try: | |
| chroma_client.delete_collection(COLLECTION_NAME) | |
| logger.info("Collection supprimée (--reset).") | |
| except Exception: | |
| pass | |
| collection = chroma_client.get_or_create_collection( | |
| name=COLLECTION_NAME, | |
| metadata={"hnsw:space": "cosine"}, | |
| ) | |
| if collection.count() > 0 and not args.reset: | |
| logger.info(f"Collection déjà peuplée ({collection.count()} chunks). Utilisez --reset pour réingérer.") | |
| sys.exit(0) | |
| # ── Découverte des fichiers ─────────────────────────────────────────────── | |
| files = sorted(list(TRAIN_DATA_DIR.rglob("*.pdf")) + list(TRAIN_DATA_DIR.rglob("*.txt"))) | |
| if not files: | |
| logger.error(f"Aucun fichier PDF/TXT trouvé dans {TRAIN_DATA_DIR}") | |
| sys.exit(1) | |
| t_start = time.perf_counter() | |
| logger.info(f"{'='*60}") | |
| logger.info(f"Fichiers trouvés : {len(files)}") | |
| logger.info(f"Méthode de chunking : {CHUNKING_METHOD.upper()}") | |
| if CHUNKING_METHOD == "semantic": | |
| logger.info(f" Seuil percentile : {SEMANTIC_BREAKPOINT_PERCENTILE}") | |
| logger.info(f" Buffer phrases : {SEMANTIC_BUFFER_SIZE}") | |
| logger.info(f" Taille min/max : {SEMANTIC_MIN_CHUNK_CHARS} / {SEMANTIC_MAX_CHUNK_CHARS} chars") | |
| else: | |
| logger.info(f" Chunk size/overlap: {CHUNK_SIZE} / {CHUNK_OVERLAP}") | |
| logger.info(f"Batch size embedding: {EMBEDDING_BATCH_SIZE}") | |
| logger.info(f"Workers extraction : {EXTRACTION_WORKERS}") | |
| logger.info(f"Workers embedding : {EMBEDDING_WORKERS}") | |
| logger.info(f"{'='*60}") | |
| # ── Phase 1 : Extraction + chunking en parallèle ───────────────────────── | |
| logger.info(f"\nPhase 1/3 — Extraction et chunking ({EXTRACTION_WORKERS} workers)...") | |
| all_chunks: list[dict] = [] | |
| errors_extraction = 0 | |
| with ThreadPoolExecutor(max_workers=EXTRACTION_WORKERS) as executor: | |
| futures = {executor.submit(_process_file, f): f for f in files} | |
| done = 0 | |
| for future in as_completed(futures): | |
| done += 1 | |
| try: | |
| source, chunks = future.result() | |
| all_chunks.extend(chunks) | |
| logger.info(f" [{done}/{len(files)}] {source} → {len(chunks)} chunks") | |
| except Exception as e: | |
| errors_extraction += 1 | |
| logger.error(f" [{done}/{len(files)}] Erreur inattendue : {e}") | |
| t_phase1 = time.perf_counter() - t_start | |
| logger.info(f"Phase 1 terminée en {t_phase1:.1f}s — {len(all_chunks)} chunks ({errors_extraction} erreurs)") | |
| if not all_chunks: | |
| logger.error("Aucun chunk produit. Vérifiez les fichiers dans train_data/.") | |
| sys.exit(1) | |
| # ── Phase 2 : Embedding en parallèle ───────────────────────────────────── | |
| logger.info(f"\nPhase 2/3 — Embedding ({len(all_chunks)} chunks, batch={EMBEDDING_BATCH_SIZE}, workers={EMBEDDING_WORKERS})...") | |
| texts = [c["text"] for c in all_chunks] | |
| batches = [(i, texts[i : i + EMBEDDING_BATCH_SIZE]) for i in range(0, len(texts), EMBEDDING_BATCH_SIZE)] | |
| embeddings_map: dict[int, list] = {} | |
| errors_embedding = 0 | |
| t_phase2_start = time.perf_counter() | |
| with ThreadPoolExecutor(max_workers=EMBEDDING_WORKERS) as executor: | |
| futures = {executor.submit(_embed_batch, idx, batch): idx for idx, batch in batches} | |
| done = 0 | |
| for future in as_completed(futures): | |
| done += 1 | |
| try: | |
| batch_idx, embeddings = future.result() | |
| embeddings_map[batch_idx] = embeddings | |
| except Exception as e: | |
| errors_embedding += 1 | |
| logger.error(f" Batch échoué, {errors_embedding} erreurs totales") | |
| if done % 20 == 0 or done == len(batches): | |
| elapsed = time.perf_counter() - t_phase2_start | |
| rate = done / elapsed if elapsed > 0 else 0 | |
| eta = (len(batches) - done) / rate if rate > 0 else 0 | |
| logger.info(f" {done}/{len(batches)} batches — {elapsed:.0f}s écoulés, ETA ~{eta:.0f}s") | |
| t_phase2 = time.perf_counter() - t_phase2_start | |
| logger.info(f"Phase 2 terminée en {t_phase2:.1f}s ({errors_embedding} erreurs)") | |
| if errors_embedding > 0: | |
| logger.warning(f"{errors_embedding} batches ont échoué — les chunks correspondants seront absents de la base.") | |
| # Reconstruction dans l'ordre original | |
| flat_embeddings: list = [] | |
| failed_indices: set[int] = set() | |
| valid_chunks: list[dict] = [] | |
| for batch_start in range(0, len(texts), EMBEDDING_BATCH_SIZE): | |
| if batch_start in embeddings_map: | |
| flat_embeddings.extend(embeddings_map[batch_start]) | |
| batch_end = min(batch_start + EMBEDDING_BATCH_SIZE, len(all_chunks)) | |
| valid_chunks.extend(all_chunks[batch_start:batch_end]) | |
| else: | |
| failed_indices.add(batch_start) | |
| # ── Phase 3 : Insertion bulk dans ChromaDB ─────────────────────────────── | |
| logger.info(f"\nPhase 3/3 — Insertion dans ChromaDB ({len(valid_chunks)} chunks)...") | |
| t_phase3_start = time.perf_counter() | |
| for i in range(0, len(valid_chunks), CHROMA_INSERT_BATCH): | |
| batch_chunks = valid_chunks[i : i + CHROMA_INSERT_BATCH] | |
| batch_embeddings = flat_embeddings[i : i + CHROMA_INSERT_BATCH] | |
| collection.add( | |
| ids = [f"doc_{i + j}" for j in range(len(batch_chunks))], | |
| embeddings = batch_embeddings, | |
| documents = [c["text"] for c in batch_chunks], | |
| metadatas = [{"source": c["source"], "chunk_index": c["chunk_index"]} for c in batch_chunks], | |
| ) | |
| logger.info(f" Inséré {min(i + CHROMA_INSERT_BATCH, len(valid_chunks))}/{len(valid_chunks)} chunks") | |
| t_phase3 = time.perf_counter() - t_phase3_start | |
| t_total = time.perf_counter() - t_start | |
| logger.info(f"\n{'='*60}") | |
| logger.info(f"Ingestion terminée en {t_total:.1f}s ({t_total/60:.1f} min)") | |
| logger.info(f" Phase 1 (extraction) : {t_phase1:.1f}s") | |
| logger.info(f" Phase 2 (embedding) : {t_phase2:.1f}s") | |
| logger.info(f" Phase 3 (ChromaDB) : {t_phase3:.1f}s") | |
| logger.info(f"Chunks dans la base : {collection.count()}") | |
| logger.info(f"{'='*60}") | |
| if __name__ == "__main__": | |
| main() | |