File size: 9,501 Bytes
0033bf8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d654af
 
 
 
 
 
 
 
0033bf8
5d654af
 
 
0033bf8
5d654af
 
 
 
 
0033bf8
5d654af
 
 
 
0033bf8
5d654af
 
 
 
0033bf8
 
 
 
5d654af
0033bf8
 
 
5d654af
0033bf8
5d654af
0033bf8
 
 
5d654af
0033bf8
 
 
5d654af
 
 
 
 
 
0033bf8
5d654af
0033bf8
 
 
 
 
 
 
 
 
 
5d654af
 
0033bf8
 
 
 
 
 
 
5d654af
 
0033bf8
5d654af
0033bf8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d654af
 
 
 
 
 
 
 
 
0033bf8
5d654af
 
0033bf8
5d654af
0033bf8
5d654af
 
 
 
0033bf8
5d654af
 
 
0033bf8
5d654af
0033bf8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d654af
 
 
 
 
0033bf8
 
 
 
 
 
 
 
 
 
5d654af
 
0033bf8
5d654af
 
0033bf8
 
 
 
 
5d654af
0033bf8
 
 
5d654af
 
0033bf8
5d654af
0033bf8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d654af
0033bf8
 
 
 
5d654af
 
 
0033bf8
 
 
 
 
5d654af
 
0033bf8
5d654af
 
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
"""
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()