search_engine.py 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. #!/usr/bin/env python3
  2. """
  3. RAG search engine v2: uses optimized pickle index
  4. Loads in ~6s instead of ~50s, uses 96% less disk
  5. """
  6. import json
  7. import os
  8. import re
  9. import math
  10. import pickle
  11. import struct
  12. from collections import Counter
  13. from pathlib import Path
  14. try:
  15. from config import cfg as _app_cfg
  16. _HAS_CONFIG = True
  17. except ImportError:
  18. _HAS_CONFIG = False
  19. _index_cache = {}
  20. def _get_config():
  21. if _HAS_CONFIG:
  22. return {
  23. 'base_dir': _app_cfg.base_dir,
  24. 'index_file': _app_cfg.index_file,
  25. 'keyword_file': _app_cfg.keyword_file,
  26. }
  27. # Fallback: environment variable
  28. base = Path(os.environ.get('KNOWLEDGE_DIR', str(Path(__file__).parent)))
  29. return {
  30. 'base_dir': base,
  31. 'index_file': base / 'rag_index.json',
  32. 'keyword_file': base / 'rag_index.keywords.json',
  33. }
  34. def _load_index():
  35. if 'chunks' not in _index_cache:
  36. cfg = _get_config()
  37. base = cfg['base_dir']
  38. # Try optimized pickle first, fall back to JSON
  39. pkl_index = base / 'rag_index.pkl'
  40. pkl_kw = base / 'rag_keywords.pkl'
  41. if pkl_index.exists() and pkl_kw.exists():
  42. with open(pkl_index, 'rb') as f:
  43. data = pickle.load(f)
  44. with open(pkl_kw, 'rb') as f:
  45. kw_raw = pickle.load(f)
  46. chunks = data['chunks'] # list of (source, chunk_id, text) tuples
  47. use_bytes = data.get('use_bytes', False)
  48. # Build lookup
  49. chunk_lookup = {}
  50. for i, (source, chunk_id, text) in enumerate(chunks):
  51. cid = f"{source}#chunk{chunk_id}"
  52. chunk_lookup[i] = {
  53. 'id': cid, 'source': source,
  54. 'chunk_id': chunk_id, 'text': text, 'length': len(text),
  55. }
  56. # Convert bytes postings back to int lists on demand
  57. _index_cache['chunks'] = chunk_lookup
  58. _index_cache['total'] = len(chunks)
  59. _index_cache['keywords'] = kw_raw
  60. _index_cache['use_bytes'] = use_bytes
  61. else:
  62. # Fallback to JSON
  63. json_index = base / 'rag_index.json'
  64. json_kw = base / 'rag_index.keywords.json'
  65. with open(json_index, 'r', encoding='utf-8') as f:
  66. data = json.load(f)
  67. _index_cache['chunks'] = {c['id']: c for c in data['chunks']}
  68. _index_cache['total'] = data['total_chunks']
  69. with open(json_kw, 'r', encoding='utf-8') as f:
  70. _index_cache['keywords'] = json.load(f)
  71. _index_cache['use_bytes'] = False
  72. return _index_cache
  73. def _decode_postings(raw, use_bytes):
  74. """Decode posting list from bytes or list"""
  75. if use_bytes and isinstance(raw, bytes):
  76. n = len(raw) // 2
  77. return list(struct.unpack(f"<{n}H", raw))
  78. return raw
  79. def tokenize(text):
  80. text = text.lower()
  81. words = []
  82. chinese = re.findall(r'[\u4e00-\u9fff]+', text)
  83. for cc in chinese:
  84. for i in range(len(cc) - 1):
  85. words.append(cc[i:i+2])
  86. if len(cc) >= 3:
  87. for i in range(len(cc) - 2):
  88. words.append(cc[i:i+3])
  89. words.extend(re.findall(r'[a-z]+', text))
  90. return words
  91. def search(query, top_k=5):
  92. idx = _load_index()
  93. keywords = idx['keywords']
  94. chunks = idx['chunks']
  95. total = idx['total']
  96. use_bytes = idx.get('use_bytes', False)
  97. query_tokens = tokenize(query)
  98. if not query_tokens:
  99. return []
  100. scores = Counter()
  101. for token in query_tokens:
  102. if token not in keywords:
  103. continue
  104. posting_raw = keywords[token]
  105. posting_list = _decode_postings(posting_raw, use_bytes)
  106. idf = math.log((total - len(posting_list) + 0.5) / (len(posting_list) + 0.5) + 1)
  107. for idx_num in posting_list:
  108. scores[idx_num] += idf
  109. top = scores.most_common(top_k)
  110. results = []
  111. for idx_num, score in top:
  112. chunk = chunks.get(idx_num)
  113. if chunk:
  114. results.append({
  115. 'source': chunk['source'],
  116. 'chunk_id': chunk['chunk_id'],
  117. 'score': round(score, 3),
  118. 'text': chunk['text'],
  119. })
  120. return results
  121. if __name__ == '__main__':
  122. import sys, time
  123. cfg = _get_config()
  124. print(f'Config: base={cfg["base_dir"]}')
  125. t0 = time.time()
  126. _load_index()
  127. t1 = time.time()
  128. print(f'Index loaded in {t1-t0:.1f}s')
  129. query = sys.argv[1] if len(sys.argv) > 1 else '船舶进出港报告'
  130. t0 = time.time()
  131. results = search(query, top_k=3)
  132. t1 = time.time()
  133. print(f'Search "{query}" in {(t1-t0)*1000:.0f}ms')
  134. for i, r in enumerate(results):
  135. print(f'\n[{i+1}] {r["source"][:50]} (score={r["score"]})')
  136. print(f' {r["text"][:150]}...')