| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356 |
- """Read-only, manifest-pinned, cross-database retrieval with verified full records."""
- import json
- import math
- import re
- import logging
- import os
- from concurrent.futures import ThreadPoolExecutor
- from threading import RLock
- from .timing import slot, measured, mapped
- from src.step2_knowledge_ingest.ingest import (
- CONTRACT, DIM, TYPES, Embedder, canonical, config, model_matches_manifest, restore, sha,
- )
- SEARCH_TYPES = tuple(kind for kind in TYPES if kind != 'catalog')
- PER_BANK_CANDIDATES = 3
- MAX_CANDIDATES = 12
- MAX_RELATED_GUIDES = 8
- RELATED_GUIDE_SEARCH_LIMIT = 8
- def related_guide_queries(question, records):
- """Expand an explicit list request from named sibling scenarios, never from prose."""
- if not re.search(r'列举|几个|哪些|所有|全部|一览|清单', question) or not re.search(
- r'办理事项|办事事项|办事指南|办理情形', question):
- return []
- groups, present = {}, set()
- for row in records:
- if row['type'] != 'service_guide':
- continue
- fields = (row['record'].get('data') or {}).get('structured_content') or {}
- if not isinstance(fields, dict):
- continue
- matter, district, scenario = (fields.get(key) for key in ('事项名称', '区划名称', '情形名称'))
- if not all(isinstance(value, str) and value.strip() for value in (matter, district, scenario)):
- continue
- group = (matter.strip(), district.strip())
- present.add((*group, scenario.strip()))
- basic = fields.get('基本信息')
- related = basic.get('其他业务办理项') if isinstance(basic, dict) else None
- names = related if isinstance(related, list) else related.split() if isinstance(related, str) else []
- for name in names:
- if isinstance(name, str) and 4 <= len(name.strip()) <= 120:
- groups.setdefault((*group, name.strip()), None)
- return [key for key in groups if key not in present][:MAX_RELATED_GUIDES]
- def choose_candidates(hits_by_type, query_hits, limit=MAX_CANDIDATES):
- """Keep bank coverage, then each query's best hit before score-only filling."""
- chosen, seen = [], set()
- def add(kind, hit):
- key = (kind, hit['id'])
- if len(chosen) < limit and key not in seen:
- chosen.append((kind, hit))
- seen.add(key)
- for kind, hits in hits_by_type.items():
- if hits:
- add(kind, hits[0])
- for hits in query_hits:
- if hits:
- kind, hit = max(hits, key=lambda pair: pair[1]['distance'])
- add(kind, hit)
- for kind, hit in sorted(((kind, hit) for kind, hits in hits_by_type.items() for hit in hits),
- key=lambda pair: pair[1]['distance'], reverse=True):
- add(kind, hit)
- return chosen
- def query_concurrency(path, environ=None, key='RETRIEVAL_QUERY_CONCURRENCY', default=4, maximum=64):
- value = str(default)
- if path.exists():
- for line in path.read_text(encoding='utf-8-sig').splitlines():
- line = line.strip().removeprefix('export ')
- if '=' in line and not line.startswith('#'):
- name, raw = line.split('=', 1)
- if name.strip() == key:
- value = raw.split(' #', 1)[0].strip().strip('\"\'')
- value = (os.environ if environ is None else environ).get(key, value)
- if not re.fullmatch(r'[0-9]+', value) or not 1 <= int(value) <= maximum:
- raise ValueError('invalid_retrieval_query_concurrency')
- return int(value)
- class RetrievalError(Exception):
- pass
- def query_text(text):
- return '为这个句子生成表示以用于检索相关文章:' + text
- class ReadStore:
- """One client pinned to one database; never switches the active database."""
- def __init__(self, settings, database):
- from pymilvus import MilvusClient
- logging.getLogger('pymilvus').setLevel(logging.CRITICAL)
- self.timeout = settings['MILVUS_TIMEOUT_SECONDS']
- self.client = MilvusClient(uri=settings['MILVUS_URI'], db_name=database, timeout=self.timeout)
- def get(self, name, ids):
- return self.client.get(name, ids=ids, output_fields=['*'], consistency_level='Strong', timeout=self.timeout)
- def close(self):
- self.client.close()
- class Retriever:
- def __init__(self, env_path, manifest_path):
- self.query_workers = query_concurrency(env_path)
- self.bank_workers = query_concurrency(env_path, key='RETRIEVAL_BANK_CONCURRENCY', default=6, maximum=6)
- self.content_workers = query_concurrency(env_path, key='RETRIEVAL_CONTENT_CONCURRENCY', default=8, maximum=32)
- self.settings = config(env_path)
- self.manifest = json.loads(manifest_path.read_text(encoding='utf-8'))
- m = self.manifest
- if m.get('complete') is not True or m.get('contract') != CONTRACT or m.get('dimension') != DIM:
- raise RetrievalError('invalid_manifest')
- if not re.fullmatch('[a-f0-9]{64}', m.get('snapshot', '')):
- raise RetrievalError('invalid_snapshot')
- for kind in SEARCH_TYPES:
- spec = m['databases'][kind]
- if spec['database'] != self.settings['MILVUS_DB_PREFIX'] + '_' + kind:
- raise RetrievalError('database_mismatch')
- version = spec.get('snapshot', m['snapshot'])
- if not isinstance(version, str) or not re.fullmatch('[a-f0-9]{64}', version):
- raise RetrievalError('invalid_library_snapshot')
- for key in ('content_collection', 'index_collection'):
- expected = ('content_' if key == 'content_collection' else 'index_') + version[:20]
- if spec[key] != expected:
- raise RetrievalError('collection_mismatch')
- self.embedder = None
- self._stores = {}
- self._warmed = False
- self.lock = RLock()
- def _initialize_model(self):
- if self.embedder is None:
- model_path = self.settings['EMBEDDING_MODEL_PATH']
- if not model_matches_manifest(model_path, self.manifest):
- raise RetrievalError('embedding_model_changed')
- self.embedder = Embedder(self.settings)
- def warmup(self):
- """Load and run one local inference before accepting requests; safe to repeat."""
- with self.lock:
- if self._warmed:
- return
- try:
- self._initialize_model()
- self.embedder.encode([query_text('政策与公共服务查询')])
- except RetrievalError:
- raise
- except Exception:
- raise RetrievalError('embedding_warmup_failed') from None
- self._warmed = True
- def _initialize(self):
- self._initialize_model()
- def _store_for(self, kind):
- # One pinned client per bank, shared only for read-only operations.
- with self.lock:
- if kind not in self._stores:
- self._stores[kind] = ReadStore(self.settings, self.manifest['databases'][kind]['database'])
- return self._stores[kind]
- def close(self):
- with self.lock:
- for store in self._stores.values():
- store.close()
- self._stores.clear()
- def search(self, question, expansions=()):
- # GPU inference is serialized, but network retrieval can overlap across users.
- # Hosts must drain searches before close(), and bound concurrent requests.
- try:
- return self._search(question, expansions)
- except RetrievalError:
- raise
- except Exception:
- raise RetrievalError('retrieval_service_failure') from None
- def _search(self, question, expansions):
- if not isinstance(question, str) or not question.strip() or len(question) > 8000:
- raise RetrievalError('invalid_question')
- queries = [question]
- for text in expansions:
- if isinstance(text, str) and text.strip() and text not in queries and len(text) <= 500:
- queries.append(text)
- with slot(self.lock, 'embedding'):
- self._initialize()
- vectors = self.embedder.encode([query_text(text) for text in queries])
- # Initialize each pinned client before query tasks share its read-only searches.
- unavailable = set()
- for kind in SEARCH_TYPES:
- try:
- self._store_for(kind)
- except Exception:
- unavailable.add(kind)
- def recall(kind, vector):
- spec = self.manifest['databases'][kind]
- try:
- if kind in unavailable:
- raise RetrievalError('database_unavailable')
- store = self._stores[kind]
- groups = store.client.search(spec['index_collection'], data=[vector],
- limit=PER_BANK_CANDIDATES, output_fields=['record_hash'],
- search_params={'metric_type': 'COSINE', 'params': {'ef': 64}},
- consistency_level='Strong', timeout=store.timeout)
- unique = {}
- for group in groups:
- for hit in group:
- record_id = hit['id']
- if not re.fullmatch('k_[a-f0-9]{24}', record_id) or not math.isfinite(hit['distance']):
- raise RetrievalError('invalid_search_hit')
- previous = unique.get(record_id)
- if previous is None or hit['distance'] > previous['distance']:
- unique[record_id] = hit
- return kind, sorted(unique.values(), key=lambda hit: hit['distance'], reverse=True), False
- except Exception:
- return kind, [], True
- @measured('vector_search')
- def recall_query(index):
- with ThreadPoolExecutor(max_workers=self.bank_workers) as banks:
- return index, list(banks.map(lambda kind: recall(kind, vectors[index]), SEARCH_TYPES))
- merged = {kind: {} for kind in SEARCH_TYPES}
- failed_queries = []
- query_hits = [[] for _ in queries]
- successful_searches = 0
- with ThreadPoolExecutor(max_workers=min(self.query_workers, len(queries))) as pool:
- # Every query is scheduled; map preserves query order for deterministic merging.
- for index, rows in mapped(pool, recall_query, range(len(queries))):
- for kind, hits, failed in rows:
- if failed:
- failed_queries.append({'query_index': index, 'type': kind})
- continue
- successful_searches += 1
- query_hits[index].extend((kind, hit) for hit in hits)
- for hit in hits:
- previous = merged[kind].get(hit['id'])
- if previous is None or hit['distance'] > previous['distance']:
- merged[kind][hit['id']] = hit
- failures = [kind for kind in SEARCH_TYPES if any(row['type'] == kind for row in failed_queries)]
- hits_by_type = {kind: sorted(hits.values(), key=lambda hit: hit['distance'], reverse=True)
- for kind, hits in merged.items()}
- if not successful_searches:
- raise RetrievalError('all_databases_failed')
- # Preserve cross-bank coverage before filling the original 12-candidate budget.
- chosen = choose_candidates(hits_by_type, query_hits)
- query_best = [max(hits, key=lambda pair: pair[1]['distance']) if hits else None for hits in query_hits]
- selected_by_type = {kind: [hit for selected_kind, hit in chosen if selected_kind == kind]
- for kind in SEARCH_TYPES}
- # Batch all segments, including long records, then verify each full record.
- segments = {}
- @measured('content_read')
- def fetch_segments(kind, ids, bank_segments):
- for start in range(0, len(ids), 128):
- batch_ids = ids[start:start + 128]
- try:
- rows = self._store_for(kind).get(self.manifest['databases'][kind]['content_collection'], batch_ids)
- for row in rows:
- if row.get('id') in batch_ids:
- bank_segments.setdefault((kind, row['id']), []).append(row)
- except Exception:
- # Missing segments become explicit per-record failures below.
- continue
- def fetch_bank(kind):
- # Each worker owns its segments; first segments must arrive before
- # discovering the rest of that bank's records.
- bank_segments = {}
- fetch_segments(kind, [hit['id'] + ':0' for hit in selected_by_type[kind]], bank_segments)
- remaining = []
- for hit in selected_by_type[kind]:
- first = bank_segments.get((kind, hit['id'] + ':0'), [])
- if len(first) == 1 and type(first[0].get('total')) is int and 1 <= first[0]['total'] <= 10000:
- remaining.extend(f"{hit['id']}:{i}" for i in range(1, first[0]['total']))
- fetch_segments(kind, remaining, bank_segments)
- return bank_segments
- with ThreadPoolExecutor(max_workers=self.content_workers) as pool:
- for bank_segments in mapped(pool, fetch_bank, (kind for kind in SEARCH_TYPES if selected_by_type[kind])):
- segments.update(bank_segments)
- records, content_failures = [], []
- for kind, hit in chosen:
- spec = self.manifest['databases'][kind]
- try:
- first = segments.get((kind, hit['id'] + ':0'), [])
- if len(first) != 1 or type(first[0]['total']) is not int or not 1 <= first[0]['total'] <= 10000:
- raise RetrievalError('missing_content')
- parts = [part for i in range(first[0]['total'])
- for part in segments.get((kind, f"{hit['id']}:{i}"), [])]
- record = restore(parts)
- if record['id'] != hit['id'] or sha(canonical(record)) != hit['entity']['record_hash']:
- raise RetrievalError('index_content_mismatch')
- records.append({'record': record, 'type': kind, 'score': hit['distance'],
- 'best_query_indices': [i for i, best in enumerate(query_best)
- if best and best[0] == kind and best[1]['id'] == hit['id']],
- 'snapshot': self.manifest['snapshot']})
- except Exception:
- content_failures.append(hit['id'])
- related = related_guide_queries(question, records)
- related_guide_failures = 0
- if related and 'service_guide' not in unavailable:
- spec = self.manifest['databases']['service_guide']
- store = self._stores['service_guide']
- with slot(self.lock, 'embedding'):
- vectors = self.embedder.encode([query_text(name) for _, _, name in related])
- known = {row['record']['id'] for row in records}
- for (matter, district, scenario), vector in zip(related, vectors):
- try:
- found = False
- groups = store.client.search(spec['index_collection'], data=[vector],
- limit=RELATED_GUIDE_SEARCH_LIMIT, output_fields=['record_hash'],
- search_params={'metric_type': 'COSINE', 'params': {'ef': 64}},
- consistency_level='Strong', timeout=store.timeout)
- for group in groups:
- for hit in group:
- record_id = hit['id']
- if record_id in known or not re.fullmatch('k_[a-f0-9]{24}', record_id):
- continue
- first = store.get(spec['content_collection'], [record_id + ':0'])
- if (len(first) != 1 or first[0].get('id') != record_id + ':0'
- or type(first[0].get('total')) is not int or not 1 <= first[0]['total'] <= 10000):
- continue
- ids = [f'{record_id}:{i}' for i in range(1, first[0]['total'])]
- later = {part.get('id'): part for start in range(0, len(ids), 128)
- for part in store.get(spec['content_collection'], ids[start:start + 128])}
- if any(identity not in later for identity in ids):
- continue
- parts = first + [later[identity] for identity in ids]
- record = restore(parts)
- fields = (record.get('data') or {}).get('structured_content') or {}
- if (record.get('id') == record_id and sha(canonical(record)) == hit['entity']['record_hash']
- and isinstance(fields, dict) and fields.get('事项名称') == matter
- and fields.get('区划名称') == district and fields.get('情形名称') == scenario):
- records.append({'record': record, 'type': 'service_guide', 'score': hit['distance'],
- 'best_query_indices': [], 'snapshot': self.manifest['snapshot']})
- known.add(record_id)
- found = True
- break
- if found:
- break
- except Exception:
- # Supplementary lookup must not hide the original verified results.
- related_guide_failures += 1
- continue
- return {'candidates': records, 'queries': queries, 'failed_databases': failures,
- 'failed_queries': failed_queries,
- 'content_failures': content_failures, 'related_guide_failures': related_guide_failures,
- 'complete': not failures and not content_failures and not related_guide_failures,
- 'snapshot': self.manifest['snapshot'], 'searched_types': list(SEARCH_TYPES),
- 'candidate_limit': MAX_CANDIDATES + (MAX_RELATED_GUIDES if related else 0),
- 'base_candidate_limit': MAX_CANDIDATES,
- 'per_type_limit': PER_BANK_CANDIDATES,
- 'related_guide_limit': MAX_RELATED_GUIDES}
|