@lzhzzzzwill/cofos 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,859 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ COF-ROS-Reasoner: 检索证据脚本
4
+
5
+ 目标:
6
+ - 使用 FAISS + BM25 进行混合检索
7
+ - 合并结果并重排
8
+ - 返回证据列表
9
+ """
10
+
11
+ import os
12
+ import json
13
+ import yaml
14
+ import logging
15
+ import pickle
16
+ import re
17
+ from pathlib import Path
18
+ from typing import Dict, List, Any, Optional, Tuple
19
+ from datetime import datetime
20
+ from collections import defaultdict
21
+
22
+ import numpy as np
23
+
24
+ # 添加 src 路径
25
+
26
+ try:
27
+ from sentence_transformers import SentenceTransformer
28
+ except ImportError:
29
+ SentenceTransformer = None
30
+
31
+ try:
32
+ import faiss
33
+ except ImportError:
34
+ faiss = None
35
+
36
+ try:
37
+ from rank_bm25 import BM25Okapi
38
+ except ImportError:
39
+ BM25Okapi = None
40
+
41
+ from data_processing.utils import setup_logging, ensure_directory
42
+ from retrieval.text_search import tokenize_search_text
43
+
44
+
45
+ class EvidenceRetriever:
46
+ """检索证据"""
47
+
48
+ def __init__(self, config: Dict[str, Any]):
49
+ self.config = config
50
+ self.faiss_dir = Path(config['paths']['retrieval_store'])
51
+ self.bm25_dir = Path(config['paths']['bm25_store'])
52
+
53
+ # 设置日志
54
+ setup_logging(config)
55
+
56
+ # 检索配置
57
+ self.vector_top_k = config.get('retrieval', {}).get('vector_top_k', 20)
58
+ self.bm25_top_k = config.get('retrieval', {}).get('bm25_top_k', 20)
59
+ self.rerank_top_k = config.get('retrieval', {}).get('rerank_top_k', 5)
60
+ self.enable_faiss = config.get('retrieval', {}).get('enable_faiss', False)
61
+ self.enable_bm25 = config.get('retrieval', {}).get('enable_bm25', True)
62
+ self.min_bm25_score = float(
63
+ config.get('retrieval', {}).get('min_bm25_score', 7.0)
64
+ )
65
+ self.min_vector_score = float(
66
+ config.get('retrieval', {}).get('min_vector_score', 0.35)
67
+ )
68
+ self.min_kg_confidence = float(
69
+ config.get('retrieval', {}).get('min_kg_confidence', 2.0)
70
+ )
71
+
72
+ self.source_priority = config.get('retrieval', {}).get('source_priority', {
73
+ 'gold_json': 4,
74
+ 'silver_json': 3,
75
+ 'pdf_fulltext': 2,
76
+ 'wos_abstract': 1
77
+ })
78
+
79
+ # ROS 标准化
80
+ self.ros_normalization = config.get('data_processing', {}).get('ros_normalization', {})
81
+
82
+ # 加载索引与 KG 三元组
83
+ self._load_indices()
84
+ self._load_kg_triples()
85
+
86
+ def _load_indices(self):
87
+ """加载 FAISS 和 BM25 索引"""
88
+ # 加载 FAISS 索引
89
+ self.faiss_index = None
90
+ self.faiss_metadata = []
91
+
92
+ if self.enable_faiss and self.faiss_dir.exists():
93
+ faiss_path = self.faiss_dir / 'faiss_index.bin'
94
+ metadata_path = self.faiss_dir / 'faiss_metadata.jsonl'
95
+
96
+ if faiss is not None and faiss_path.exists() and metadata_path.exists():
97
+ self.faiss_index = faiss.read_index(str(faiss_path))
98
+
99
+ with open(metadata_path, 'r', encoding='utf-8') as f:
100
+ for line in f:
101
+ if line.strip():
102
+ self.faiss_metadata.append(json.loads(line))
103
+
104
+ logging.info(f"Loaded FAISS index with {len(self.faiss_metadata)} vectors")
105
+
106
+ # 加载 BM25 索引
107
+ self.bm25_index = None
108
+ self.bm25_metadata = []
109
+
110
+ if self.enable_bm25 and self.bm25_dir.exists():
111
+ metadata_path = self.bm25_dir / 'bm25_metadata.jsonl'
112
+ index_path = self.bm25_dir / 'bm25_index.pkl'
113
+
114
+ if metadata_path.exists():
115
+ with open(metadata_path, 'r', encoding='utf-8') as f:
116
+ for line in f:
117
+ if line.strip():
118
+ self.bm25_metadata.append(json.loads(line))
119
+
120
+ logging.info(f"Loaded BM25 index with {len(self.bm25_metadata)} documents")
121
+
122
+ if not index_path.exists():
123
+ raise FileNotFoundError(
124
+ f"BM25 index not found: {index_path}. Run scripts/build_retrieval.py."
125
+ )
126
+ with open(index_path, 'rb') as f:
127
+ self.bm25_index = pickle.load(f)
128
+ logging.info(f"Loaded persisted BM25 index from {index_path}")
129
+
130
+
131
+ def _load_kg_triples(self) -> None:
132
+ """Load real KG triples and build lightweight in-memory indexes."""
133
+ self.kg_triples = []
134
+ self.kg_by_head = defaultdict(list)
135
+ self.kg_by_tail = defaultdict(list)
136
+
137
+ kg_path = Path(self.config['paths']['kg_dir']) / 'cofros_kg_triples.jsonl'
138
+ if not kg_path.exists():
139
+ logging.warning("KG triples not found: %s", kg_path)
140
+ return
141
+
142
+ try:
143
+ with open(kg_path, 'r', encoding='utf-8') as handle:
144
+ for line in handle:
145
+ if not line.strip():
146
+ continue
147
+ triple = json.loads(line)
148
+ head = triple.get('head', '')
149
+ tail = triple.get('tail', '')
150
+ if not head or not tail:
151
+ continue
152
+ self.kg_triples.append(triple)
153
+ self.kg_by_head[head].append(triple)
154
+ self.kg_by_tail[tail].append(triple)
155
+ logging.info("Loaded %s KG triples for graph retrieval", len(self.kg_triples))
156
+ except Exception as exc:
157
+ logging.error("Failed to load KG triples from %s: %s", kg_path, exc)
158
+ self.kg_triples = []
159
+ self.kg_by_head = defaultdict(list)
160
+ self.kg_by_tail = defaultdict(list)
161
+
162
+ @staticmethod
163
+ def _kg_label(node_id: str) -> str:
164
+ """Convert a KG node id into a readable label."""
165
+ label = str(node_id or '')
166
+ if ':' in label:
167
+ label = label.split(':', 1)[1]
168
+ return label.replace('_', ' ').strip()
169
+
170
+ @staticmethod
171
+ def _kg_search_text(text: str) -> str:
172
+ """Normalize labels for fuzzy query matching without changing displayed chemistry."""
173
+ text = str(text or '').lower().replace('_', ' ')
174
+ text = text.replace('−', '-').replace('⋅', '·')
175
+ text = re.sub(r"[^a-z0-9\u4e00-\u9fff·*+\-\s]", " ", text)
176
+ return re.sub(r"\s+", " ", text).strip()
177
+
178
+ @staticmethod
179
+ def _kg_tokens(text: str) -> set:
180
+ """Extract informative tokens for KG node matching.
181
+
182
+ Only generic English stopwords are removed. Domain terms like *cof*,
183
+ *ros*, *photocatalysis* are deliberately KEPT — they are the very
184
+ terms users query with and must contribute to KG node scoring.
185
+ """
186
+ tokens = set(re.findall(r"[a-z0-9]+", text.lower()))
187
+ return tokens - {
188
+ 'the', 'and', 'with', 'under', 'what', 'which',
189
+ 'does', 'do', 'is', 'are', 'a', 'an', 'of', 'in',
190
+ 'on', 'or', 'to', 'for', 'from', 'at', 'by', 'as',
191
+ 'it', 'be', 'how', 'not', 'no', 'but', 'its',
192
+ }
193
+
194
+ def _kg_label_score(self, label: str, query: str) -> float:
195
+ label_text = self._kg_search_text(label)
196
+ query_text = self._kg_search_text(query)
197
+ if not label_text:
198
+ return 0.0
199
+ if label_text in query_text:
200
+ return 100.0 + len(label_text) / 100.0
201
+
202
+ label_tokens = self._kg_tokens(label_text)
203
+ query_tokens = self._kg_tokens(query_text)
204
+ if not label_tokens or not query_tokens:
205
+ return 0.0
206
+
207
+ overlap = label_tokens & query_tokens
208
+ if not overlap:
209
+ return 0.0
210
+
211
+ generic_tokens = {
212
+ 'cof', 'cofs', 'ros', 'light', 'visible', 'photocatalysis',
213
+ 'photocatalytic', 'phenol', 'degradation', 'generate', 'generates',
214
+ 'dominate', 'dominates', 'dominant', 'which', 'what', 'under',
215
+ }
216
+ specific_overlap = overlap - generic_tokens
217
+ if not specific_overlap:
218
+ return 0.0
219
+
220
+ # Specific material or condition tokens such as fe3o4, tpma, tt, tempo,
221
+ # h2o2, and pms should outrank short generic matches such as phenol/light.
222
+ digit_or_formula_hits = sum(
223
+ 1 for token in specific_overlap if any(ch.isdigit() for ch in token)
224
+ )
225
+ score = (len(specific_overlap) * 3.0) + (len(overlap) * 0.5) + (digit_or_formula_hits * 2.0)
226
+ return score / max(len(label_tokens) ** 0.25, 1.0)
227
+
228
+ def _find_kg_nodes(self, query: str, node_prefix: str) -> List[str]:
229
+ return [node for _, node in self._find_scored_kg_nodes(query, node_prefix)]
230
+
231
+ def _find_scored_kg_nodes(self, query: str, node_prefix: str) -> List[Tuple[float, str]]:
232
+ nodes = set()
233
+ for triple in self.kg_triples:
234
+ for field in ('head', 'tail'):
235
+ node = triple.get(field, '')
236
+ if node.startswith(node_prefix):
237
+ nodes.add(node)
238
+
239
+ scored = []
240
+ for node in nodes:
241
+ score = self._kg_label_score(self._kg_label(node), query)
242
+ if score > 0:
243
+ scored.append((score, node))
244
+ scored.sort(key=lambda item: item[0], reverse=True)
245
+ return scored
246
+
247
+ @staticmethod
248
+ def is_domain_query(query: str) -> bool:
249
+ """Return whether a query is specific enough for COF/ROS retrieval."""
250
+ return bool(re.search(
251
+ r"\b(cof|cofs|ros|h2o2|o2|orr|oxygen|radical|peroxide|hydroxyl|"
252
+ r"superoxide|singlet|photocatal|photocatalysis|photocatalytic|"
253
+ r"redox|oxidation|reduction|electron|radicals?)\b",
254
+ query,
255
+ flags=re.IGNORECASE,
256
+ ))
257
+
258
+ @staticmethod
259
+ def _looks_like_kg_query(query: str) -> bool:
260
+ return EvidenceRetriever.is_domain_query(query)
261
+
262
+ @staticmethod
263
+ def _kg_fact_priority(fact: Dict[str, Any]) -> float:
264
+ relation_weight = {
265
+ 'dominantly_generates': 5.0,
266
+ 'secondarily_generates': 4.0,
267
+ 'has_intermediate': 3.0,
268
+ 'under_condition': 2.0,
269
+ 'reported_in': 0.5,
270
+ }.get(fact.get('relation', ''), 1.0)
271
+ evidence = fact.get('evidence') or {}
272
+ if not isinstance(evidence, dict):
273
+ evidence = {'evidence_level': str(evidence)}
274
+ support_weight = {
275
+ 'supported': 3.0,
276
+ 'partially_supported': 2.0,
277
+ 'unsupported': -2.0,
278
+ }.get(evidence.get('support_status') or fact.get('support_status', ''), 0.0)
279
+ level_weight = {
280
+ 'high': 2.0,
281
+ 'medium': 1.0,
282
+ 'low': 0.0,
283
+ }.get(evidence.get('evidence_level') or fact.get('evidence_level', ''), 0.0)
284
+ return relation_weight + support_weight + level_weight
285
+
286
+ @staticmethod
287
+ def _kg_fact_support_status(fact: Dict[str, Any]) -> str:
288
+ evidence = fact.get('evidence') or {}
289
+ if isinstance(evidence, dict):
290
+ return str(evidence.get('support_status') or fact.get('support_status') or '').lower()
291
+ return str(fact.get('support_status') or '').lower()
292
+
293
+ def is_confident_kg_fact(
294
+ self,
295
+ fact: Dict[str, Any],
296
+ min_confidence: Optional[float] = None,
297
+ ) -> bool:
298
+ """Return whether a KG fact is suitable for RAG context injection."""
299
+ if self._kg_fact_support_status(fact) == 'unsupported':
300
+ return False
301
+ threshold = getattr(self, 'min_kg_confidence', 2.0) if min_confidence is None else float(min_confidence)
302
+ return self._kg_fact_priority(fact) >= threshold
303
+
304
+ def _filter_confident_kg_facts(
305
+ self,
306
+ facts: List[Dict[str, Any]],
307
+ min_confidence: Optional[float] = None,
308
+ ) -> List[Dict[str, Any]]:
309
+ if min_confidence is None:
310
+ return facts
311
+ return [
312
+ fact for fact in facts
313
+ if self.is_confident_kg_fact(fact, min_confidence=min_confidence)
314
+ ]
315
+
316
+ def _format_kg_fact(self, triple: Dict[str, Any]) -> Dict[str, Any]:
317
+ fact = dict(triple)
318
+ fact['head'] = self._kg_label(triple.get('head', ''))
319
+ fact['tail'] = self._kg_label(triple.get('tail', ''))
320
+ fact['source'] = 'kg_triples'
321
+ return fact
322
+
323
+ def _dedupe_kg_facts(self, facts: List[Dict[str, Any]], top_k: int) -> List[Dict[str, Any]]:
324
+ seen = set()
325
+ unique = []
326
+ for fact in sorted(facts, key=self._kg_fact_priority, reverse=True):
327
+ key = (
328
+ fact.get('head', ''),
329
+ fact.get('relation', ''),
330
+ fact.get('tail', ''),
331
+ json.dumps(fact.get('condition', {}), sort_keys=True, ensure_ascii=False),
332
+ )
333
+ if key in seen:
334
+ continue
335
+ seen.add(key)
336
+ unique.append(fact)
337
+ if len(unique) >= top_k:
338
+ break
339
+ return unique
340
+
341
+ def _retrieve_graph_kg_facts(
342
+ self,
343
+ query: str,
344
+ top_k: int,
345
+ min_confidence: Optional[float] = None,
346
+ ) -> List[Dict[str, Any]]:
347
+ if not self.kg_triples or not self._looks_like_kg_query(query):
348
+ return []
349
+
350
+ facts = []
351
+ generation_relations = {
352
+ 'dominantly_generates',
353
+ 'secondarily_generates',
354
+ 'has_intermediate',
355
+ 'under_condition',
356
+ }
357
+
358
+ reverse_species_query = bool(re.search(
359
+ r"\bwhich\b.*\b(?:cof|cofs|materials?)\b.*\b(?:generate|generates|produce|produces|yield|yields)\b",
360
+ query,
361
+ flags=re.IGNORECASE,
362
+ ))
363
+
364
+ if not reverse_species_query:
365
+ scored_material_nodes = self._find_scored_kg_nodes(query, 'Material:')
366
+ if scored_material_nodes:
367
+ top_score = scored_material_nodes[0][0]
368
+ # If a query clearly names a material, use that material neighborhood
369
+ # instead of mixing in weaker generic material matches.
370
+ selected_nodes = [scored_material_nodes[0][1]] if top_score >= 2.5 else [
371
+ node for _, node in scored_material_nodes[:5]
372
+ ]
373
+ for material_node in selected_nodes:
374
+ for triple in self.kg_by_head.get(material_node, []):
375
+ if triple.get('relation') in generation_relations:
376
+ facts.append(self._format_kg_fact(triple))
377
+
378
+ # A concrete material mention should drive the graph neighborhood. Only use
379
+ # reverse species lookup when no material node was found.
380
+ if facts:
381
+ facts = self._filter_confident_kg_facts(facts, min_confidence=min_confidence)
382
+ return self._dedupe_kg_facts(facts, top_k)
383
+
384
+ for species_node in self._find_kg_nodes(query, 'ROS_or_Product:')[:5]:
385
+ for triple in self.kg_by_tail.get(species_node, []):
386
+ if triple.get('relation') in generation_relations:
387
+ facts.append(self._format_kg_fact(triple))
388
+
389
+ for species_node in self._find_kg_nodes(query, 'Intermediate:')[:5]:
390
+ for triple in self.kg_by_tail.get(species_node, []):
391
+ if triple.get('relation') in generation_relations:
392
+ facts.append(self._format_kg_fact(triple))
393
+
394
+ facts = self._filter_confident_kg_facts(facts, min_confidence=min_confidence)
395
+ return self._dedupe_kg_facts(facts, top_k)
396
+
397
+ def get_schema_record(self, record: Dict[str, Any]) -> Optional[Dict[str, Any]]:
398
+ """Return Gold schema record or validated Silver extracted payload."""
399
+ extracted = record.get('extracted_json')
400
+ if isinstance(extracted, dict):
401
+ if extracted.get('status') == 'insufficient_evidence' or extracted.get('error'):
402
+ return None
403
+ payload = dict(extracted)
404
+ payload['source_chunk_id'] = record.get('chunk_id', '')
405
+ payload['evidence_level'] = record.get('evidence_level', 'low')
406
+ payload['support_status'] = record.get('support_status', 'unsupported')
407
+ return payload
408
+
409
+ if 'material_context' in record or 'dominant_ros_or_product' in record:
410
+ return record
411
+
412
+ return None
413
+
414
+ def _load_text_metadata(self) -> Dict[str, Dict[str, Any]]:
415
+ """加载所有元数据以便检索文本"""
416
+ metadata = {}
417
+
418
+ # 加载 Gold/Silver JSON 元数据
419
+ processed_dir = Path(self.config['paths']['processed_dir'])
420
+ jsonl_paths = [
421
+ processed_dir / 'json_records_cleaned.jsonl',
422
+ processed_dir / 'silver_json_validated.jsonl',
423
+ ]
424
+ for jsonl_path in jsonl_paths:
425
+ if not jsonl_path.exists():
426
+ continue
427
+
428
+ with open(jsonl_path, 'r', encoding='utf-8') as f:
429
+ for line in f:
430
+ if line.strip():
431
+ raw_record = json.loads(line)
432
+ record = self.get_schema_record(raw_record)
433
+ if not record:
434
+ continue
435
+ doc_id = record.get('source_chunk_id', raw_record.get('chunk_id', ''))
436
+ if doc_id:
437
+ metadata[doc_id] = {
438
+ 'source_type': 'gold_silver_json',
439
+ 'text': self._construct_json_text(record),
440
+ 'evidence_level': record.get('evidence_level', 'low'),
441
+ **record
442
+ }
443
+
444
+ # 加载 PDF 元数据
445
+ pdf_path = processed_dir / 'pdf_chunks.jsonl'
446
+ if pdf_path.exists():
447
+ with open(pdf_path, 'r', encoding='utf-8') as f:
448
+ for line in f:
449
+ if line.strip():
450
+ record = json.loads(line)
451
+ doc_id = record.get('chunk_id', '')
452
+ if doc_id:
453
+ metadata[doc_id] = {
454
+ 'source_type': 'pdf_fulltext',
455
+ 'text': record.get('text', ''),
456
+ **record
457
+ }
458
+
459
+ # 加载 WOS 元数据
460
+ wos_path = processed_dir / 'wos_abstract_chunks.jsonl'
461
+ if wos_path.exists():
462
+ with open(wos_path, 'r', encoding='utf-8') as f:
463
+ for line in f:
464
+ if line.strip():
465
+ record = json.loads(line)
466
+ doc_id = record.get('doc_id', '')
467
+ if doc_id:
468
+ metadata[doc_id] = {
469
+ 'source_type': 'wos_abstract',
470
+ 'text': record.get('text', ''),
471
+ **record
472
+ }
473
+
474
+ return metadata
475
+
476
+ def _construct_json_text(self, record: Dict[str, Any]) -> str:
477
+ """构造 JSON 记录的文本表示"""
478
+ parts = []
479
+
480
+ material = record.get('material_context', {})
481
+ if material.get('material_type'):
482
+ parts.append(f"Material: {material['material_type']}")
483
+ if material.get('active_site'):
484
+ parts.append(f"Active site: {material['active_site']}")
485
+
486
+ env = record.get('reaction_environment', {})
487
+ if env.get('phase'):
488
+ parts.append(f"Phase: {env['phase']}")
489
+ if env.get('oxidant_source'):
490
+ parts.append(f"Oxidant: {env['oxidant_source']}")
491
+ if env.get('driving_force'):
492
+ parts.append(f"Driving: {env['driving_force']}")
493
+
494
+ for item in record.get('dominant_ros_or_product', []):
495
+ if item.get('species'):
496
+ parts.append(f"ROS: {item['species']}")
497
+
498
+ for step in record.get('mechanism', []):
499
+ if step:
500
+ parts.append(f"Mechanism: {step}")
501
+
502
+ return ' '.join(parts)
503
+
504
+ def _load_metadata_dict(self) -> Dict[str, Dict[str, Any]]:
505
+ """加载所有元数据为字典"""
506
+ if hasattr(self, '_metadata_dict_cache'):
507
+ return self._metadata_dict_cache
508
+
509
+ metadata = self._load_text_metadata()
510
+
511
+ self._metadata_dict_cache = metadata
512
+ return metadata
513
+
514
+ @staticmethod
515
+ def _truncate_at_sentence(text: str, max_chars: int = 500) -> str:
516
+ """Truncate *text* at the last sentence boundary before *max_chars*."""
517
+ if len(text) <= max_chars:
518
+ return text
519
+ # Find the last sentence terminator within the limit.
520
+ snippet = text[:max_chars]
521
+ for sep in ('. ', '.\n', '? ', '?\n', '! ', '!\n', '。', ';'):
522
+ idx = snippet.rfind(sep)
523
+ if idx > max_chars * 0.5: # don't cut too early
524
+ return snippet[:idx + len(sep.rstrip())]
525
+ # Fallback: truncate at the last space.
526
+ last_space = snippet.rfind(' ')
527
+ if last_space > max_chars * 0.5:
528
+ return snippet[:last_space]
529
+ return snippet
530
+
531
+ def normalize_ros(self, name: str) -> str:
532
+ """标准化 ROS 名称"""
533
+ if not name:
534
+ return ""
535
+ name = name.strip()
536
+ return self.ros_normalization.get(name, self.ros_normalization.get(name.lower(), name))
537
+
538
+ def tokenize(self, text: str) -> List[str]:
539
+ """Use the same tokenizer as index construction."""
540
+ return tokenize_search_text(text)
541
+
542
+ def is_confident_result(self, result: Dict[str, Any]) -> bool:
543
+ """Return whether a text result clears at least one retrieval confidence gate."""
544
+ return (
545
+ result.get('bm25_score', 0.0) >= self.min_bm25_score
546
+ or result.get('vector_score', 0.0) >= self.min_vector_score
547
+ )
548
+
549
+ def _is_confident_result(self, result: Dict[str, Any]) -> bool:
550
+ """Backward-compatible alias for older callers/tests."""
551
+ return self.is_confident_result(result)
552
+
553
+ def hybrid_retrieve(self, query: str, top_k: int = 10) -> List[Dict[str, Any]]:
554
+ """混合检索"""
555
+ if not self.is_domain_query(query):
556
+ return []
557
+
558
+ results = []
559
+
560
+ # 1. 向量检索
561
+ vector_results = self._vector_search(query, self.vector_top_k)
562
+
563
+ # 2. BM25 检索
564
+ bm25_results = self._bm25_search(query, self.bm25_top_k)
565
+
566
+ # 3. 合并结果
567
+ combined = self._merge_results(vector_results, bm25_results)
568
+
569
+ # 4. 重排
570
+ reranked = self._rerank_results(combined, query)
571
+
572
+ # 5. 丢弃低置信度结果,避免无关问题也被强制注入 RAG 上下文
573
+ confident = [result for result in reranked if self.is_confident_result(result)]
574
+ return confident[:top_k]
575
+
576
+ def _vector_search(self, query: str, top_k: int) -> List[Tuple[int, float]]:
577
+ """向量搜索"""
578
+ if self.faiss_index is None or SentenceTransformer is None:
579
+ return []
580
+
581
+ try:
582
+ # 嵌入查询
583
+ embedding_model = self.config.get('models', {}).get('embedding_model', '')
584
+ if not embedding_model:
585
+ return []
586
+
587
+ model = SentenceTransformer(embedding_model)
588
+ query_vec = model.encode([query], normalize_embeddings=True).astype('float32')
589
+
590
+ # 搜索
591
+ D, I = self.faiss_index.search(query_vec, top_k)
592
+
593
+ results = []
594
+ for i, idx in enumerate(I[0]):
595
+ if idx >= 0 and idx < len(self.faiss_metadata):
596
+ results.append((idx, float(D[0][i])))
597
+
598
+ return results
599
+
600
+ except Exception as e:
601
+ logging.error(f"Vector search error: {e}")
602
+ return []
603
+
604
+ def _bm25_search(self, query: str, top_k: int) -> List[Tuple[int, float]]:
605
+ """BM25 搜索"""
606
+ if BM25Okapi is None or not self.bm25_metadata:
607
+ return []
608
+
609
+ try:
610
+ # 分词查询
611
+ query_tokens = self.tokenize(query)
612
+
613
+ # 计算 BM25 得分
614
+ scores = self.bm25_index.get_scores(query_tokens)
615
+
616
+ # 排序
617
+ results = [(i, float(score)) for i, score in enumerate(scores)]
618
+ results.sort(key=lambda x: x[1], reverse=True)
619
+
620
+ return results[:top_k]
621
+
622
+ except Exception as e:
623
+ logging.error(f"BM25 search error: {e}")
624
+ return []
625
+
626
+ def _merge_results(self, vector_results: List[Tuple[int, float]],
627
+ bm25_results: List[Tuple[int, float]]) -> List[Dict[str, Any]]:
628
+ """合并检索结果,对 BM25 分数做归一化后再组合。"""
629
+ merged = {}
630
+ metadata_dict = self._load_metadata_dict()
631
+
632
+ # Normalise BM25 scores to [0, 1] so they combine fairly with vector scores.
633
+ bm25_max = max((s for _, s in bm25_results), default=1.0)
634
+ bm25_max = max(bm25_max, 1.0) # avoid division by zero
635
+
636
+ # 处理向量结果
637
+ for idx, score in vector_results:
638
+ if idx < len(self.faiss_metadata):
639
+ meta = self.faiss_metadata[idx]
640
+ doc_id = meta.get('doc_id') or f"faiss_{idx}"
641
+
642
+ if doc_id not in merged:
643
+ merged[doc_id] = {
644
+ 'doc_id': doc_id,
645
+ 'vector_score': score,
646
+ 'bm25_score': 0.0,
647
+ 'combined_score': score,
648
+ 'metadata': meta
649
+ }
650
+ else:
651
+ merged[doc_id]['vector_score'] = score
652
+ merged[doc_id]['combined_score'] += score
653
+
654
+ # 处理 BM25 结果
655
+ for idx, score in bm25_results:
656
+ if idx < len(self.bm25_metadata):
657
+ meta = self.bm25_metadata[idx]
658
+ doc_id = meta.get('doc_id') or f"bm25_{idx}"
659
+ norm_score = score / bm25_max
660
+
661
+ if doc_id not in merged:
662
+ merged[doc_id] = {
663
+ 'doc_id': doc_id,
664
+ 'vector_score': 0.0,
665
+ 'bm25_score': score,
666
+ 'combined_score': norm_score,
667
+ 'metadata': meta
668
+ }
669
+ else:
670
+ merged[doc_id]['bm25_score'] = score
671
+ merged[doc_id]['combined_score'] += norm_score
672
+
673
+ # 添加来自 metadata_dict 的完整数据
674
+ for doc_id, result in merged.items():
675
+ if doc_id in metadata_dict:
676
+ result['full_data'] = metadata_dict[doc_id]
677
+
678
+ return list(merged.values())
679
+
680
+ def _rerank_results(self, results: List[Dict[str, Any]], query: str) -> List[Dict[str, Any]]:
681
+ """重排结果"""
682
+ query_lower = query.lower()
683
+ query_tokens = set(self.tokenize(query))
684
+
685
+ # 提取查询中的关键词
686
+ ros_keywords = ['·oh', 'o2·-', '1o2', 'h2o2', 'so4·-', 'hydroxyl', 'superoxide', 'singlet oxygen', 'hydrogen peroxide', 'sulfate radical']
687
+ condition_keywords = [' photocatalysis', 'light', 'pms', 'pds', '2e-', 'orr', 'electrochemical']
688
+
689
+ # 重排逻辑
690
+ def score_result(result: Dict[str, Any]) -> float:
691
+ score = result['combined_score']
692
+
693
+ # 来源优先级
694
+ metadata = result.get('metadata', {})
695
+ source_type = metadata.get('source_type', '')
696
+
697
+ source_bonus = self.source_priority.get(source_type, 1)
698
+ score += 0.1 * source_bonus
699
+
700
+ # 材料匹配
701
+ if 'full_data' in result:
702
+ full_data = result['full_data']
703
+
704
+ # 检查 ROS 匹配
705
+ requested_ros = [ros for ros in ros_keywords if ros in query_lower]
706
+ if requested_ros:
707
+ for item in full_data.get('dominant_ros_or_product', []):
708
+ species = item.get('species', '').lower()
709
+ if any(ros in species or species in ros for ros in requested_ros):
710
+ score += 2.0
711
+ break
712
+
713
+ # 检查条件匹配
714
+ env = full_data.get('reaction_environment', {})
715
+ if any(kw in query_lower for kw in condition_keywords):
716
+ if env.get('phase') or env.get('driving_force'):
717
+ score += 1.0
718
+
719
+ # 检查材料匹配
720
+ material = full_data.get('material_context', {})
721
+ material_text = material.get('material_type', '').lower()
722
+ material_tokens = set(self.tokenize(material_text))
723
+ specific_query_tokens = query_tokens - {'cof', 'cofs'}
724
+ if specific_query_tokens & material_tokens:
725
+ score += 1.5
726
+
727
+ result['rerank_score'] = score
728
+ return score
729
+
730
+ # 排序
731
+ results.sort(key=score_result, reverse=True)
732
+
733
+ return results
734
+
735
+ def retrieve_with_sources(self, query: str, top_k: int = 10) -> List[Dict[str, Any]]:
736
+ """检索并返回带完整来源信息的结果"""
737
+ results = self.hybrid_retrieve(query, top_k * 2) # 检索更多结果用于重排
738
+
739
+ # 构建返回结果
740
+ output = []
741
+ for result in results:
742
+ full_data = result.get('full_data') or result.get('metadata', {})
743
+
744
+ output.append({
745
+ 'doc_id': result['doc_id'],
746
+ 'score': result.get('rerank_score', result['combined_score']),
747
+ 'vector_score': result['vector_score'],
748
+ 'bm25_score': result['bm25_score'],
749
+ 'source_type': result['metadata'].get('source_type', 'unknown'),
750
+ 'evidence_level': full_data.get('evidence_level', 'low'),
751
+ 'material_context': full_data.get('material_context', {}),
752
+ 'reaction_environment': full_data.get('reaction_environment', {}),
753
+ 'dominant_ros_or_product': full_data.get('dominant_ros_or_product', []),
754
+ 'secondary_or_intermediate_ros': full_data.get('secondary_or_intermediate_ros', []),
755
+ 'mechanism': full_data.get('mechanism', []),
756
+ 'text': full_data.get('text', ''),
757
+ 'source_chunk_id': full_data.get('source_chunk_id', '')
758
+ })
759
+
760
+ return output[:top_k]
761
+
762
+ def _retrieve_bm25_kg_facts(self, query: str, top_k: int = 10) -> List[Dict[str, Any]]:
763
+ """Fallback: derive compact facts from confident text retrieval results."""
764
+ results = self.hybrid_retrieve(query, top_k)
765
+
766
+ kg_facts = []
767
+
768
+ for result in results:
769
+ full_data = result.get('full_data', {})
770
+ if str(full_data.get('support_status', '')).lower() == 'unsupported':
771
+ continue
772
+
773
+ material = full_data.get('material_context', {})
774
+ if material.get('material_type'):
775
+ kg_facts.append({
776
+ 'head': material.get('material_type', ''),
777
+ 'relation': 'has_condition',
778
+ 'tail': ', '.join(filter(None, [
779
+ full_data.get('reaction_environment', {}).get('phase', ''),
780
+ full_data.get('reaction_environment', {}).get('driving_force', '')
781
+ ])),
782
+ 'evidence': full_data.get('evidence_level', 'low'),
783
+ 'source': 'bm25_fallback',
784
+ })
785
+
786
+ for item in full_data.get('dominant_ros_or_product', []):
787
+ if item.get('species'):
788
+ kg_facts.append({
789
+ 'head': material.get('material_type', ''),
790
+ 'relation': 'generates',
791
+ 'tail': item.get('species', ''),
792
+ 'category': item.get('category', ''),
793
+ 'evidence': full_data.get('evidence_level', 'low'),
794
+ 'source': 'bm25_fallback',
795
+ })
796
+
797
+ return kg_facts
798
+
799
+ def retrieve_kg_facts(
800
+ self,
801
+ query: str,
802
+ top_k: int = 10,
803
+ min_confidence: Optional[float] = None,
804
+ ) -> List[Dict[str, Any]]:
805
+ """Retrieve confidence-gated KG facts from graph first, then BM25 fallback."""
806
+ threshold = getattr(self, 'min_kg_confidence', 2.0) if min_confidence is None else float(min_confidence)
807
+ graph_facts = self._retrieve_graph_kg_facts(
808
+ query,
809
+ top_k,
810
+ min_confidence=threshold,
811
+ )
812
+ if graph_facts:
813
+ return graph_facts
814
+ return self._retrieve_bm25_kg_facts(query, top_k)
815
+
816
+ def run(self):
817
+ """运行检索测试"""
818
+ logging.info("Evidence Retriever initialized")
819
+ logging.info(f"FAISS vectors: {len(self.faiss_metadata) if self.faiss_metadata else 0}")
820
+ logging.info(f"BM25 documents: {len(self.bm25_metadata) if self.bm25_metadata else 0}")
821
+
822
+
823
+ def main():
824
+ """主函数"""
825
+ import argparse
826
+
827
+ parser = argparse.ArgumentParser(description='Retrieve evidence for COF-ROS queries')
828
+ parser.add_argument('--config', type=str, default='config/config.yaml',
829
+ help='Path to config file')
830
+ parser.add_argument('--query', type=str, default=None,
831
+ help='Query to retrieve (if not provided, run in test mode)')
832
+ parser.add_argument('--top-k', type=int, default=5,
833
+ help='Number of results to return')
834
+ args = parser.parse_args()
835
+
836
+ # 加载配置
837
+ config_path = Path(args.config)
838
+ with open(config_path, 'r', encoding='utf-8') as f:
839
+ config = yaml.safe_load(f)
840
+
841
+ # 初始化检索器
842
+ retriever = EvidenceRetriever(config)
843
+
844
+ if args.query:
845
+ # 检索查询
846
+ results = retriever.retrieve_with_sources(args.query, args.top_k)
847
+
848
+ for i, result in enumerate(results, 1):
849
+ print(f"\n--- Result {i} (score: {result['score']:.3f}) ---")
850
+ print(f"Source: {result['source_type']}")
851
+ print(f"Material: {result['material_context'].get('material_type', 'N/A')}")
852
+ print(f"Dominant ROS: {[item['species'] for item in result['dominant_ros_or_product']]}")
853
+ print(f"Text preview: {result['text'][:200]}...")
854
+ else:
855
+ retriever.run()
856
+
857
+
858
+ if __name__ == '__main__':
859
+ main()