#!/usr/bin/env python3 """ index.py — indexation hybride des mini‑fiches =========================================== • 1 fichier = 1 passage **si** le fichier ≤ WORD_LIMIT mots (par défaut : 600). • Au‑delà (rare : fiche ICS, ISG, etc.), on découpe en blocs ~CHUNK mots avec chevauchement OVERLAP pour isoler les tableaux et valeurs numériques. • Incrémental : encode uniquement les fichiers nouveaux ou modifiés. • Embeddings : BGE‑M3 (FlagEmbedding) en CPU, normalisés L2. Usage : python index.py --root Corpus # première construction python index.py # relance rapide (0 s si rien) Arguments : --root dossier des fiches (déf. Corpus) --index nom du fichier FAISS (déf. corpus.idx) --meta fichier méta JSON (déf. corpus.meta.json) --word WORD_LIMIT (déf. 600) --chunk CHUNK (déf. 350) """ import argparse, json, re, sys from pathlib import Path import faiss, numpy as np from FlagEmbedding import BGEM3FlagModel from rich import print # --------------------- CLI -------------------------------------------------- parser = argparse.ArgumentParser(description="Indexation hybride : 1 passage par fiche courte, découpe douce pour les longues.") parser.add_argument("--root", default="Corpus", help="Répertoire racine des fiches") parser.add_argument("--index", default="corpus.idx", help="Nom du fichier FAISS") parser.add_argument("--meta", default="corpus.meta.json", help="Nom du méta JSON") parser.add_argument("--word", type=int, default=600, help="WORD_LIMIT : au‑delà on découpe (mots)") parser.add_argument("--chunk", type=int, default=350, help="Taille des chunks quand on découpe (mots)") args = parser.parse_args() ROOT = Path(args.root) INDEX_F = Path(args.index) META_F = Path(args.meta) WORD_LIMIT= args.word CHUNK = args.chunk OVERLAP = 50 EXTS = {".md", ".markdown", ".txt"} print(f"[dim]Racine : {ROOT} | Index : {INDEX_F}[/]") # ---------------- split helper -------------------------------------------- def split_long(text: str): """Découpe douce : blocs ~CHUNK mots, préserve tableaux.""" sentences = re.split(r"(?<=[.!?])\s+", text) chunks, buf = [], [] for s in sentences: if "|" in s or re.fullmatch(r"\s*-{3,}\s*", s): if buf: chunks.append(" ".join(buf)) buf = [] chunks.append(s) continue buf.append(s) if len(" ".join(buf).split()) >= CHUNK: chunks.append(" ".join(buf)) buf = buf[-OVERLAP:] if buf: chunks.append(" ".join(buf)) return chunks # ------------------------ lire méta existant ------------------------------ old_meta = {} if INDEX_F.exists() and META_F.exists(): try: for m in json.load(META_F.open()): old_meta[m["path"]] = m except Exception as e: print(f"[yellow]Avertissement : méta illisible ({e}), reconstruction complète.[/]") old_meta = {} # ------------------------ scanner les fichiers ---------------------------- files = [fp for fp in ROOT.rglob("*") if fp.suffix.lower() in EXTS] files.sort() new_docs, new_meta, kept_meta = [], [], [] for fp in files: rel = str(fp.relative_to(ROOT)) mtime = int(fp.stat().st_mtime) prev = old_meta.get(rel) if prev and prev["mtime"] == mtime: kept_meta.append(prev) continue txt = fp.read_text(encoding="utf-8") words = len(txt.split()) if words <= WORD_LIMIT: new_docs.append(txt) new_meta.append({"path": rel, "part": 0, "mtime": mtime}) else: for i, chunk in enumerate(split_long(txt)): new_docs.append(chunk) new_meta.append({"path": rel, "part": i, "mtime": mtime}) print(f"Nouveaux/Modifiés : {len(new_meta)} | Conservés : {len(kept_meta)}") if not new_meta and INDEX_F.exists(): print("Index déjà à jour ✔︎") sys.exit(0) # ------------------------ embeddings -------------------------------------- model = BGEM3FlagModel("BAAI/bge-m3", device="cpu") emb = model.encode(new_docs) if isinstance(emb, dict): emb = next(v for v in emb.values() if isinstance(v, np.ndarray)) emb = emb / np.linalg.norm(emb, axis=1, keepdims=True) emb = emb.astype("float32") # ------------------------ FAISS update ------------------------------------ if INDEX_F.exists(): idx = faiss.read_index(str(INDEX_F)) else: idx = faiss.IndexFlatIP(emb.shape[1]) idx.add(emb) faiss.write_index(idx, str(INDEX_F)) # ------------------------ save meta --------------------------------------- all_meta = kept_meta + new_meta json.dump(all_meta, META_F.open("w"), ensure_ascii=False, indent=2) print(f"Index mis à jour ✔︎ | Total passages : {idx.ntotal}")