build_vector_index.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. #!/usr/bin/env python3
  2. """
  3. Build vector index for RAG v2 hybrid search.
  4. Embeds all chunks via local Ollama bge-m3, saves as numpy .npy + metadata pickle.
  5. Supports checkpoint/resume for large corpora.
  6. Usage:
  7. python build_vector_index.py [--base-dir the knowledge base directory]
  8. """
  9. import json
  10. import os
  11. import pickle
  12. import sys
  13. import time
  14. import urllib.request
  15. from pathlib import Path
  16. OLLAMA_EMBED_URL = os.environ.get('OLLAMA_EMBED_URL', 'http://127.0.0.1:11435/api/embed')
  17. EMBED_MODEL = os.environ.get('EMBED_MODEL', 'bge-m3')
  18. BATCH_SIZE = 32
  19. CHECKPOINT_INTERVAL = 500 # Save every N chunks
  20. def embed_batch(texts, model=None):
  21. """Embed a batch of texts via Ollama"""
  22. if model is None:
  23. model = EMBED_MODEL
  24. body = json.dumps({
  25. "model": model,
  26. "input": texts,
  27. }).encode('utf-8')
  28. req = urllib.request.Request(
  29. OLLAMA_EMBED_URL,
  30. data=body,
  31. headers={"Content-Type": "application/json"},
  32. method="POST"
  33. )
  34. with urllib.request.urlopen(req, timeout=300) as resp:
  35. data = json.loads(resp.read())
  36. return data.get('embeddings', [])
  37. def main():
  38. import argparse
  39. # Default from config.py
  40. try:
  41. from config import cfg as _app_cfg
  42. default_base = str(_app_cfg.base_dir)
  43. except ImportError:
  44. default_base = os.environ.get('KNOWLEDGE_DIR', str(Path(__file__).parent.parent / 'knowledge'))
  45. parser = argparse.ArgumentParser()
  46. parser.add_argument('--base-dir', default=default_base)
  47. args = parser.parse_args()
  48. base = Path(args.base_dir)
  49. index_path = base / 'rag_index.json'
  50. vec_path = base / 'rag_vectors_v2.npy'
  51. meta_path = base / 'rag_vectors_meta_v2.pkl'
  52. checkpoint_path = base / 'rag_vectors_checkpoint.pkl'
  53. print("=" * 60)
  54. print("Building Vector Index (bge-m3 via Ollama)")
  55. print("=" * 60)
  56. # Load chunks from existing index
  57. if not index_path.exists():
  58. print(f"ERROR: {index_path} not found. Run rebuild_index.py first.")
  59. sys.exit(1)
  60. print(f"Loading chunks from {index_path}...")
  61. t0 = time.time()
  62. with open(index_path) as f:
  63. data = json.load(f)
  64. chunks = data['chunks']
  65. print(f" Loaded {len(chunks)} chunks in {time.time()-t0:.1f}s")
  66. # Check for checkpoint (resume support)
  67. all_embeddings = []
  68. all_meta = []
  69. start_idx = 0
  70. if checkpoint_path.exists():
  71. print("Found checkpoint, resuming...")
  72. with open(checkpoint_path, 'rb') as f:
  73. cp = pickle.load(f)
  74. all_embeddings = cp['embeddings']
  75. all_meta = cp['meta']
  76. start_idx = cp['next_idx']
  77. print(f" Resumed from index {start_idx} ({len(all_embeddings)} embeddings done)")
  78. # Test embedding API
  79. print("\nTesting Ollama embedding API...")
  80. test_vecs = embed_batch(["test"])
  81. if not test_vecs:
  82. print("ERROR: Ollama embedding API not available")
  83. sys.exit(1)
  84. dim = len(test_vecs[0])
  85. print(f" OK, embedding dim: {dim}")
  86. # Embed all chunks
  87. total = len(chunks)
  88. remaining = total - start_idx
  89. print(f"\nEmbedding {remaining} chunks (batch_size={BATCH_SIZE})...")
  90. print(f"Estimated time: ~{remaining / BATCH_SIZE * 0.5 / 60:.1f} minutes")
  91. batch_texts = []
  92. batch_indices = []
  93. errors = 0
  94. embed_start = time.time()
  95. for i in range(start_idx, total):
  96. chunk = chunks[i]
  97. text = chunk['text'][:2000] # bge-m3 max ~8192 tokens, truncate for safety
  98. batch_texts.append(text)
  99. batch_indices.append(i)
  100. if len(batch_texts) >= BATCH_SIZE or i == total - 1:
  101. try:
  102. vecs = embed_batch(batch_texts)
  103. if len(vecs) == len(batch_texts):
  104. all_embeddings.extend(vecs)
  105. for j, idx in enumerate(batch_indices):
  106. c = chunks[idx]
  107. all_meta.append({
  108. 'source': c['source'],
  109. 'chunk_id': c['chunk_id'],
  110. 'text': c['text'],
  111. })
  112. else:
  113. # Partial result, embed one by one
  114. for t in batch_texts:
  115. v = embed_batch([t])
  116. if v:
  117. all_embeddings.append(v[0])
  118. else:
  119. all_embeddings.append([0.0] * dim)
  120. errors += 1
  121. for idx in batch_indices:
  122. c = chunks[idx]
  123. all_meta.append({
  124. 'source': c['source'],
  125. 'chunk_id': c['chunk_id'],
  126. 'text': c['text'],
  127. })
  128. except Exception as e:
  129. errors += 1
  130. # Fill with zeros for failed batch
  131. for _ in batch_texts:
  132. all_embeddings.append([0.0] * dim)
  133. for idx in batch_indices:
  134. c = chunks[idx]
  135. all_meta.append({
  136. 'source': c['source'],
  137. 'chunk_id': c['chunk_id'],
  138. 'text': c['text'],
  139. })
  140. if errors <= 3:
  141. print(f" ERROR at batch {i}: {e}")
  142. batch_texts = []
  143. batch_indices = []
  144. # Progress
  145. done = len(all_embeddings)
  146. if done % (CHECKPOINT_INTERVAL) < BATCH_SIZE or i == total - 1:
  147. elapsed = time.time() - embed_start
  148. rate = (done - start_idx) / max(elapsed, 1)
  149. eta = (total - done) / max(rate, 0.01) / 60
  150. print(f" [{done}/{total}] {rate:.1f} chunks/s, ETA: {eta:.1f}min, errors: {errors}")
  151. # Save checkpoint
  152. with open(checkpoint_path, 'wb') as f:
  153. pickle.dump({
  154. 'embeddings': all_embeddings,
  155. 'meta': all_meta,
  156. 'next_idx': i + 1,
  157. }, f, protocol=pickle.HIGHEST_PROTOCOL)
  158. # Save final index
  159. print(f"\nSaving vector index...")
  160. try:
  161. import numpy as np
  162. vectors = np.array(all_embeddings, dtype=np.float32)
  163. np.save(str(vec_path), vectors)
  164. print(f" Vectors: {vec_path} ({vectors.shape}, {vec_path.stat().st_size/1024/1024:.1f} MB)")
  165. except ImportError:
  166. # Fallback: save as pickle
  167. vec_path = vec_path.with_suffix('.pkl')
  168. with open(vec_path, 'wb') as f:
  169. pickle.dump(all_embeddings, f, protocol=pickle.HIGHEST_PROTOCOL)
  170. print(f" Vectors (pickle): {vec_path}")
  171. with open(meta_path, 'wb') as f:
  172. pickle.dump(all_meta, f, protocol=pickle.HIGHEST_PROTOCOL)
  173. print(f" Meta: {meta_path} ({meta_path.stat().st_size/1024/1024:.1f} MB)")
  174. # Cleanup checkpoint
  175. if checkpoint_path.exists():
  176. checkpoint_path.unlink()
  177. print(" Checkpoint removed")
  178. total_time = time.time() - embed_start
  179. print(f"\nDone! {len(all_embeddings)} vectors in {total_time/60:.1f} minutes, {errors} errors")
  180. if __name__ == '__main__':
  181. main()