Update index.py

This commit is contained in:
Stéphan Peccini 2025-05-18 19:25:56 +02:00
parent 3f2f13b65f
commit 89d167a2f8

View File

@ -1,32 +1,43 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Indexation du répertoire Fiches avec BGEM3 (FlagEmbedding) + FAISS. Indexation incrémentale des fiches avec BGEM3 (FlagEmbedding) + FAISS
Parcourt récursivement tous les fichiers markdown / texte (extensions .md, .MD, .markdown, .txt), ---------------------------------------------------------------------
découpe en blocs de ~800 tokens, génère les embeddings, et écrit corpus.idx + corpus.meta.json. 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)
Temps gagné : pour 1 fiche ajoutée, seules ses quelques dizaines de passages
sont -encodés, pas les ~6000 déjà en place indexation en quelques
secondes au lieu de minutes.
""" """
from pathlib import Path from pathlib import Path
import json import json
import re import re
import time
from datetime import datetime
import faiss import faiss
import numpy as np import numpy as np
from FlagEmbedding import BGEM3FlagModel from FlagEmbedding import BGEM3FlagModel
# --- Paramètres ------------------------------------------------------------- # --- Paramètres -------------------------------------------------------------
# Si vous exécutez ce script SUR LHÔTE, mettez le chemin local ROOT = Path("Fiches") # répertoire local des fiches
ROOT = Path("Fiches") # ou Path("/home/fabnum/fabnum-dev/Fiches") # dossier monté contenant les fiches MODEL_NAME = "BAAI/bge-m3" # embedder multilingue, MIT
MODEL_NAME = "BAAI/bge-m3" # embedding multilingue, licence MIT CHUNK = 800 # taille cible d'un bloc (≈600 mots)
CHUNK = 800 # taille cible (≈600 mots) OVERLAP = 100 # chevauchement pour la cohérence
OVERLAP = 100 # chevauchement pour la cohésion
INDEX_FILE = "corpus.idx" INDEX_FILE = "corpus.idx"
META_FILE = "corpus.meta.json" META_FILE = "corpus.meta.json"
EXTENSIONS = ["*.md", "*.MD", "*.markdown", "*.txt"] EXTENSIONS = ["*.md", "*.MD", "*.markdown", "*.txt"]
BATCH = 128 # plus grand batch : encode plus vite
# --- Fonctions utilitaires -------------------------------------------------- # --- Fonctions utilitaires --------------------------------------------------
def split(text: str, chunk_size: int = CHUNK, overlap: int = OVERLAP): def split(text: str, chunk_size: int = CHUNK, overlap: int = OVERLAP):
"""Découpe un texte en morceaux de chunk_size mots avec overlap mots de recouvrement.""" """Découpe un texte en morceaux de chunk_size mots avec overlap mots."""
sentences = re.split(r"(?<=[\.!?])\s+", text) sentences = re.split(r"(?<=[\.\!\?])\s+", text)
chunks, buf = [], [] chunks, buf = [], []
for s in sentences: for s in sentences:
buf.append(s) buf.append(s)
@ -37,45 +48,76 @@ def split(text: str, chunk_size: int = CHUNK, overlap: int = OVERLAP):
chunks.append(" ".join(buf)) chunks.append(" ".join(buf))
return chunks return chunks
# --- Pipeline principal -----------------------------------------------------
def gather_files(root: Path): def gather_files(root: Path):
for pattern in EXTENSIONS: for pattern in EXTENSIONS:
yield from root.rglob(pattern) yield from root.rglob(pattern)
# --- Chargement éventuel de l'index existant -------------------------------
def load_existing():
if not Path(INDEX_FILE).exists():
return None, [], 0 # pas d'index, pas de meta
index = faiss.read_index(INDEX_FILE)
meta = json.load(open(META_FILE, encoding="utf-8"))
return index, meta, len(meta)
# --- Pipeline principal -----------------------------------------------------
def main(): def main():
docs, meta = [], [] index, meta, existing = load_existing()
files_count = 0
# 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): for fp in gather_files(ROOT):
files_count += 1 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") text = fp.read_text(encoding="utf-8", errors="ignore")
for i, chunk in enumerate(split(text)): for i, chunk in enumerate(split(text)):
docs.append(chunk) new_docs.append(chunk)
meta.append({"file": fp.relative_to(ROOT).as_posix(), "part": i}) new_meta.append({"file": rel, "part": i, "mtime": mtime})
if not docs: if not new_docs:
raise SystemExit("Aucun fichier trouvé dans /app/Fiches. Vérifiez le montage ou les extensions.") print("Aucun fichier nouveau ou modifié. Index à jour ✔︎")
return
print(f"Traité {files_count} fichiers, découpé {len(docs)} passages, génération des embeddings…") 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") model = BGEM3FlagModel(MODEL_NAME, device="cpu")
emb = model.encode(docs, batch_size=64) # pas de normalisation interne emb = model.encode(new_docs, batch_size=BATCH, return_dict=False)
# Normalisation manuelle (cosine)
emb = emb.astype("float32") emb = emb.astype("float32")
norms = np.linalg.norm(emb, axis=1, keepdims=True) emb /= np.linalg.norm(emb, axis=1, keepdims=True) + 1e-12
emb = emb / np.maximum(norms, 1e-12)
if index is None:
index = faiss.IndexFlatIP(emb.shape[1]) index = faiss.IndexFlatIP(emb.shape[1])
index.add(emb) index.add(emb)
# Mettre à jour les métadonnées (anciens + nouveaux)
meta.extend(new_meta)
faiss.write_index(index, INDEX_FILE) faiss.write_index(index, INDEX_FILE)
json.dump(meta, open(META_FILE, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
with open(META_FILE, "w", encoding="utf-8") as f: print(
json.dump(meta, f, ensure_ascii=False, indent=2) f"Index mis à jour : {len(meta)} vecteurs au total ("\
f"+{len(new_docs)}). Dernière maj : {datetime.now().isoformat(timespec='seconds')}"
print(f"Index écrit dans {INDEX_FILE} avec {len(docs)} vecteurs.") )
if __name__ == "__main__": if __name__ == "__main__":
t0 = time.time()
main() main()
print(f"Terminé en {time.time() - t0:.1f} s")