search_engine.py 4.9 KB

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