From 70d856c58be05f702a8554fbb457b7fa19d6e497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phan?= Date: Mon, 19 May 2025 07:10:06 +0200 Subject: [PATCH] Update index.py --- index.py | 215 ++++++++++++++++++++++--------------------------------- 1 file changed, 84 insertions(+), 131 deletions(-) diff --git a/index.py b/index.py index e7e7839..d805c66 100644 --- a/index.py +++ b/index.py @@ -1,151 +1,104 @@ #!/usr/bin/env python3 """ -Indexation incrémentale des fiches avec BGE‑M3 (FlagEmbedding) + FAISS ---------------------------------------------------------------------- -• Parcourt récursivement le dossier Fiches (ext .md .MD .markdown .txt) -• Découpe chaque fichier en blocs de ~800 tokens (chevauchement 100) -• Encode uniquement les passages provenant de fichiers **nouveaux ou modifiés** - depuis la dernière indexation (basé sur l'horodatage mtime). -• Les embeddings sont ajoutés à l'index existant sans toucher aux anciens. -• Écrit/Met à jour : corpus.idx (vecteurs) + corpus.meta.json (métadonnées) +index.py — Indexation « mini‑fiches » SANS découpage +==================================================== -➡ Robustesse améliorée : le script détecte tous les formats de retour possibles - de `BGEM3FlagModel.encode` (ndarray ou dict avec clef `embedding`, etc.). +Objectif : chaque fichier (chapitre) devient **un seul** passage, afin de +préserver l’intégrité des tableaux, listes, etc. — conformément à votre +organisation manuelle. + +Caractéristiques : +• Incrémental : seuls les fichiers nouveaux ou modifiés sont ré‑encodés. +• Paramètres CLI : + --root racine des fiches (défaut : Corpus) + --index nom du fichier idx (défaut : corpus.idx) + --meta nom du fichier méta (défaut : corpus.meta.json) +• Extensions prises : .md .markdown .txt +• Embeddings : BGE‑M3 (FlagEmbedding) en CPU, normalisés L2. + +Usage : + python index.py # première indexation (tous les fichiers) + python index.py # relance instantanée (rien à faire) + touch Corpus/…/nouveau.md + python index.py # encode seulement 1 fichier """ +import argparse, json, os, time from pathlib import Path -import json -import re -import time -from datetime import datetime -import faiss -import numpy as np + +import faiss, numpy as np from FlagEmbedding import BGEM3FlagModel +from rich import print -# --- Paramètres ------------------------------------------------------------- -ROOT = Path("Fiches") # répertoire local des fiches -MODEL_NAME = "BAAI/bge-m3" # embedder multilingue, MIT -CHUNK = 800 # taille cible d'un bloc (≈600 mots) -OVERLAP = 100 # chevauchement pour la cohérence -INDEX_FILE = "corpus.idx" -META_FILE = "corpus.meta.json" -EXTENSIONS = ["*.md", "*.MD", "*.markdown", "*.txt"] -BATCH = 256 # plus grand batch : encode plus vite +# --------------------- CLI -------------------------------------------------- +parser = argparse.ArgumentParser(description="Indexation incrémentale des mini‑fiches (1 fichier = 1 passage).") +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") +args = parser.parse_args() -# --- Fonctions utilitaires -------------------------------------------------- +ROOT = Path(args.root).expanduser() +INDEX_F = Path(args.index) +META_F = Path(args.meta) +EXTS = {".md", ".markdown", ".txt"} -def split(text: str, chunk_size: int = CHUNK, overlap: int = OVERLAP): - """Découpe *text* en chunks (~chunk_size mots) tout en - préservant entièrement les tableaux Markdown. +print(f"[dim]Racine : {ROOT} | Index : {INDEX_F}[/]") - • Si une ligne contient ‘|’ ou n’est constituée que de tirets (---), - on force la coupure avant / après pour ne pas casser le tableau. - • Le reste est découpé sur la ponctuation (. ! ?) avec overlap. - """ - sentences = re.split(r"(?<=[\.!?])\s+", text) - chunks, buf = [], [] +# ------------------------ lire méta existant ------------------------------- +old_meta = [] +old_mtime = {} +if INDEX_F.exists() and META_F.exists(): + try: + old_meta = json.load(META_F.open()) + old_mtime = {m["path"]: m["mtime"] for m in old_meta} + except Exception as e: + print(f"[yellow]Avertissement : impossible de lire l'ancien méta : {e}. On repart de zéro.[/]") + old_meta = [] + old_mtime = {} - for s in sentences: - # ---- table Markdown ------------------------------------------------ - if "|" in s or re.fullmatch(r"\s*-{3,}\s*", s): - if buf: # vider le buffer courant - chunks.append(" ".join(buf)) - buf = [] - chunks.append(s) # garder le tableau entier - continue +# ------------------------ scanner les fichiers ----------------------------- +files = [fp for fp in ROOT.rglob("*") if fp.suffix.lower() in EXTS] +files.sort() - # ---- traitement normal -------------------------------------------- - buf.append(s) - if len(" ".join(buf).split()) >= chunk_size: - chunks.append(" ".join(buf)) - buf = buf[-overlap:] # chevauchement +new_docs, new_meta = [], [] +kept_meta = [] # meta non modifiés - if buf: - chunks.append(" ".join(buf)) +for fp in files: + path_str = str(fp.relative_to(ROOT)) + mtime = int(fp.stat().st_mtime) + if path_str in old_mtime and old_mtime[path_str] == mtime: + # déjà indexé, rien à faire + kept_meta.append(next(m for m in old_meta if m["path"] == path_str)) + continue + # fichier nouveau ou modifié + txt = fp.read_text(encoding="utf-8") + new_docs.append(txt) + new_meta.append({"path": path_str, "mtime": mtime}) - return chunks +print(f"Nouveaux/Modifiés : {len(new_docs)} | Conservés : {len(kept_meta)}") +if not new_docs and INDEX_F.exists(): + print("Index déjà à jour ✔︎") + exit(0) +# ------------------------ embeddings BGE‑M3 --------------------------------- +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") -def gather_files(root: Path): - for pattern in EXTENSIONS: - yield from root.rglob(pattern) +# ------------------------ mise à jour FAISS -------------------------------- +if INDEX_F.exists(): + idx = faiss.read_index(str(INDEX_F)) +else: + idx = faiss.IndexFlatIP(emb.shape[1]) -# --- Chargement éventuel de l'index existant ------------------------------- +idx.add(emb) +faiss.write_index(idx, str(INDEX_F)) -def load_existing(): - if not Path(INDEX_FILE).exists(): - return None, [], 0 # pas d'index, pas de meta +# ------------------------ enregistrer le nouveau méta ---------------------- +all_meta = kept_meta + new_meta +json.dump(all_meta, META_F.open("w"), ensure_ascii=False, indent=2) - index = faiss.read_index(INDEX_FILE) - meta = json.load(open(META_FILE, encoding="utf-8")) - return index, meta, len(meta) - -# --- Pipeline principal ----------------------------------------------------- - -def main(): - index, meta, existing = load_existing() - - # mapping chemin relatif ➜ mtime stocké - meta_mtime = {m["file"]: m.get("mtime", 0) for m in meta} - - new_docs, new_meta = [], [] - files_scanned, files_updated = 0, 0 - - for fp in gather_files(ROOT): - files_scanned += 1 - rel = fp.relative_to(ROOT).as_posix() - mtime = int(fp.stat().st_mtime) - if meta_mtime.get(rel) == mtime: - continue # inchangé - files_updated += 1 - text = fp.read_text(encoding="utf-8", errors="ignore") - for i, chunk in enumerate(split(text)): - new_docs.append(chunk) - new_meta.append({"file": rel, "part": i, "mtime": mtime}) - - if not new_docs: - print("Aucun fichier nouveau ou modifié. Index à jour ✔︎") - return - - print( - f"Nouveaux passages : {len(new_docs)} issus de {files_updated} fiches ("\ - f"{files_scanned} fiches scannées). Génération des embeddings…" - ) - - model = BGEM3FlagModel(MODEL_NAME, device="cpu") - emb_out = model.encode(new_docs, batch_size=BATCH) - - # --- Gestion robuste du format de sortie -------------------------------- - if isinstance(emb_out, np.ndarray): - emb = emb_out - elif isinstance(emb_out, dict): - for key in ("embedding", "embeddings", "sentence_embeds", "sentence_embedding"): - if key in emb_out: - emb = np.asarray(emb_out[key]) - break - else: - emb = np.asarray(next(iter(emb_out.values()))) - else: # liste de tableaux ? - emb = np.asarray(emb_out) - - emb = emb.astype("float32") - emb /= np.linalg.norm(emb, axis=1, keepdims=True) + 1e-12 - - if index is None: - index = faiss.IndexFlatIP(emb.shape[1]) - index.add(emb) - - # Mettre à jour les métadonnées (anciens + nouveaux) - meta.extend(new_meta) - faiss.write_index(index, INDEX_FILE) - json.dump(meta, open(META_FILE, "w", encoding="utf-8"), ensure_ascii=False, indent=2) - - print( - f"Index mis à jour : {len(meta)} vecteurs au total ("\ - f"+{len(new_docs)}). Dernière maj : {datetime.now().isoformat(timespec='seconds')}" - ) - -if __name__ == "__main__": - t0 = time.time() - main() - print(f"Terminé en {time.time() - t0:.1f} s") +print(f"Index mis à jour ✔︎ | Total passages : {idx.ntotal}")