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
"""
Indexation du répertoire 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.
Indexation incrémentale des fiches avec BGEM3 (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)
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
import json
import re
import time
from datetime import datetime
import faiss
import numpy as np
from FlagEmbedding import BGEM3FlagModel
# --- Paramètres -------------------------------------------------------------
# Si vous exécutez ce script SUR LHÔTE, mettez le chemin local
ROOT = Path("Fiches") # ou Path("/home/fabnum/fabnum-dev/Fiches") # dossier monté contenant les fiches
MODEL_NAME = "BAAI/bge-m3" # embedding multilingue, licence MIT
CHUNK = 800 # taille cible (≈600 mots)
OVERLAP = 100 # chevauchement pour la cohésion
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 = 128 # plus grand batch : encode plus vite
# --- Fonctions utilitaires --------------------------------------------------
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."""
sentences = re.split(r"(?<=[\.!?])\s+", text)
"""Découpe un texte en morceaux de chunk_size mots avec overlap mots."""
sentences = re.split(r"(?<=[\.\!\?])\s+", text)
chunks, buf = [], []
for s in sentences:
buf.append(s)
@ -37,45 +48,76 @@ def split(text: str, chunk_size: int = CHUNK, overlap: int = OVERLAP):
chunks.append(" ".join(buf))
return chunks
# --- Pipeline principal -----------------------------------------------------
def gather_files(root: Path):
for pattern in EXTENSIONS:
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():
docs, meta = [], []
files_count = 0
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_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")
for i, chunk in enumerate(split(text)):
docs.append(chunk)
meta.append({"file": fp.relative_to(ROOT).as_posix(), "part": i})
new_docs.append(chunk)
new_meta.append({"file": rel, "part": i, "mtime": mtime})
if not docs:
raise SystemExit("Aucun fichier trouvé dans /app/Fiches. Vérifiez le montage ou les extensions.")
if not new_docs:
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")
emb = model.encode(docs, batch_size=64) # pas de normalisation interne
# Normalisation manuelle (cosine)
emb = model.encode(new_docs, batch_size=BATCH, return_dict=False)
emb = emb.astype("float32")
norms = np.linalg.norm(emb, axis=1, keepdims=True)
emb = emb / np.maximum(norms, 1e-12)
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)
with open(META_FILE, "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
print(f"Index écrit dans {INDEX_FILE} avec {len(docs)} vecteurs.")
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")