#!/usr/bin/env python3 """ RAG search engine v2: uses optimized pickle index Loads in ~6s instead of ~50s, uses 96% less disk """ import json import os import re import math import pickle import struct from collections import Counter from pathlib import Path def _resolve_config(): search_paths = [ os.environ.get('AGENT_CONFIG', ''), Path(__file__).parent.parent / 'agent-config.yml', Path('/app/agentexample/qaagent67/agent-config.yml'), ] for p in search_paths: p = Path(p) if p.exists(): import yaml with open(p) as f: cfg = yaml.safe_load(f) knowledge = cfg.get('knowledge', {}) base = Path(knowledge.get('baseDir', '/data/knowledge/maritime')) return {'base_dir': base} base = Path(os.environ.get('KNOWLEDGE_DIR', '/data/knowledge/maritime')) return {'base_dir': base} _config = None _index_cache = {} def _get_config(): global _config if _config is None: _config = _resolve_config() return _config def _load_index(): if 'chunks' not in _index_cache: cfg = _get_config() base = cfg['base_dir'] # Try optimized pickle first, fall back to JSON pkl_index = base / 'rag_index.pkl' pkl_kw = base / 'rag_keywords.pkl' if pkl_index.exists() and pkl_kw.exists(): with open(pkl_index, 'rb') as f: data = pickle.load(f) with open(pkl_kw, 'rb') as f: kw_raw = pickle.load(f) chunks = data['chunks'] # list of (source, chunk_id, text) tuples use_bytes = data.get('use_bytes', False) # Build lookup chunk_lookup = {} for i, (source, chunk_id, text) in enumerate(chunks): cid = f"{source}#chunk{chunk_id}" chunk_lookup[i] = { 'id': cid, 'source': source, 'chunk_id': chunk_id, 'text': text, 'length': len(text), } # Convert bytes postings back to int lists on demand _index_cache['chunks'] = chunk_lookup _index_cache['total'] = len(chunks) _index_cache['keywords'] = kw_raw _index_cache['use_bytes'] = use_bytes else: # Fallback to JSON json_index = base / 'rag_index.json' json_kw = base / 'rag_index.keywords.json' with open(json_index, 'r', encoding='utf-8') as f: data = json.load(f) _index_cache['chunks'] = {c['id']: c for c in data['chunks']} _index_cache['total'] = data['total_chunks'] with open(json_kw, 'r', encoding='utf-8') as f: _index_cache['keywords'] = json.load(f) _index_cache['use_bytes'] = False return _index_cache def _decode_postings(raw, use_bytes): """Decode posting list from bytes or list""" if use_bytes and isinstance(raw, bytes): n = len(raw) // 2 return list(struct.unpack(f"<{n}H", raw)) return raw def tokenize(text): text = text.lower() words = [] chinese = re.findall(r'[\u4e00-\u9fff]+', text) for cc in chinese: for i in range(len(cc) - 1): words.append(cc[i:i+2]) if len(cc) >= 3: for i in range(len(cc) - 2): words.append(cc[i:i+3]) words.extend(re.findall(r'[a-z]+', text)) return words def search(query, top_k=5): idx = _load_index() keywords = idx['keywords'] chunks = idx['chunks'] total = idx['total'] use_bytes = idx.get('use_bytes', False) query_tokens = tokenize(query) if not query_tokens: return [] scores = Counter() for token in query_tokens: if token not in keywords: continue posting_raw = keywords[token] posting_list = _decode_postings(posting_raw, use_bytes) idf = math.log((total - len(posting_list) + 0.5) / (len(posting_list) + 0.5) + 1) for idx_num in posting_list: scores[idx_num] += idf top = scores.most_common(top_k) results = [] for idx_num, score in top: chunk = chunks.get(idx_num) if chunk: results.append({ 'source': chunk['source'], 'chunk_id': chunk['chunk_id'], 'score': round(score, 3), 'text': chunk['text'], }) return results if __name__ == '__main__': import sys, time cfg = _get_config() print(f'Config: base={cfg["base_dir"]}') t0 = time.time() _load_index() t1 = time.time() print(f'Index loaded in {t1-t0:.1f}s') query = sys.argv[1] if len(sys.argv) > 1 else '船舶进出港报告' t0 = time.time() results = search(query, top_k=3) t1 = time.time() print(f'Search "{query}" in {(t1-t0)*1000:.0f}ms') for i, r in enumerate(results): print(f'\n[{i+1}] {r["source"][:50]} (score={r["score"]})') print(f' {r["text"][:150]}...')