ingest.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. """Typed, versioned ingestion. Full records and index text have separate storage."""
  2. import argparse
  3. from collections import Counter
  4. from contextlib import contextmanager
  5. import hashlib
  6. import json
  7. import logging
  8. import math
  9. import os
  10. from pathlib import Path
  11. import re
  12. import sqlite3
  13. import sys
  14. from src.step1_knowledge_prepare.prepare import ROOT, strict_json
  15. from src.step1_knowledge_prepare.summary_input import scenario
  16. from src.step1_knowledge_prepare.summarize import atomic_write, run_lock, utc_now
  17. from src.knowledge_profiles import AGENTS, check_records, for_agent, prepared_path
  18. KINDS = ('policy', 'finance', 'general', 'qa')
  19. TYPES = ('policy', 'finance', 'qa', 'article', 'service_guide', 'service_catalog', 'catalog')
  20. DEFAULTS = {
  21. 'MILVUS_URI': 'http://192.168.2.69:19530',
  22. 'MILVUS_DB_PREFIX': 'business_knowledge',
  23. 'TALENT_MILVUS_DB_PREFIX': 'talent_knowledge',
  24. 'MILVUS_TIMEOUT_SECONDS': '60',
  25. 'EMBEDDING_MODEL_PATH': 'models/bge-large-zh-v1.5',
  26. 'EMBEDDING_DEVICE': 'cpu',
  27. 'EMBEDDING_BATCH_SIZE': '8',
  28. 'EMBEDDING_CPU_THREADS': '16',
  29. }
  30. DIM = 1024
  31. CONTRACT = 'title-summary-bge-zh-cls-l2-fp32-token-weighted-windows-v1'
  32. def canonical(value):
  33. return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':'), allow_nan=False)
  34. def sha(value):
  35. return hashlib.sha256(value.encode('utf-8')).hexdigest()
  36. def file_sha(path):
  37. with path.open('rb') as stream:
  38. return hashlib.file_digest(stream, 'sha256').hexdigest()
  39. def model_fingerprint(model_dir):
  40. files = {str(p.relative_to(model_dir)): file_sha(p) for p in sorted(model_dir.rglob('*'))
  41. if p.is_file() and p.suffix in ('.json', '.safetensors', '.bin', '.txt')}
  42. if not ({'model.safetensors', 'pytorch_model.bin'} & files.keys()) or 'tokenizer.json' not in files:
  43. raise ValueError('missing_local_model')
  44. return files
  45. def model_matches_manifest(model_dir, manifest):
  46. """Accept path separator differences while requiring identical model files."""
  47. actual = model_fingerprint(model_dir)
  48. if sha(canonical(actual)) == manifest['model_hash']:
  49. return True
  50. expected = manifest.get('model_files')
  51. if not isinstance(expected, dict) or sha(canonical(expected)) != manifest['model_hash']:
  52. return False
  53. normalize = lambda files: {name.replace('\\', '/'): digest for name, digest in files.items()}
  54. return len(normalize(actual)) == len(actual) and len(normalize(expected)) == len(expected) and normalize(actual) == normalize(expected)
  55. def config(path, environ=None):
  56. values = dict(DEFAULTS)
  57. if path.exists():
  58. for line in path.read_text(encoding='utf-8-sig').splitlines():
  59. line = line.strip().removeprefix('export ')
  60. if '=' not in line or line.startswith('#'):
  61. continue
  62. key, value = (item.strip() for item in line.split('=', 1))
  63. if key not in DEFAULTS:
  64. continue
  65. if value.startswith(('"', "'")):
  66. end = value.find(value[0], 1)
  67. if end < 0 or value[end + 1:].strip() and not value[end + 1:].strip().startswith('#'):
  68. raise ValueError('invalid_ingest_config_quote')
  69. value = value[1:end]
  70. else:
  71. value = value.split(' #', 1)[0].strip()
  72. values[key] = value
  73. env = os.environ if environ is None else environ
  74. values.update({key: env[key] for key in DEFAULTS if key in env})
  75. if not re.fullmatch(r'[A-Za-z][A-Za-z0-9_]{0,99}', values['MILVUS_DB_PREFIX']):
  76. raise ValueError('invalid_database_prefix')
  77. for key in ('MILVUS_TIMEOUT_SECONDS', 'EMBEDDING_BATCH_SIZE', 'EMBEDDING_CPU_THREADS'):
  78. values[key] = int(values[key])
  79. if values[key] <= 0:
  80. raise ValueError('nonpositive_ingest_config')
  81. model = Path(values['EMBEDDING_MODEL_PATH'])
  82. values['EMBEDDING_MODEL_PATH'] = model if model.is_absolute() else ROOT / model
  83. return values
  84. def route(record):
  85. if record['kind'] == 'qa':
  86. return 'qa'
  87. return scenario(record) or 'catalog'
  88. def index_text(record):
  89. kind = route(record)
  90. if kind == 'catalog':
  91. return None
  92. if kind == 'qa':
  93. value = record['data']['question']
  94. if record.get('summary_status') != 'not_required_qa':
  95. raise ValueError('qa_status_mismatch')
  96. else:
  97. if record.get('summary_status') != 'generated' or not isinstance(record.get('summary'), str) or not record['summary'].strip():
  98. raise ValueError('missing_complete_summary')
  99. value = record['title'] + '\n' + record['summary']
  100. if not isinstance(value, str) or not value.strip():
  101. raise ValueError('empty_index_text')
  102. if record.get('retrieval_text') != value:
  103. raise ValueError('retrieval_text_mismatch')
  104. if len(value.encode('utf-8')) > 65535:
  105. raise ValueError('index_text_exceeds_milvus_capacity_no_truncation')
  106. return value
  107. def load(prepared):
  108. records, seen = [], set()
  109. for kind in KINDS:
  110. original = [strict_json(line) for line in (prepared / f'{kind}.jsonl').read_text(encoding='utf-8').splitlines()]
  111. enhanced = [strict_json(line) for line in (prepared / f'{kind}.with_summary.jsonl').read_text(encoding='utf-8').splitlines()]
  112. if len(original) != len(enhanced):
  113. raise ValueError('source_count_mismatch')
  114. for source, record in zip(original, enhanced, strict=True):
  115. if any(key not in record or record[key] != value for key, value in source.items()):
  116. raise ValueError('original_record_changed')
  117. if record['kind'] != kind or record['id'] in seen or not re.fullmatch(r'k_[a-f0-9]{24}', record['id']):
  118. raise ValueError('invalid_id_or_kind')
  119. seen.add(record['id'])
  120. index_text(record)
  121. records.append(record)
  122. if not records:
  123. raise ValueError('empty_knowledge')
  124. return records
  125. def content_parts(record):
  126. raw = canonical(record)
  127. # 12,000 Unicode characters occupy at most 48,000 UTF-8 bytes.
  128. parts = [raw[i:i + 12000] for i in range(0, len(raw), 12000)]
  129. return [{'id': f"{record['id']}:{i}", 'record_id': record['id'], 'part': i,
  130. 'total': len(parts), 'payload': part, 'record_hash': sha(raw),
  131. 'vector': [1.0, 0.0]} for i, part in enumerate(parts)]
  132. def restore(parts):
  133. if not parts:
  134. raise ValueError('missing_content')
  135. parts = sorted(parts, key=lambda part: part['part'])
  136. if any(p['part'] != i or p['total'] != len(parts) or p['record_id'] != parts[0]['record_id'] or p['record_hash'] != parts[0]['record_hash'] for i, p in enumerate(parts)):
  137. raise ValueError('incomplete_content_parts')
  138. raw = ''.join(part['payload'] for part in parts)
  139. if sha(raw) != parts[0]['record_hash']:
  140. raise ValueError('content_hash_mismatch')
  141. return strict_json(raw)
  142. def valid_vector(vector):
  143. return len(vector) == DIM and all(math.isfinite(v) for v in vector) and abs(sum(v * v for v in vector) - 1) < 0.001
  144. class Embedder:
  145. def __init__(self, settings):
  146. import torch
  147. from transformers import AutoModel, AutoTokenizer
  148. torch.set_num_threads(settings['EMBEDDING_CPU_THREADS'])
  149. self.torch = torch
  150. path = str(settings['EMBEDDING_MODEL_PATH'])
  151. self.tokenizer = AutoTokenizer.from_pretrained(path, local_files_only=True, padding_side='right')
  152. self.model = AutoModel.from_pretrained(path, local_files_only=True, trust_remote_code=False, dtype=torch.float32).to(settings['EMBEDDING_DEVICE']).eval()
  153. self.limit = min(512, self.model.config.max_position_embeddings)
  154. self.batch_size = settings['EMBEDDING_BATCH_SIZE']
  155. if self.model.config.hidden_size != DIM or self.model.config.model_type != 'bert':
  156. raise ValueError('model_dimension_mismatch')
  157. def preflight(self, texts):
  158. lengths = [len(self.tokenizer(text, truncation=False)['input_ids']) for text in texts]
  159. return max(lengths, default=0)
  160. def encode(self, texts):
  161. # Preserve every token. Long index/query texts use contiguous 510-token
  162. # windows; aggregate normalized CLS vectors by payload-token count.
  163. windows, owners, weights = [], [], []
  164. for owner, text in enumerate(texts):
  165. ids = self.tokenizer(text, add_special_tokens=False, truncation=False)['input_ids']
  166. for start in range(0, max(1, len(ids)), self.limit - 2):
  167. part = ids[start:start + self.limit - 2]
  168. windows.append({'input_ids': [self.tokenizer.cls_token_id] + part + [self.tokenizer.sep_token_id]})
  169. owners.append(owner)
  170. weights.append(max(1, len(part)))
  171. totals = self.torch.zeros((len(texts), DIM), dtype=self.torch.float32)
  172. for start in range(0, len(windows), self.batch_size):
  173. inputs = self.tokenizer.pad(windows[start:start + self.batch_size], padding=True, return_tensors='pt').to(self.model.device)
  174. with self.torch.inference_mode():
  175. hidden = self.model(**inputs).last_hidden_state[:, 0]
  176. chunks = self.torch.nn.functional.normalize(hidden.float(), p=2, dim=1).cpu()
  177. for offset, vector in enumerate(chunks):
  178. i = start + offset
  179. totals[owners[i]] += vector * weights[i]
  180. vectors = self.torch.nn.functional.normalize(totals, p=2, dim=1).tolist()
  181. if len(vectors) != len(texts) or not all(valid_vector(v) for v in vectors):
  182. raise ValueError('invalid_embedding')
  183. return vectors
  184. class Store:
  185. def __init__(self, settings):
  186. from pymilvus import MilvusClient
  187. logging.getLogger('pymilvus').setLevel(logging.CRITICAL)
  188. self.settings = settings
  189. self.timeout = settings['MILVUS_TIMEOUT_SECONDS']
  190. self.client = MilvusClient(uri=settings['MILVUS_URI'], timeout=settings['MILVUS_TIMEOUT_SECONDS'])
  191. def use(self, kind, create):
  192. database = self.settings['MILVUS_DB_PREFIX'] + '_' + kind
  193. if database not in self.client.list_databases(timeout=self.timeout):
  194. if not create:
  195. raise ValueError('missing_database')
  196. self.client.create_database(database, timeout=self.timeout)
  197. self.client.use_database(database, timeout=self.timeout)
  198. return database
  199. def ensure(self, name, content=False):
  200. from pymilvus import DataType
  201. if self.client.has_collection(name, timeout=self.timeout):
  202. info = self.client.describe_collection(name, timeout=self.timeout)
  203. if info.get('description') != CONTRACT:
  204. raise ValueError('existing_collection_contract_mismatch')
  205. return
  206. schema = self.client.create_schema(auto_id=False, enable_dynamic_field=False, description=CONTRACT)
  207. schema.add_field('id', DataType.VARCHAR, is_primary=True, max_length=128)
  208. schema.add_field('vector', DataType.FLOAT_VECTOR, dim=2 if content else DIM)
  209. schema.add_field('record_hash', DataType.VARCHAR, max_length=64)
  210. if content:
  211. schema.add_field('record_id', DataType.VARCHAR, max_length=128)
  212. schema.add_field('part', DataType.INT64)
  213. schema.add_field('total', DataType.INT64)
  214. schema.add_field('payload', DataType.VARCHAR, max_length=65535)
  215. else:
  216. schema.add_field('index_text', DataType.VARCHAR, max_length=65535)
  217. indexes = self.client.prepare_index_params()
  218. indexes.add_index(field_name='vector', index_type='FLAT' if content else 'HNSW', metric_type='COSINE', params={} if content else {'M': 16, 'efConstruction': 200})
  219. self.client.create_collection(name, schema=schema, index_params=indexes, consistency_level='Strong', timeout=self.timeout)
  220. def get(self, name, ids):
  221. return self.client.get(name, ids=ids, output_fields=['*'], consistency_level='Strong', timeout=self.timeout)
  222. def verify(self, records, content_name, index_name):
  223. for start in range(0, len(records), 32):
  224. batch = records[start:start + 32]
  225. ids = [part['id'] for record in batch for part in content_parts(record)]
  226. grouped = {}
  227. for offset in range(0, len(ids), 128):
  228. for part in self.get(content_name, ids[offset:offset + 128]):
  229. grouped.setdefault(part['record_id'], []).append(part)
  230. indexed_ids = [r['id'] for r in batch if index_text(r) is not None]
  231. rows = {row['id']: row for row in self.get(index_name, indexed_ids)} if indexed_ids else {}
  232. for record in batch:
  233. restored = restore(grouped.get(record['id'], []))
  234. if restored != record:
  235. raise ValueError('remote_content_mismatch')
  236. text = index_text(record)
  237. if text is not None:
  238. row = rows.get(record['id'])
  239. if row is None or row['record_hash'] != sha(canonical(record)) or row['index_text'] != text or not valid_vector(row['vector']):
  240. raise ValueError('remote_index_mismatch')
  241. for name, expected in ((content_name, sum(len(content_parts(r)) for r in records)), (index_name, sum(index_text(r) is not None for r in records))):
  242. if expected:
  243. count = self.client.query(name, filter='', output_fields=['count(*)'], consistency_level='Strong', timeout=self.timeout)[0]['count(*)']
  244. if count != expected:
  245. raise ValueError('remote_count_mismatch')
  246. indexed = next((r for r in records if index_text(r) is not None), None)
  247. if indexed:
  248. vector = self.get(index_name, [indexed['id']])[0]['vector']
  249. hits = self.client.search(index_name, data=[vector], limit=1, search_params={'metric_type': 'COSINE', 'params': {'ef': 64}}, timeout=self.timeout)[0]
  250. if not hits or hits[0]['distance'] < 0.999:
  251. raise ValueError('vector_search_smoke_failed')
  252. @contextmanager
  253. def publication_lock(prepared):
  254. """Stable lock outside the directory that an import may replace."""
  255. try:
  256. with run_lock(prepared.parent / ('.' + prepared.name + '.publish.lock')):
  257. yield
  258. except ValueError as exc:
  259. if str(exc) == 'another_summary_run_is_active':
  260. raise ValueError('another_knowledge_operation_is_active') from None
  261. raise
  262. def execute(args):
  263. with publication_lock(args.prepared):
  264. return _execute(args)
  265. def _execute(args):
  266. settings = for_agent(config(args.env_file), getattr(args, 'agent', 'business'), getattr(args, 'db_prefix', None))
  267. records = load(args.prepared)
  268. check_records(records, settings)
  269. model_dir = settings['EMBEDDING_MODEL_PATH']
  270. model_files = model_fingerprint(model_dir)
  271. model_hash = sha(canonical(model_files))
  272. snapshot = sha(canonical({'contract': CONTRACT, 'model': model_hash, 'records': records}))
  273. content_name, index_name = 'content_' + snapshot[:20], 'index_' + snapshot[:20]
  274. counts = Counter(route(r) for r in records)
  275. published = None
  276. if args.verify_only:
  277. published = json.loads((args.prepared / 'ingestion/manifest.json').read_text(encoding='utf-8'))
  278. if (published.get('complete') is not True or published.get('contract') != CONTRACT
  279. or published.get('dimension') != DIM or published.get('counts') != dict(counts)
  280. or set(published.get('databases', {})) != set(counts)
  281. or not model_matches_manifest(model_dir, published)):
  282. raise ValueError('invalid_published_manifest')
  283. model_hash, model_files = published['model_hash'], published['model_files']
  284. snapshot = sha(canonical({'contract': CONTRACT, 'model': model_hash, 'records': records}))
  285. if published.get('snapshot') != snapshot:
  286. raise ValueError('published_snapshot_mismatch')
  287. print(json.dumps({'counts': counts, 'snapshot': snapshot, 'mode': 'verify' if args.verify_only else 'dry-run' if args.dry_run else 'ingest'}), flush=True)
  288. if args.dry_run:
  289. return
  290. output = args.prepared / 'ingestion'
  291. output.mkdir(exist_ok=True)
  292. with run_lock(output / '.ingest.lock'):
  293. store = Store(settings)
  294. cache = sqlite3.connect(output / 'embedding_cache.sqlite3')
  295. try:
  296. cache.execute('CREATE TABLE IF NOT EXISTS vectors (key TEXT PRIMARY KEY, vector TEXT NOT NULL)')
  297. embedder = None
  298. if not args.verify_only:
  299. embedder = Embedder(settings)
  300. print(json.dumps({'max_index_tokens': embedder.preflight([index_text(r) for r in records if index_text(r) is not None])}), flush=True)
  301. manifest = {'complete': False, 'snapshot': snapshot, 'contract': CONTRACT, 'model_hash': model_hash, 'model_files': model_files, 'dimension': DIM, 'counts': dict(counts), 'databases': {}, 'verified_at': None}
  302. generated, reused = 0, 0
  303. for kind in TYPES:
  304. subset = [r for r in records if route(r) == kind]
  305. if not subset:
  306. continue
  307. database = store.use(kind, create=not args.verify_only)
  308. if published:
  309. spec = published['databases'][kind]
  310. version = spec.get('snapshot', snapshot)
  311. if (not isinstance(version, str) or not re.fullmatch(r'[a-f0-9]{64}', version)
  312. or spec.get('database') != database
  313. or spec.get('content_collection') != 'content_' + version[:20]
  314. or spec.get('index_collection') != (None if kind == 'catalog' else 'index_' + version[:20])):
  315. raise ValueError('invalid_published_collection')
  316. content_name, index_name = spec['content_collection'], spec['index_collection']
  317. manifest['databases'][kind] = {'database': database, 'content_collection': content_name, 'index_collection': index_name if kind != 'catalog' else None}
  318. if published and 'snapshot' in spec:
  319. manifest['databases'][kind]['snapshot'] = spec['snapshot']
  320. if not args.verify_only:
  321. store.ensure(content_name, content=True)
  322. if kind != 'catalog':
  323. store.ensure(index_name)
  324. batch_size = settings['EMBEDDING_BATCH_SIZE']
  325. for start in range(0, len(subset), batch_size):
  326. batch = subset[start:start + batch_size]
  327. texts = [index_text(r) for r in batch]
  328. keys = [sha(model_hash + CONTRACT + text) if text is not None else None for text in texts]
  329. vectors, missing = {}, []
  330. for i, key in enumerate(keys):
  331. if key is None:
  332. continue
  333. saved = cache.execute('SELECT vector FROM vectors WHERE key=?', (key,)).fetchone()
  334. if saved and valid_vector(json.loads(saved[0])):
  335. vectors[i] = json.loads(saved[0])
  336. reused += 1
  337. else:
  338. missing.append(i)
  339. if missing:
  340. generated += len(missing)
  341. for i, vector in zip(missing, embedder.encode([texts[i] for i in missing]), strict=True):
  342. vectors[i] = vector
  343. cache.execute('INSERT OR REPLACE INTO vectors VALUES (?, ?)', (keys[i], canonical(vector)))
  344. cache.commit()
  345. parts = [part for r in batch for part in content_parts(r)]
  346. for offset in range(0, len(parts), 32):
  347. store.client.upsert(content_name, data=parts[offset:offset + 32], timeout=store.timeout)
  348. rows = [{'id': r['id'], 'vector': vectors[i], 'index_text': texts[i], 'record_hash': sha(canonical(r))} for i, r in enumerate(batch) if texts[i] is not None]
  349. if rows:
  350. store.client.upsert(index_name, data=rows, timeout=store.timeout)
  351. if start % (batch_size * 10) == 0 or start + batch_size >= len(subset):
  352. print(json.dumps({'type': kind, 'written': min(start + batch_size, len(subset)), 'total': len(subset)}), flush=True)
  353. store.client.flush(content_name, timeout=store.timeout)
  354. if kind != 'catalog':
  355. store.client.flush(index_name, timeout=store.timeout)
  356. store.verify(subset, content_name, index_name)
  357. print(json.dumps({'type': kind, 'verified': len(subset)}), flush=True)
  358. manifest['complete'] = True
  359. manifest['verified_at'] = utc_now()
  360. atomic_write(output / 'manifest.json', json.dumps(manifest, ensure_ascii=False, indent=2) + '\n')
  361. print(json.dumps({'complete': True, 'records_verified': len(records), 'vectors_generated': generated, 'vectors_reused': reused}), flush=True)
  362. finally:
  363. cache.close()
  364. store.client.close()
  365. def main():
  366. parser = argparse.ArgumentParser(description=__doc__)
  367. parser.add_argument('--agent', choices=AGENTS, default='business')
  368. parser.add_argument('--db-prefix', help='Explicit target prefix; validated against the other agent')
  369. parser.add_argument('--prepared', type=Path)
  370. parser.add_argument('--env-file', type=Path, default=ROOT / '.env')
  371. mode = parser.add_mutually_exclusive_group()
  372. mode.add_argument('--dry-run', action='store_true')
  373. mode.add_argument('--verify-only', action='store_true')
  374. args = parser.parse_args()
  375. args.prepared = args.prepared or prepared_path(ROOT, args.agent)
  376. try:
  377. execute(args)
  378. return 0
  379. except Exception as exc:
  380. # SDK exceptions may contain request payloads/addresses; do not echo them.
  381. code = str(exc) if isinstance(exc, ValueError) and re.fullmatch(r'[a-z_]+', str(exc)) else type(exc).__name__
  382. print('knowledge_ingest_failed:' + code, file=sys.stderr)
  383. return 1