ThePredictors / ingest_train_data_optimized.py
YBDIXIT
commit
0033bf8
Raw
History Blame Contribute Delete
9.5 kB
"""
Pipeline d'ingestion optimisé — Green AI Edition
=================================================
Aligne sur app.py : mêmes embeddings locaux, même chunking parent-child,
même collection ChromaDB (rag_documents_optimized).
Optimisations :
- sentence-transformers local (zéro appel API Azure)
- PyMuPDF get_text("markdown") pour préserver la structure des PDFs
- Parent-child chunking (child 380 chars pour le retrieval, parent 1024 pour le LLM)
- Extraction parallèle (ThreadPoolExecutor, I/O-bound)
- Embedding par lots de 128 (CPU/MPS)
- Filtre anti-doublon sur les sources déjà indexées
"""
import os
import sys
import logging
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
os.environ.setdefault("ANONYMIZED_TELEMETRY", "False")
import fitz
import chromadb
from chromadb.config import Settings
from langchain_text_splitters import RecursiveCharacterTextSplitter
from sentence_transformers import SentenceTransformer
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
PROJECT_ROOT = Path(__file__).parent
DATA_DIR = Path("/data") if Path("/data").is_dir() else PROJECT_ROOT / "data"
TRAIN_DATA_DIR = PROJECT_ROOT / "train_data"
CHROMA_PERSIST_DIR = str(DATA_DIR / "chroma_db_optimized")
COLLECTION_NAME = "rag_documents_optimized"
CHUNK_SIZE = 380
CHUNK_OVERLAP = 80
PARENT_CHUNK_SIZE = 1024
PARENT_CHUNK_OVERLAP = 100
EMBEDDING_BATCH_SIZE = 128 # taille de lot pour l'encodage local CPU/MPS
MAX_WORKERS = 8 # threads pour l'extraction parallèle des fichiers
INSERT_BATCH = 500 # taille de lot pour l'insertion ChromaDB
_SEPARATORS = ["---", "\n## ", "\n### ", "\n\n", "\n", ". ", " ", ""]
# ---------------------------------------------------------------------------
# Modèle local (chargé une fois)
# ---------------------------------------------------------------------------
logger.info("Chargement du modèle d'embedding (paraphrase-multilingual-MiniLM-L12-v2)...")
_embedding_model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
logger.info(f"Modèle prêt. Dimension : {_embedding_model.get_embedding_dimension()}")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def extract_text_from_pdf(pdf_path: Path) -> str:
doc = fitz.open(str(pdf_path))
pages_text = []
for page in doc:
try:
text = page.get_text("markdown")
except Exception:
text = page.get_text("text")
if text.strip():
pages_text.append(text.strip())
doc.close()
return "\n\n---\n\n".join(pages_text)
def chunk_text(text: str, source: str) -> list[dict]:
"""Parent-child chunking — même logique que app.py."""
parent_splitter = RecursiveCharacterTextSplitter(
chunk_size=PARENT_CHUNK_SIZE,
chunk_overlap=PARENT_CHUNK_OVERLAP,
separators=_SEPARATORS,
)
child_splitter = RecursiveCharacterTextSplitter(
chunk_size=CHUNK_SIZE,
chunk_overlap=CHUNK_OVERLAP,
separators=_SEPARATORS,
)
result = []
for parent_text in parent_splitter.split_text(text):
for child_text in child_splitter.split_text(parent_text):
result.append({
"text": child_text,
"parent_text": parent_text,
"source": source,
"chunk_index": len(result),
})
return result
def generate_embeddings(texts: list[str]) -> list[list[float]]:
"""Embedding local L2-normalisé — zéro appel API."""
return _embedding_model.encode(
texts, normalize_embeddings=True, show_progress_bar=False
).tolist()
def process_single_file(file_path: Path) -> list[dict]:
source = str(file_path.relative_to(TRAIN_DATA_DIR))
try:
if file_path.suffix.lower() == ".pdf":
text = extract_text_from_pdf(file_path)
else:
text = file_path.read_text(encoding="utf-8")
if not text.strip():
return []
return chunk_text(text, source=source)
except Exception as e:
logger.warning(f"[Erreur] {source} : {e}")
return []
# ---------------------------------------------------------------------------
# Pipeline principal
# ---------------------------------------------------------------------------
def main():
if not TRAIN_DATA_DIR.exists():
logger.error(f"Dossier introuvable : {TRAIN_DATA_DIR}")
sys.exit(1)
logger.info("=" * 60)
logger.info("PIPELINE D'INGESTION OPTIMISÉ (embeddings locaux)")
logger.info("=" * 60)
chroma_client = chromadb.PersistentClient(
path=CHROMA_PERSIST_DIR,
settings=Settings(anonymized_telemetry=False),
)
collection = chroma_client.get_or_create_collection(
name=COLLECTION_NAME,
metadata={"hnsw:space": "cosine"},
)
# Détection de mismatch de dimensions (ex : ancienne DB Azure 1536 dims)
expected_dim = _embedding_model.get_embedding_dimension()
if collection.count() > 0:
try:
sample = collection.get(limit=1, include=["embeddings"])
existing_dim = len(sample["embeddings"][0])
if existing_dim != expected_dim:
logger.warning(
f"Dimension mismatch ({existing_dim}{expected_dim}). "
"Recréation de la collection."
)
chroma_client.delete_collection(COLLECTION_NAME)
collection = chroma_client.create_collection(
name=COLLECTION_NAME, metadata={"hnsw:space": "cosine"}
)
except Exception as e:
logger.warning(f"Vérification dimensions impossible : {e}")
# Étape 0 : Filtre anti-doublon
logger.info("Étape 0 : Vérification des sources déjà indexées...")
existing_sources: set[str] = set()
existing_data = collection.get(include=["metadatas"])
if existing_data and existing_data["metadatas"]:
for meta in existing_data["metadatas"]:
if meta and "source" in meta:
existing_sources.add(meta["source"])
logger.info(f"→ {len(existing_sources)} fichiers déjà présents en base.")
all_files = sorted(
list(TRAIN_DATA_DIR.rglob("*.pdf")) + list(TRAIN_DATA_DIR.rglob("*.txt"))
)
files_to_process = [
f for f in all_files
if str(f.relative_to(TRAIN_DATA_DIR)) not in existing_sources
]
logger.info(f"{len(all_files)} fichiers au total, {len(files_to_process)} nouveaux.")
if not files_to_process:
logger.info("Base déjà à jour. Rien à faire.")
return
# Étape 1 : Extraction et chunking parallèle (I/O-bound)
logger.info(f"Étape 1 : Extraction parallèle ({MAX_WORKERS} threads)...")
all_chunks: list[dict] = []
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
futures = {executor.submit(process_single_file, f): f for f in files_to_process}
for idx, future in enumerate(as_completed(futures), start=1):
all_chunks.extend(future.result())
if idx % 10 == 0 or idx == len(files_to_process):
logger.info(f" [{idx}/{len(files_to_process)}] — {len(all_chunks)} chunks")
if not all_chunks:
logger.error("Aucun texte extrait. Arrêt.")
return
logger.info(f"→ {len(all_chunks)} chunks générés.")
# Étape 2 : Embedding local par lots
logger.info(f"Étape 2 : Embedding local par lots de {EMBEDDING_BATCH_SIZE}...")
batches = [
all_chunks[i:i + EMBEDDING_BATCH_SIZE]
for i in range(0, len(all_chunks), EMBEDDING_BATCH_SIZE)
]
all_embeddings: list[list[float]] = []
for idx, batch in enumerate(batches, start=1):
all_embeddings.extend(generate_embeddings([c["text"] for c in batch]))
if idx % 10 == 0 or idx == len(batches):
logger.info(f" [{idx}/{len(batches)}] — {len(all_embeddings)} embeddings")
logger.info(f"→ {len(all_embeddings)} embeddings générés.")
# Étape 3 : Insertion ChromaDB par lots
logger.info(f"Étape 3 : Insertion dans ChromaDB (lots de {INSERT_BATCH})...")
base = collection.count()
ids = [f"doc_{base + i}" for i in range(len(all_chunks))]
texts = [c["text"] for c in all_chunks]
metadatas = [
{
"source": c["source"],
"chunk_index": c["chunk_index"],
"parent_text": c.get("parent_text", c["text"]),
}
for c in all_chunks
]
for i in range(0, len(all_chunks), INSERT_BATCH):
collection.add(
ids=ids[i:i + INSERT_BATCH],
embeddings=all_embeddings[i:i + INSERT_BATCH],
documents=texts[i:i + INSERT_BATCH],
metadatas=metadatas[i:i + INSERT_BATCH],
)
logger.info("=" * 60)
logger.info("INGESTION TERMINÉE")
logger.info(f" DB : {CHROMA_PERSIST_DIR}")
logger.info(f" Collection : {COLLECTION_NAME}")
logger.info(f" Chunks ajoutés : {len(all_chunks)}")
logger.info(f" Total en DB : {collection.count()}")
logger.info("=" * 60)
if __name__ == "__main__":
main()