RoCodex / src /scraper /indexer.py
Razvanix's picture
Upload 12 files
83892b0 verified
Raw
History Blame Contribute Delete
6.4 kB
"""
indexer.py
──────────
Reads articles.jsonl, embeds each article using a multilingual
sentence-transformers model, and saves a FAISS index to disk.
What this script produces:
data/faiss.index — the vector index (for similarity search)
data/metadata.jsonl — article metadata in the same order as the index
(needed to recover the text after a search)
Usage:
python indexer.py
Install:
pip install faiss-cpu sentence-transformers
"""
import json
import faiss
import numpy as np
from pathlib import Path
from sentence_transformers import SentenceTransformer
from tqdm import tqdm
# ── Config ────────────────────────────────────────────────────────────────────
MODEL_NAME = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
ARTICLES_FILE = Path("data/articles.jsonl")
INDEX_FILE = Path("data/faiss.index")
METADATA_FILE = Path("data/metadata.jsonl")
BATCH_SIZE = 32 # embed this many articles at a time
# ── Load articles ─────────────────────────────────────────────────────────────
def load_articles(path: Path) -> list[dict]:
articles = []
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
articles.append(json.loads(line))
print(f"Loaded {len(articles)} articles from {path}")
return articles
# ── Embed ─────────────────────────────────────────────────────────────────────
def embed_articles(articles: list[dict], model: SentenceTransformer) -> np.ndarray:
"""
Convert each article's 'chunk' field into a vector.
The 'chunk' field contains:
"LEGE 53 2003\nArticolul 142\n\nSalariații au dreptul..."
Including the law title and article number in the chunk means the
embedding captures source context, not just the article text alone.
This improves retrieval accuracy.
Returns a 2D numpy array of shape (n_articles, embedding_dim).
"""
chunks = [a["chunk"] for a in articles]
all_embeddings = []
print(f"Embedding {len(chunks)} articles in batches of {BATCH_SIZE}...")
for i in tqdm(range(0, len(chunks), BATCH_SIZE)):
batch = chunks[i : i + BATCH_SIZE]
embeddings = model.encode(
batch,
convert_to_numpy=True,
normalize_embeddings=True, # normalize for cosine similarity
show_progress_bar=False,
)
all_embeddings.append(embeddings)
return np.vstack(all_embeddings).astype("float32")
# ── Build FAISS index ─────────────────────────────────────────────────────────
def build_index(embeddings: np.ndarray) -> faiss.Index:
"""
Build a FAISS index from the embeddings.
We use IndexFlatIP (Inner Product) because our embeddings are
normalized — inner product == cosine similarity when vectors are
unit length. This gives us semantic similarity search for free.
For larger datasets (100k+ articles) you'd switch to IndexIVFFlat
which is approximate but much faster. For our size, exact search is fine.
"""
dim = embeddings.shape[1] # embedding dimension (384 for MiniLM)
print(f"Building FAISS index — {len(embeddings)} vectors, dim={dim}")
index = faiss.IndexFlatIP(dim) # IP = Inner Product (cosine similarity)
index.add(embeddings)
print(f"Index contains {index.ntotal} vectors")
return index
# ── Save ──────────────────────────────────────────────────────────────────────
def save_metadata(articles: list[dict], path: Path):
"""
Save article metadata in the same order as the FAISS index.
FAISS only stores vectors — it doesn't store text. When we search
and get back index positions [42, 17, 8], we need this file to
look up what article #42 actually says.
"""
with open(path, "w", encoding="utf-8") as f:
for article in articles:
# Save only what we need for display — not the full chunk
meta = {
"law_id": article.get("law_id"),
"law_title": article.get("law_title"),
"article_number": article.get("article_number"),
"text": article.get("text"),
"chunk": article.get("chunk"),
}
f.write(json.dumps(meta, ensure_ascii=False) + "\n")
print(f"Saved metadata to {path}")
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
# 1. Load articles
articles = load_articles(ARTICLES_FILE)
if not articles:
print("No articles found. Run pipeline.py first.")
return
# 2. Load embedding model
# First run downloads ~120MB — subsequent runs use the cache
print(f"Loading model: {MODEL_NAME}")
model = SentenceTransformer(MODEL_NAME)
# 3. Embed all articles
embeddings = embed_articles(articles, model)
# 4. Build FAISS index
index = build_index(embeddings)
# 5. Save index and metadata
faiss.write_index(index, str(INDEX_FILE))
print(f"Saved FAISS index to {INDEX_FILE}")
save_metadata(articles, METADATA_FILE)
print("\nDone! You can now run retriever.py to search the index.")
print(f" Index: {INDEX_FILE} ({INDEX_FILE.stat().st_size // 1024} KB)")
print(f" Metadata: {METADATA_FILE} ({METADATA_FILE.stat().st_size // 1024} KB)")
if __name__ == "__main__":
main()