File size: 7,732 Bytes
5f5e6b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8408785
5f5e6b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9f8fc4c
5f5e6b2
8408785
 
 
 
 
5f5e6b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8408785
 
 
 
 
 
5f5e6b2
 
 
 
8408785
 
 
 
 
5f5e6b2
 
 
 
 
8408785
 
 
5f5e6b2
 
 
8408785
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5f5e6b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Ingest Training Data Script
============================

Standalone script to convert all documents in ./train_data into embeddings
and store them in the ChromaDB vector store.

Usage:
    python ingest_train_data.py

Requires:
    - AZURE_API_KEY environment variable set
    - data/config.json (or root config.json as fallback) with endpoint URLs
    - Documents (PDF/TXT) in ./train_data/<category>/
"""
import re
import os
import sys
import json
import logging
import time
from pathlib import Path

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
# ---------------------------------------------------------------------------

PROJECT_ROOT = Path(__file__).parent
DATA_DIR = Path("/data") if Path("/data").is_dir() else PROJECT_ROOT / "data"
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"
EMBEDDING_BATCH_SIZE = 128

# Chunking par paragraphe
PARA_TARGET = 1200
PARA_MAX = 2000
PARA_MIN = 200

_CONFIG_PATH = DATA_DIR / "config.json"
if not _CONFIG_PATH.exists():
    _CONFIG_PATH = PROJECT_ROOT / "config.json"
    logger.warning(f"No config.json in {DATA_DIR} — using root config.json as fallback.")

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 is not set. Export it before running this script.")
    sys.exit(1)

_fallback_splitter = RecursiveCharacterTextSplitter(
    chunk_size=PARA_TARGET,
    chunk_overlap=100,
    separators=["\n", ". ", " ", ""],
)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def clean_text(text: str) -> str:
    text = re.sub(r"-\n", "", text)
    text = re.sub(r"(?<!\n)\n(?!\n)", " ", text)
    text = re.sub(r"[ \t]+", " ", text)
    return text.strip()

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 = clean_text(page.extract_text() or "")
        if text:
            pages_text.append(f"[Page {page_num}]\n{text}")
    return "\n\n".join(pages_text)

def chunk_text(text: str, source: str) -> list[dict]:
    paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]

    chunks, buffer = [], ""
    for para in paragraphs:
        if len(para) > PARA_MAX:
            if buffer:
                chunks.append(buffer); buffer = ""
            chunks.extend(_fallback_splitter.split_text(para))
            continue
        if len(buffer) + len(para) + 2 <= PARA_TARGET:
            buffer = (buffer + "\n\n" + para).strip()
        else:
            if buffer:
                chunks.append(buffer)
            buffer = para
    if buffer:
        chunks.append(buffer)

    merged = []
    for c in chunks:
        if merged and len(c) < PARA_MIN:
            merged[-1] = merged[-1] + "\n\n" + c
        else:
            merged.append(c)

    return [{"text": c, "source": source, "chunk_index": i} for i, c in enumerate(merged)]


def generate_embeddings(texts: list[str]) -> list[list[float]]:
    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()
    data = resp.json()
    return [item["embedding"] for item in data["data"]]


def generate_embeddings_batched(texts: list[str]) -> list[list[float]]:
    all_embeddings = []
    for i in range(0, len(texts), EMBEDDING_BATCH_SIZE):
        batch = texts[i:i + EMBEDDING_BATCH_SIZE]
        logger.info(f"  Embedding batch {i // EMBEDDING_BATCH_SIZE + 1} ({len(batch)} chunks)...")
        embeddings = generate_embeddings(batch)
        all_embeddings.extend(embeddings)
        time.sleep(0.5)  # Rate-limit courtesy
    return all_embeddings


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main():
    if not TRAIN_DATA_DIR.exists():
        logger.error(f"Training data directory not found: {TRAIN_DATA_DIR}")
        sys.exit(1)

    logger.info(f"Training data directory: {TRAIN_DATA_DIR}")
    logger.info(f"ChromaDB path: {CHROMA_PERSIST_DIR}")
    logger.info(f"Embedding model: {EMBEDDING_MODEL_NAME}")

    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"},
    )
    logger.info(f"ChromaDB collection '{COLLECTION_NAME}' — existing documents: {collection.count()}")

    # Gather all PDF and TXT files recursively
    files = sorted(
        list(TRAIN_DATA_DIR.rglob("*.pdf")) + list(TRAIN_DATA_DIR.rglob("*.txt"))
    )
    logger.info(f"Found {len(files)} files to process.")

    total_chunks_added = 0

    for idx, file_path in enumerate(files, start=1):
        relative = file_path.relative_to(TRAIN_DATA_DIR)
        # Use category/filename as the source identifier
        source = str(relative)
        logger.info(f"[{idx}/{len(files)}] Processing: {source}")

        try:
            if file_path.suffix.lower() == ".pdf":
                text = extract_text_from_pdf(file_path)
            else:
                text = file_path.read_text(encoding="utf-8")
        except Exception as e:
            logger.warning(f"  Skipped (read error): {e}")
            continue

        if not text.strip():
            logger.warning(f"  Skipped (no extractable text)")
            continue

        chunks = chunk_text(text, source=source)
        if not chunks:
            logger.warning(f"  Skipped (no chunks produced)")
            continue

        logger.info(f"  {len(chunks)} chunks extracted")

        texts = [c["text"] for c in chunks]
        try:
            embeddings = generate_embeddings_batched(texts)
        except http_requests.exceptions.HTTPError as e:
            logger.error(f"  Embedding failed: {e}")
            continue
        except Exception as e:
            logger.error(f"  Unexpected embedding error: {e}")
            continue

        existing_count = collection.count()
        ids = [f"doc_{existing_count + i}" for i in range(len(chunks))]
        metadatas = [{"source": c["source"], "chunk_index": c["chunk_index"]} for c in chunks]

        collection.add(
            ids=ids,
            embeddings=embeddings,
            documents=texts,
            metadatas=metadatas,
        )
        total_chunks_added += len(chunks)
        logger.info(f"  Added {len(chunks)} chunks (total in store: {collection.count()})")

    logger.info("=" * 60)
    logger.info(f"Ingestion complete. Chunks added this run: {total_chunks_added}")
    logger.info(f"Total documents in vector store: {collection.count()}")


if __name__ == "__main__":
    main()