#!/usr/bin/env python3 """ Build vector index for RAG v2 hybrid search. Embeds all chunks via local Ollama bge-m3, saves as numpy .npy + metadata pickle. Supports checkpoint/resume for large corpora. Usage: python build_vector_index.py [--base-dir /data/knowledge/maritime] """ import json import os import pickle import sys import time import urllib.request from pathlib import Path OLLAMA_EMBED_URL = os.environ.get('OLLAMA_EMBED_URL', 'http://127.0.0.1:11435/api/embed') EMBED_MODEL = os.environ.get('EMBED_MODEL', 'bge-m3') BATCH_SIZE = 32 CHECKPOINT_INTERVAL = 500 # Save every N chunks def embed_batch(texts, model=None): """Embed a batch of texts via Ollama""" if model is None: model = EMBED_MODEL body = json.dumps({ "model": model, "input": texts, }).encode('utf-8') req = urllib.request.Request( OLLAMA_EMBED_URL, data=body, headers={"Content-Type": "application/json"}, method="POST" ) with urllib.request.urlopen(req, timeout=300) as resp: data = json.loads(resp.read()) return data.get('embeddings', []) def main(): import argparse parser = argparse.ArgumentParser() parser.add_argument('--base-dir', default=os.environ.get('KNOWLEDGE_DIR', '/data/knowledge/maritime')) args = parser.parse_args() base = Path(args.base_dir) index_path = base / 'rag_index.json' vec_path = base / 'rag_vectors_v2.npy' meta_path = base / 'rag_vectors_meta_v2.pkl' checkpoint_path = base / 'rag_vectors_checkpoint.pkl' print("=" * 60) print("Building Vector Index (bge-m3 via Ollama)") print("=" * 60) # Load chunks from existing index if not index_path.exists(): print(f"ERROR: {index_path} not found. Run rebuild_index.py first.") sys.exit(1) print(f"Loading chunks from {index_path}...") t0 = time.time() with open(index_path) as f: data = json.load(f) chunks = data['chunks'] print(f" Loaded {len(chunks)} chunks in {time.time()-t0:.1f}s") # Check for checkpoint (resume support) all_embeddings = [] all_meta = [] start_idx = 0 if checkpoint_path.exists(): print("Found checkpoint, resuming...") with open(checkpoint_path, 'rb') as f: cp = pickle.load(f) all_embeddings = cp['embeddings'] all_meta = cp['meta'] start_idx = cp['next_idx'] print(f" Resumed from index {start_idx} ({len(all_embeddings)} embeddings done)") # Test embedding API print("\nTesting Ollama embedding API...") test_vecs = embed_batch(["test"]) if not test_vecs: print("ERROR: Ollama embedding API not available") sys.exit(1) dim = len(test_vecs[0]) print(f" OK, embedding dim: {dim}") # Embed all chunks total = len(chunks) remaining = total - start_idx print(f"\nEmbedding {remaining} chunks (batch_size={BATCH_SIZE})...") print(f"Estimated time: ~{remaining / BATCH_SIZE * 0.5 / 60:.1f} minutes") batch_texts = [] batch_indices = [] errors = 0 embed_start = time.time() for i in range(start_idx, total): chunk = chunks[i] text = chunk['text'][:2000] # bge-m3 max ~8192 tokens, truncate for safety batch_texts.append(text) batch_indices.append(i) if len(batch_texts) >= BATCH_SIZE or i == total - 1: try: vecs = embed_batch(batch_texts) if len(vecs) == len(batch_texts): all_embeddings.extend(vecs) for j, idx in enumerate(batch_indices): c = chunks[idx] all_meta.append({ 'source': c['source'], 'chunk_id': c['chunk_id'], 'text': c['text'], }) else: # Partial result, embed one by one for t in batch_texts: v = embed_batch([t]) if v: all_embeddings.append(v[0]) else: all_embeddings.append([0.0] * dim) errors += 1 for idx in batch_indices: c = chunks[idx] all_meta.append({ 'source': c['source'], 'chunk_id': c['chunk_id'], 'text': c['text'], }) except Exception as e: errors += 1 # Fill with zeros for failed batch for _ in batch_texts: all_embeddings.append([0.0] * dim) for idx in batch_indices: c = chunks[idx] all_meta.append({ 'source': c['source'], 'chunk_id': c['chunk_id'], 'text': c['text'], }) if errors <= 3: print(f" ERROR at batch {i}: {e}") batch_texts = [] batch_indices = [] # Progress done = len(all_embeddings) if done % (CHECKPOINT_INTERVAL) < BATCH_SIZE or i == total - 1: elapsed = time.time() - embed_start rate = (done - start_idx) / max(elapsed, 1) eta = (total - done) / max(rate, 0.01) / 60 print(f" [{done}/{total}] {rate:.1f} chunks/s, ETA: {eta:.1f}min, errors: {errors}") # Save checkpoint with open(checkpoint_path, 'wb') as f: pickle.dump({ 'embeddings': all_embeddings, 'meta': all_meta, 'next_idx': i + 1, }, f, protocol=pickle.HIGHEST_PROTOCOL) # Save final index print(f"\nSaving vector index...") try: import numpy as np vectors = np.array(all_embeddings, dtype=np.float32) np.save(str(vec_path), vectors) print(f" Vectors: {vec_path} ({vectors.shape}, {vec_path.stat().st_size/1024/1024:.1f} MB)") except ImportError: # Fallback: save as pickle vec_path = vec_path.with_suffix('.pkl') with open(vec_path, 'wb') as f: pickle.dump(all_embeddings, f, protocol=pickle.HIGHEST_PROTOCOL) print(f" Vectors (pickle): {vec_path}") with open(meta_path, 'wb') as f: pickle.dump(all_meta, f, protocol=pickle.HIGHEST_PROTOCOL) print(f" Meta: {meta_path} ({meta_path.stat().st_size/1024/1024:.1f} MB)") # Cleanup checkpoint if checkpoint_path.exists(): checkpoint_path.unlink() print(" Checkpoint removed") total_time = time.time() - embed_start print(f"\nDone! {len(all_embeddings)} vectors in {total_time/60:.1f} minutes, {errors} errors") if __name__ == '__main__': main()