@lzhzzzzwill/cofos 1.0.4 → 1.1.0
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.
- package/README.md +36 -10
- package/RUNTIME.md +38 -0
- package/bin/cofos.js +80 -20
- package/package.json +13 -5
- package/requirements.txt +0 -3
- package/runtime/config/config.yaml +73 -260
- package/runtime/config/prompt_templates.yaml +73 -369
- package/runtime/src/data_processing/__init__.py +2 -27
- package/runtime/src/data_processing/utils.py +22 -1
- package/runtime/src/inference/__init__.py +2 -6
- package/runtime/src/inference/query_router.py +23 -11
- package/runtime/src/inference/student_adapter_inference.py +312 -61
- package/runtime/src/retrieval/__init__.py +14 -7
- package/runtime/src/retrieval/retrieve_evidence.py +84 -25
- package/scripts/chat.py +88 -25
- package/scripts/sync_runtime.py +45 -0
- package/runtime/config/qwen3_5_config.md +0 -46
- package/runtime/src/data_processing/clean_json_records.py +0 -387
- package/runtime/src/data_processing/parse_wos_xlsx.py +0 -255
- package/runtime/src/data_processing/schema_utils.py +0 -56
- package/runtime/src/inference/run_kg_grounded_inference.py +0 -370
- package/runtime/src/retrieval/build_faiss_index.py +0 -328
|
@@ -1,370 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
COF-ROS-Reasoner: KG-RAG 推理 demo
|
|
4
|
-
|
|
5
|
-
目标:
|
|
6
|
-
- 实现基于知识图谱和检索的问答推理
|
|
7
|
-
- 输出必须包含:主导物种、辅助物种、反应条件、证据依据、机理解释、不确定性
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
import os
|
|
11
|
-
import json
|
|
12
|
-
import yaml
|
|
13
|
-
import logging
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
from typing import Dict, List, Any, Optional
|
|
16
|
-
from datetime import datetime
|
|
17
|
-
|
|
18
|
-
# 添加 src 路径
|
|
19
|
-
|
|
20
|
-
try:
|
|
21
|
-
from openai import OpenAI
|
|
22
|
-
except ImportError:
|
|
23
|
-
OpenAI = None
|
|
24
|
-
|
|
25
|
-
try:
|
|
26
|
-
import requests
|
|
27
|
-
except ImportError:
|
|
28
|
-
requests = None
|
|
29
|
-
|
|
30
|
-
try:
|
|
31
|
-
from sentence_transformers import SentenceTransformer
|
|
32
|
-
except ImportError:
|
|
33
|
-
SentenceTransformer = None
|
|
34
|
-
|
|
35
|
-
try:
|
|
36
|
-
import faiss
|
|
37
|
-
except ImportError:
|
|
38
|
-
faiss = None
|
|
39
|
-
|
|
40
|
-
try:
|
|
41
|
-
from rank_bm25 import BM25Okapi
|
|
42
|
-
except ImportError:
|
|
43
|
-
BM25Okapi = None
|
|
44
|
-
|
|
45
|
-
from retrieval.retrieve_evidence import EvidenceRetriever
|
|
46
|
-
from data_processing.utils import setup_logging, ensure_directory
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
class KGRAGInference:
|
|
50
|
-
"""KG-RAG 推理"""
|
|
51
|
-
|
|
52
|
-
def __init__(self, config: Dict[str, Any]):
|
|
53
|
-
self.config = config
|
|
54
|
-
self.retriever = None
|
|
55
|
-
|
|
56
|
-
# 设置日志
|
|
57
|
-
setup_logging(config)
|
|
58
|
-
|
|
59
|
-
# 推理配置
|
|
60
|
-
self.temperature = config.get('generation', {}).get('temperature', 0.2)
|
|
61
|
-
self.max_new_tokens = config.get('generation', {}).get('max_new_tokens', 1024)
|
|
62
|
-
llm_config = config.get('llm', {})
|
|
63
|
-
self.llm_provider = llm_config.get('provider', 'ollama')
|
|
64
|
-
self.generate_url = os.environ.get(
|
|
65
|
-
'COFROS_LLM_URL',
|
|
66
|
-
llm_config.get('generate_url', 'http://localhost:11434/api/generate')
|
|
67
|
-
)
|
|
68
|
-
self.model_name = (
|
|
69
|
-
os.environ.get('COFROS_LLM_MODEL')
|
|
70
|
-
or os.environ.get('COFROS_INFERENCE_MODEL')
|
|
71
|
-
or llm_config.get('inference_model')
|
|
72
|
-
or llm_config.get('teacher_model')
|
|
73
|
-
or llm_config.get('model')
|
|
74
|
-
or config.get('models', {}).get('teacher_model', 'qwen3.6:35b')
|
|
75
|
-
)
|
|
76
|
-
self.timeout = llm_config.get('timeout', 120)
|
|
77
|
-
|
|
78
|
-
# 加载检索器
|
|
79
|
-
self._load_retriever()
|
|
80
|
-
|
|
81
|
-
# 加载 KG facts
|
|
82
|
-
self.kg_facts = self._load_kg_facts()
|
|
83
|
-
|
|
84
|
-
# 加载 prompt 模板
|
|
85
|
-
self.system_prompt = self._load_system_prompt()
|
|
86
|
-
self.user_prompt_template = self._load_user_prompt_template()
|
|
87
|
-
|
|
88
|
-
def _load_retriever(self):
|
|
89
|
-
"""加载检索器"""
|
|
90
|
-
try:
|
|
91
|
-
self.retriever = EvidenceRetriever(self.config)
|
|
92
|
-
logging.info("Retriever loaded successfully")
|
|
93
|
-
except Exception as e:
|
|
94
|
-
logging.error(f"Failed to load retriever: {e}")
|
|
95
|
-
|
|
96
|
-
def _load_kg_facts(self) -> List[Dict[str, Any]]:
|
|
97
|
-
"""加载 KG facts"""
|
|
98
|
-
kg_path = Path(self.config['paths']['kg_dir']) / 'cofros_kg_triples.jsonl'
|
|
99
|
-
facts = []
|
|
100
|
-
|
|
101
|
-
if kg_path.exists():
|
|
102
|
-
try:
|
|
103
|
-
with open(kg_path, 'r', encoding='utf-8') as f:
|
|
104
|
-
for line in f:
|
|
105
|
-
if line.strip():
|
|
106
|
-
facts.append(json.loads(line))
|
|
107
|
-
logging.info(f"Loaded {len(facts)} KG facts")
|
|
108
|
-
except Exception as e:
|
|
109
|
-
logging.error(f"Failed to load KG facts: {e}")
|
|
110
|
-
|
|
111
|
-
return facts
|
|
112
|
-
|
|
113
|
-
def _load_system_prompt(self) -> str:
|
|
114
|
-
"""加载系统提示"""
|
|
115
|
-
prompt_path = Path('config/prompt_templates.yaml')
|
|
116
|
-
if not prompt_path.exists():
|
|
117
|
-
raise FileNotFoundError(f"Prompt template file not found: {prompt_path}")
|
|
118
|
-
with open(prompt_path, 'r', encoding='utf-8') as f:
|
|
119
|
-
prompt_config = yaml.safe_load(f) or {}
|
|
120
|
-
prompt = prompt_config.get('kg_rag_system_prompt')
|
|
121
|
-
if not prompt:
|
|
122
|
-
raise ValueError(f"kg_rag_system_prompt is missing from {prompt_path}")
|
|
123
|
-
return prompt
|
|
124
|
-
|
|
125
|
-
def _load_user_prompt_template(self) -> str:
|
|
126
|
-
"""加载用户提示模板"""
|
|
127
|
-
prompt_path = Path('config/prompt_templates.yaml')
|
|
128
|
-
if not prompt_path.exists():
|
|
129
|
-
raise FileNotFoundError(f"Prompt template file not found: {prompt_path}")
|
|
130
|
-
with open(prompt_path, 'r', encoding='utf-8') as f:
|
|
131
|
-
prompt_config = yaml.safe_load(f) or {}
|
|
132
|
-
prompt = prompt_config.get('kg_rag_user_prompt')
|
|
133
|
-
if not prompt:
|
|
134
|
-
raise ValueError(f"kg_rag_user_prompt is missing from {prompt_path}")
|
|
135
|
-
return prompt
|
|
136
|
-
|
|
137
|
-
def format_kg_facts(self, kg_facts: List[Dict[str, Any]], max_items: int = 10) -> str:
|
|
138
|
-
"""格式化 KG facts,标注可信度。"""
|
|
139
|
-
if not kg_facts:
|
|
140
|
-
return "No relevant knowledge graph facts found."
|
|
141
|
-
|
|
142
|
-
support_icon = {
|
|
143
|
-
'supported': '✓',
|
|
144
|
-
'partially_supported': '~',
|
|
145
|
-
'unsupported': '?',
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
formatted = []
|
|
149
|
-
for fact in kg_facts[:max_items]:
|
|
150
|
-
head = fact.get('head', '')
|
|
151
|
-
relation = fact.get('relation', '')
|
|
152
|
-
tail = fact.get('tail', '')
|
|
153
|
-
tail_category = fact.get('tail_category', '')
|
|
154
|
-
|
|
155
|
-
line = f"- {head} {relation} {tail}"
|
|
156
|
-
if tail_category:
|
|
157
|
-
line += f" ({tail_category})"
|
|
158
|
-
|
|
159
|
-
condition = fact.get('condition') or {}
|
|
160
|
-
if isinstance(condition, dict):
|
|
161
|
-
condition_text = '; '.join(
|
|
162
|
-
str(value) for value in [
|
|
163
|
-
condition.get('phase'),
|
|
164
|
-
condition.get('oxidant_source'),
|
|
165
|
-
condition.get('driving_force'),
|
|
166
|
-
]
|
|
167
|
-
if value
|
|
168
|
-
)
|
|
169
|
-
if condition_text:
|
|
170
|
-
line += f" | condition: {condition_text}"
|
|
171
|
-
|
|
172
|
-
evidence = fact.get('evidence') or {}
|
|
173
|
-
if isinstance(evidence, dict):
|
|
174
|
-
status = evidence.get('support_status') or ''
|
|
175
|
-
level = evidence.get('evidence_level') or ''
|
|
176
|
-
evidence_text = ' '.join(filter(None, [status, level]))
|
|
177
|
-
icon = support_icon.get(status, '')
|
|
178
|
-
if icon:
|
|
179
|
-
evidence_text = f"{icon} {evidence_text}"
|
|
180
|
-
else:
|
|
181
|
-
evidence_text = str(evidence)
|
|
182
|
-
if evidence_text:
|
|
183
|
-
line += f" | {evidence_text}"
|
|
184
|
-
|
|
185
|
-
formatted.append(line)
|
|
186
|
-
|
|
187
|
-
return '\n'.join(formatted)
|
|
188
|
-
|
|
189
|
-
@staticmethod
|
|
190
|
-
def _truncate_at_sentence(text: str, max_chars: int = 500) -> str:
|
|
191
|
-
"""Truncate *text* at the last sentence boundary before *max_chars*."""
|
|
192
|
-
if len(text) <= max_chars:
|
|
193
|
-
return text
|
|
194
|
-
snippet = text[:max_chars]
|
|
195
|
-
for sep in ('. ', '.\n', '? ', '?\n', '! ', '!\n', '。', ';'):
|
|
196
|
-
idx = snippet.rfind(sep)
|
|
197
|
-
if idx > max_chars * 0.5:
|
|
198
|
-
return snippet[:idx + len(sep.rstrip())]
|
|
199
|
-
last_space = snippet.rfind(' ')
|
|
200
|
-
if last_space > max_chars * 0.5:
|
|
201
|
-
return snippet[:last_space]
|
|
202
|
-
return snippet
|
|
203
|
-
|
|
204
|
-
def format_evidence(self, evidence_list: List[Dict[str, Any]], max_items: int = 10) -> str:
|
|
205
|
-
"""格式化证据,标注可信度等级和验证状态。"""
|
|
206
|
-
if not evidence_list:
|
|
207
|
-
return "No relevant evidence found."
|
|
208
|
-
|
|
209
|
-
level_badge = {
|
|
210
|
-
'high': '[HIGH confidence]',
|
|
211
|
-
'medium': '[MEDIUM confidence]',
|
|
212
|
-
'low': '[LOW confidence]',
|
|
213
|
-
}
|
|
214
|
-
support_badge = {
|
|
215
|
-
'supported': 'verified',
|
|
216
|
-
'partially_supported': 'partial',
|
|
217
|
-
'unsupported': 'unverified',
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
formatted = []
|
|
221
|
-
for i, evidence in enumerate(evidence_list[:max_items], 1):
|
|
222
|
-
source_type = evidence.get('source_type', 'unknown')
|
|
223
|
-
material = evidence.get('material_context', {}).get('material_type', 'N/A')
|
|
224
|
-
dominant = [item.get('species', 'N/A') for item in evidence.get('dominant_ros_or_product', [])]
|
|
225
|
-
evidence_level = evidence.get('evidence_level', 'low')
|
|
226
|
-
support_status = evidence.get('support_status', 'unsupported')
|
|
227
|
-
|
|
228
|
-
level_str = level_badge.get(evidence_level, evidence_level)
|
|
229
|
-
support_str = support_badge.get(support_status, support_status)
|
|
230
|
-
text = self._truncate_at_sentence(evidence.get('text', ''), max_chars=500)
|
|
231
|
-
|
|
232
|
-
formatted.append(
|
|
233
|
-
f"[{i}] {source_type} {level_str} ({support_str})\n"
|
|
234
|
-
f" Material: {material}\n"
|
|
235
|
-
f" Dominant ROS: {', '.join(dominant)}\n"
|
|
236
|
-
f" {text}"
|
|
237
|
-
)
|
|
238
|
-
return '\n\n'.join(formatted)
|
|
239
|
-
|
|
240
|
-
def call_llm(self, system_prompt: str, user_prompt: str) -> str:
|
|
241
|
-
"""调用本地/内网 LLM。"""
|
|
242
|
-
if self.llm_provider != 'ollama':
|
|
243
|
-
raise ValueError(f"Unsupported LLM provider: {self.llm_provider}")
|
|
244
|
-
|
|
245
|
-
if requests is None:
|
|
246
|
-
raise ImportError("requests required. Run: pip install requests")
|
|
247
|
-
|
|
248
|
-
prompt = f"{system_prompt.strip()}\n\n{user_prompt.strip()}"
|
|
249
|
-
payload = {
|
|
250
|
-
"model": self.model_name,
|
|
251
|
-
"prompt": prompt,
|
|
252
|
-
"stream": False,
|
|
253
|
-
"options": {
|
|
254
|
-
"temperature": self.temperature,
|
|
255
|
-
"top_p": self.config.get('generation', {}).get('top_p', 0.9),
|
|
256
|
-
"num_predict": self.max_new_tokens,
|
|
257
|
-
},
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
response = requests.post(self.generate_url, json=payload, timeout=self.timeout)
|
|
261
|
-
response.raise_for_status()
|
|
262
|
-
data = response.json()
|
|
263
|
-
return data.get("response", "").strip()
|
|
264
|
-
|
|
265
|
-
def answer_question(self, question: str, top_k: int = 5) -> Dict[str, Any]:
|
|
266
|
-
"""回答问题"""
|
|
267
|
-
result = {
|
|
268
|
-
'question': question,
|
|
269
|
-
'timestamp': datetime.now().isoformat()
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
if self.retriever is None:
|
|
273
|
-
result['kg_facts'] = []
|
|
274
|
-
result['evidence'] = []
|
|
275
|
-
result['answer'] = "Evidence retrieval is unavailable. Build retrieval indexes before running KG-RAG inference."
|
|
276
|
-
return result
|
|
277
|
-
|
|
278
|
-
# 1. 检索 KG facts
|
|
279
|
-
kg_facts = self.retriever.retrieve_kg_facts(question, top_k)
|
|
280
|
-
result['kg_facts'] = kg_facts
|
|
281
|
-
|
|
282
|
-
# 2. 检索文献证据
|
|
283
|
-
evidence = self.retriever.retrieve_with_sources(question, top_k)
|
|
284
|
-
result['evidence'] = evidence
|
|
285
|
-
|
|
286
|
-
# 3. 构造 prompt
|
|
287
|
-
kg_facts_str = self.format_kg_facts(kg_facts)
|
|
288
|
-
evidence_str = self.format_evidence(evidence)
|
|
289
|
-
|
|
290
|
-
user_prompt = self.user_prompt_template.format(
|
|
291
|
-
question=question,
|
|
292
|
-
kg_facts=kg_facts_str,
|
|
293
|
-
evidence=evidence_str
|
|
294
|
-
)
|
|
295
|
-
|
|
296
|
-
# 4. 调用 LLM
|
|
297
|
-
answer = self.call_llm(self.system_prompt, user_prompt)
|
|
298
|
-
result['answer'] = answer
|
|
299
|
-
|
|
300
|
-
return result
|
|
301
|
-
|
|
302
|
-
def run_demo(self):
|
|
303
|
-
"""运行 demo"""
|
|
304
|
-
logging.info("KG-RAG Inference Demo")
|
|
305
|
-
logging.info("=" * 50)
|
|
306
|
-
|
|
307
|
-
# 从 prompt_templates.yaml 加载 demo 问题
|
|
308
|
-
import yaml
|
|
309
|
-
prompt_path = Path("config/prompt_templates.yaml")
|
|
310
|
-
test_questions = [
|
|
311
|
-
"What ROS does TT-T-COF generate under visible light photocatalysis?",
|
|
312
|
-
"Which COF materials are reported to generate H2O2 as the main product?",
|
|
313
|
-
"What is the mechanism of ROS generation in donor-acceptor COFs?",
|
|
314
|
-
]
|
|
315
|
-
if prompt_path.exists():
|
|
316
|
-
with open(prompt_path, "r", encoding="utf-8") as f:
|
|
317
|
-
prompt_config = yaml.safe_load(f)
|
|
318
|
-
test_questions = prompt_config.get("demo", {}).get("questions", test_questions)
|
|
319
|
-
|
|
320
|
-
for question in test_questions:
|
|
321
|
-
print(f"\n{'=' * 50}")
|
|
322
|
-
print(f"Question: {question}")
|
|
323
|
-
print("=" * 50)
|
|
324
|
-
|
|
325
|
-
result = self.answer_question(question)
|
|
326
|
-
|
|
327
|
-
print(f"\nAnswer:")
|
|
328
|
-
print(result['answer'])
|
|
329
|
-
|
|
330
|
-
def run(self):
|
|
331
|
-
"""运行推理服务"""
|
|
332
|
-
logging.info("KG-RAG Inference initialized")
|
|
333
|
-
logging.info(f"KG facts loaded: {len(self.kg_facts)}")
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
def main():
|
|
337
|
-
"""主函数"""
|
|
338
|
-
import argparse
|
|
339
|
-
|
|
340
|
-
parser = argparse.ArgumentParser(description='Run KG-RAG inference for COF-ROS questions')
|
|
341
|
-
parser.add_argument('--config', type=str, default='config/config.yaml',
|
|
342
|
-
help='Path to config file')
|
|
343
|
-
parser.add_argument('--question', type=str, default=None,
|
|
344
|
-
help='Single question to answer')
|
|
345
|
-
parser.add_argument('--demo', action='store_true',
|
|
346
|
-
help='Run demo mode')
|
|
347
|
-
args = parser.parse_args()
|
|
348
|
-
|
|
349
|
-
# 加载配置
|
|
350
|
-
config_path = Path(args.config)
|
|
351
|
-
with open(config_path, 'r', encoding='utf-8') as f:
|
|
352
|
-
config = yaml.safe_load(f)
|
|
353
|
-
|
|
354
|
-
# 初始化推理器
|
|
355
|
-
inference = KGRAGInference(config)
|
|
356
|
-
|
|
357
|
-
if args.question:
|
|
358
|
-
# 单个问题
|
|
359
|
-
result = inference.answer_question(args.question)
|
|
360
|
-
print(f"\nQuestion: {args.question}")
|
|
361
|
-
print(f"\nAnswer:\n{result['answer']}")
|
|
362
|
-
elif args.demo:
|
|
363
|
-
# Demo 模式
|
|
364
|
-
inference.run_demo()
|
|
365
|
-
else:
|
|
366
|
-
inference.run()
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
if __name__ == '__main__':
|
|
370
|
-
main()
|
|
@@ -1,328 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
COF-ROS-Reasoner: FAISS 向量索引构建脚本
|
|
4
|
-
|
|
5
|
-
目标:
|
|
6
|
-
- 构建 FAISS 向量索引用于相似性检索
|
|
7
|
-
- 支持来自 JSON evidence、PDF chunks、WOS abstracts 的文本
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
import os
|
|
11
|
-
import json
|
|
12
|
-
import yaml
|
|
13
|
-
import logging
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
from typing import Dict, List, Any, Optional
|
|
16
|
-
from datetime import datetime
|
|
17
|
-
|
|
18
|
-
import numpy as np
|
|
19
|
-
|
|
20
|
-
# 添加 src 路径
|
|
21
|
-
|
|
22
|
-
try:
|
|
23
|
-
from sentence_transformers import SentenceTransformer
|
|
24
|
-
except ImportError:
|
|
25
|
-
SentenceTransformer = None
|
|
26
|
-
|
|
27
|
-
try:
|
|
28
|
-
import faiss
|
|
29
|
-
except ImportError:
|
|
30
|
-
faiss = None
|
|
31
|
-
|
|
32
|
-
from data_processing.utils import setup_logging, ensure_directory
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
class FAISSIndexBuilder:
|
|
36
|
-
"""构建 FAISS 向量索引"""
|
|
37
|
-
|
|
38
|
-
def __init__(self, config: Dict[str, Any]):
|
|
39
|
-
self.config = config
|
|
40
|
-
self.output_dir = Path(config['paths']['retrieval_store'])
|
|
41
|
-
|
|
42
|
-
# 设置日志
|
|
43
|
-
setup_logging(config)
|
|
44
|
-
|
|
45
|
-
# 嵌入模型配置
|
|
46
|
-
self.embedding_model_name = config.get('models', {}).get('embedding_model', '')
|
|
47
|
-
|
|
48
|
-
# 加载嵌入模型
|
|
49
|
-
self._load_embedding_model()
|
|
50
|
-
|
|
51
|
-
# 索引参数
|
|
52
|
-
self.dimension = 1024 # BGE-M3 的维度
|
|
53
|
-
self.index = None
|
|
54
|
-
|
|
55
|
-
# 存储元数据
|
|
56
|
-
self.metadata: List[Dict[str, Any]] = []
|
|
57
|
-
|
|
58
|
-
def _load_embedding_model(self):
|
|
59
|
-
"""加载嵌入模型"""
|
|
60
|
-
if not self.embedding_model_name:
|
|
61
|
-
raise ValueError(
|
|
62
|
-
"FAISS is enabled but models.embedding_model is empty. "
|
|
63
|
-
"Set it to a local embedding model directory, or keep retrieval.enable_faiss=false."
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
if SentenceTransformer is None:
|
|
67
|
-
logging.error("sentence-transformers not installed. Run: pip install sentence-transformers")
|
|
68
|
-
raise ImportError("sentence-transformers required")
|
|
69
|
-
|
|
70
|
-
logging.info(f"Loading embedding model: {self.embedding_model_name}")
|
|
71
|
-
try:
|
|
72
|
-
self.model = SentenceTransformer(self.embedding_model_name)
|
|
73
|
-
except Exception as exc:
|
|
74
|
-
message = (
|
|
75
|
-
f"Failed to load embedding model '{self.embedding_model_name}'. "
|
|
76
|
-
"If this machine is offline, download the model on a machine with network access "
|
|
77
|
-
"and set models.embedding_model in config/config.yaml to the local model directory."
|
|
78
|
-
)
|
|
79
|
-
logging.error(message)
|
|
80
|
-
raise RuntimeError(message) from exc
|
|
81
|
-
|
|
82
|
-
# 获取模型维度
|
|
83
|
-
try:
|
|
84
|
-
self.dimension = self.model.get_sentence_embedding_dimension()
|
|
85
|
-
except Exception:
|
|
86
|
-
self.dimension = 1024
|
|
87
|
-
|
|
88
|
-
logging.info(f"Model dimension: {self.dimension}")
|
|
89
|
-
|
|
90
|
-
def create_index(self):
|
|
91
|
-
"""创建 FAISS 索引"""
|
|
92
|
-
if faiss is None:
|
|
93
|
-
logging.error("faiss not installed. Run: pip install faiss-cpu")
|
|
94
|
-
raise ImportError("faiss required")
|
|
95
|
-
|
|
96
|
-
# 使用 IVFFlat 索引
|
|
97
|
-
nlist = 100 # 簇的数量
|
|
98
|
-
self.index = faiss.index_factory(self.dimension, f"IVF{nlist},Flat", faiss.METRIC_INNER_PRODUCT)
|
|
99
|
-
|
|
100
|
-
# 训练索引(需要一些样本)
|
|
101
|
-
logging.info("Training FAISS index...")
|
|
102
|
-
# 使用随机样本训练
|
|
103
|
-
samples = np.random.randn(1000, self.dimension).astype('float32')
|
|
104
|
-
self.index.train(samples)
|
|
105
|
-
|
|
106
|
-
logging.info("FAISS index created")
|
|
107
|
-
|
|
108
|
-
def embed_texts(self, texts: List[str]) -> np.ndarray:
|
|
109
|
-
"""嵌入文本列表"""
|
|
110
|
-
embeddings = self.model.encode(texts, normalize_embeddings=True)
|
|
111
|
-
return embeddings.astype('float32')
|
|
112
|
-
|
|
113
|
-
def add_texts(self, texts: List[str], metadata: List[Dict[str, Any]]):
|
|
114
|
-
"""添加文本到索引"""
|
|
115
|
-
if len(texts) == 0:
|
|
116
|
-
return
|
|
117
|
-
|
|
118
|
-
logging.info(f"Adding {len(texts)} texts to index...")
|
|
119
|
-
|
|
120
|
-
# 嵌入文本
|
|
121
|
-
embeddings = self.embed_texts(texts)
|
|
122
|
-
|
|
123
|
-
# 添加到 FAISS 索引
|
|
124
|
-
self.index.add(embeddings)
|
|
125
|
-
|
|
126
|
-
# 保存元数据
|
|
127
|
-
self.metadata.extend(metadata)
|
|
128
|
-
|
|
129
|
-
logging.info(f"Added {len(texts)} vectors. Total: {self.index.ntotal}")
|
|
130
|
-
|
|
131
|
-
def save_index(self):
|
|
132
|
-
"""保存 FAISS 索引和元数据"""
|
|
133
|
-
ensure_directory(self.output_dir)
|
|
134
|
-
|
|
135
|
-
# 保存 FAISS 索引
|
|
136
|
-
index_path = self.output_dir / 'faiss_index.bin'
|
|
137
|
-
faiss.write_index(self.index, str(index_path))
|
|
138
|
-
logging.info(f"FAISS index saved to {index_path}")
|
|
139
|
-
|
|
140
|
-
# 保存元数据
|
|
141
|
-
metadata_path = self.output_dir / 'faiss_metadata.jsonl'
|
|
142
|
-
with open(metadata_path, 'w', encoding='utf-8') as f:
|
|
143
|
-
for meta in self.metadata:
|
|
144
|
-
f.write(json.dumps(meta, ensure_ascii=False) + '\n')
|
|
145
|
-
logging.info(f"Metadata saved to {metadata_path}")
|
|
146
|
-
|
|
147
|
-
def build_from_json_records(self, jsonl_path: Path):
|
|
148
|
-
"""从 JSON 记录构建索引"""
|
|
149
|
-
logging.info(f"Building index from {jsonl_path}")
|
|
150
|
-
|
|
151
|
-
texts = []
|
|
152
|
-
metadata = []
|
|
153
|
-
|
|
154
|
-
if not jsonl_path.exists():
|
|
155
|
-
logging.warning(f"File not found: {jsonl_path}")
|
|
156
|
-
return
|
|
157
|
-
|
|
158
|
-
with open(jsonl_path, 'r', encoding='utf-8') as f:
|
|
159
|
-
for line in f:
|
|
160
|
-
if line.strip():
|
|
161
|
-
record = json.loads(line)
|
|
162
|
-
payload = record.get('extracted_json')
|
|
163
|
-
if isinstance(payload, dict) and payload.get('status') != 'insufficient_evidence':
|
|
164
|
-
source_record = payload
|
|
165
|
-
else:
|
|
166
|
-
source_record = record
|
|
167
|
-
|
|
168
|
-
# 构造文本
|
|
169
|
-
text_parts = []
|
|
170
|
-
|
|
171
|
-
# 来自 JSON evidence
|
|
172
|
-
dominant = source_record.get('dominant_ros_or_product', [])
|
|
173
|
-
for item in dominant:
|
|
174
|
-
species = item.get('species', '')
|
|
175
|
-
evidence = item.get('evidence', '')
|
|
176
|
-
if species:
|
|
177
|
-
text_parts.append(f"Species: {species}")
|
|
178
|
-
if evidence:
|
|
179
|
-
text_parts.append(f"Evidence: {evidence}")
|
|
180
|
-
|
|
181
|
-
secondary = source_record.get('secondary_or_intermediate_ros', [])
|
|
182
|
-
for item in secondary:
|
|
183
|
-
species = item.get('species', '')
|
|
184
|
-
evidence = item.get('evidence', '')
|
|
185
|
-
if species:
|
|
186
|
-
text_parts.append(f"Secondary: {species}")
|
|
187
|
-
if evidence:
|
|
188
|
-
text_parts.append(f"Evidence: {evidence}")
|
|
189
|
-
|
|
190
|
-
material = source_record.get('material_context', {})
|
|
191
|
-
material_type = material.get('material_type', '')
|
|
192
|
-
active_site = material.get('active_site', '')
|
|
193
|
-
|
|
194
|
-
if material_type:
|
|
195
|
-
text_parts.insert(0, f"Material: {material_type}")
|
|
196
|
-
if active_site:
|
|
197
|
-
text_parts.insert(1, f"Active site: {active_site}")
|
|
198
|
-
|
|
199
|
-
reaction_env = source_record.get('reaction_environment', {})
|
|
200
|
-
phase = reaction_env.get('phase', '')
|
|
201
|
-
oxidant = reaction_env.get('oxidant_source', '')
|
|
202
|
-
driving = reaction_env.get('driving_force', '')
|
|
203
|
-
|
|
204
|
-
if phase:
|
|
205
|
-
text_parts.append(f"Phase: {phase}")
|
|
206
|
-
if oxidant:
|
|
207
|
-
text_parts.append(f"Oxidant: {oxidant}")
|
|
208
|
-
if driving:
|
|
209
|
-
text_parts.append(f"Driving force: {driving}")
|
|
210
|
-
|
|
211
|
-
mechanism = source_record.get('mechanism', [])
|
|
212
|
-
for step in mechanism:
|
|
213
|
-
if step:
|
|
214
|
-
text_parts.append(f"Mechanism: {step}")
|
|
215
|
-
|
|
216
|
-
# 合并文本
|
|
217
|
-
text = ' '.join(text_parts)
|
|
218
|
-
if not text.strip():
|
|
219
|
-
continue
|
|
220
|
-
|
|
221
|
-
texts.append(text)
|
|
222
|
-
|
|
223
|
-
# 构造元数据
|
|
224
|
-
meta = {
|
|
225
|
-
'doc_id': record.get('source_chunk_id', record.get('chunk_id', '')),
|
|
226
|
-
'source_type': 'gold_silver_json',
|
|
227
|
-
'evidence_level': record.get('evidence_level', 'low'),
|
|
228
|
-
'text': text
|
|
229
|
-
}
|
|
230
|
-
metadata.append(meta)
|
|
231
|
-
|
|
232
|
-
logging.info(f"Loaded {len(texts)} records from {jsonl_path}")
|
|
233
|
-
self.add_texts(texts, metadata)
|
|
234
|
-
|
|
235
|
-
def build_from_chunks(self, chunks_path: Path, source_type: str = 'pdf'):
|
|
236
|
-
"""从 chunks 文件构建索引"""
|
|
237
|
-
logging.info(f"Building index from {chunks_path}")
|
|
238
|
-
|
|
239
|
-
texts = []
|
|
240
|
-
metadata = []
|
|
241
|
-
|
|
242
|
-
if not chunks_path.exists():
|
|
243
|
-
logging.warning(f"File not found: {chunks_path}")
|
|
244
|
-
return
|
|
245
|
-
|
|
246
|
-
with open(chunks_path, 'r', encoding='utf-8') as f:
|
|
247
|
-
for line in f:
|
|
248
|
-
if line.strip():
|
|
249
|
-
record = json.loads(line)
|
|
250
|
-
|
|
251
|
-
text = record.get('text', '')
|
|
252
|
-
if not text.strip():
|
|
253
|
-
continue
|
|
254
|
-
|
|
255
|
-
# 如果只添加 ROS 相关的 chunks
|
|
256
|
-
if source_type == 'pdf' and not record.get('is_ros_related', False):
|
|
257
|
-
continue
|
|
258
|
-
|
|
259
|
-
texts.append(text)
|
|
260
|
-
|
|
261
|
-
meta = {
|
|
262
|
-
'doc_id': record.get('chunk_id', ''),
|
|
263
|
-
'source_type': source_type,
|
|
264
|
-
'source': record.get('source_type', 'pdf_fulltext'),
|
|
265
|
-
'title': record.get('title', ''),
|
|
266
|
-
'doi': record.get('doi', ''),
|
|
267
|
-
'page': record.get('page', 0),
|
|
268
|
-
'is_ros_related': record.get('is_ros_related', False),
|
|
269
|
-
'text': text
|
|
270
|
-
}
|
|
271
|
-
metadata.append(meta)
|
|
272
|
-
|
|
273
|
-
logging.info(f"Loaded {len(texts)} {source_type} chunks")
|
|
274
|
-
self.add_texts(texts, metadata)
|
|
275
|
-
|
|
276
|
-
def run(self):
|
|
277
|
-
"""运行索引构建流程"""
|
|
278
|
-
logging.info("Starting FAISS index construction")
|
|
279
|
-
|
|
280
|
-
# 创建索引
|
|
281
|
-
self.create_index()
|
|
282
|
-
|
|
283
|
-
# 从 JSON 记录构建
|
|
284
|
-
gold_json_path = Path(self.config['paths']['processed_dir']) / 'json_records_cleaned.jsonl'
|
|
285
|
-
if gold_json_path.exists():
|
|
286
|
-
self.build_from_json_records(gold_json_path)
|
|
287
|
-
|
|
288
|
-
json_path = Path(self.config['paths']['processed_dir']) / 'silver_json_validated.jsonl'
|
|
289
|
-
if json_path.exists():
|
|
290
|
-
self.build_from_json_records(json_path)
|
|
291
|
-
|
|
292
|
-
# 从 PDF chunks 构建
|
|
293
|
-
pdf_path = Path(self.config['paths']['processed_dir']) / 'pdf_chunks.jsonl'
|
|
294
|
-
if pdf_path.exists():
|
|
295
|
-
self.build_from_chunks(pdf_path, 'pdf')
|
|
296
|
-
|
|
297
|
-
# 从 WoS abstracts 构建
|
|
298
|
-
wos_path = Path(self.config['paths']['processed_dir']) / 'wos_abstract_chunks.jsonl'
|
|
299
|
-
if wos_path.exists():
|
|
300
|
-
self.build_from_chunks(wos_path, 'wos')
|
|
301
|
-
|
|
302
|
-
# 保存索引
|
|
303
|
-
self.save_index()
|
|
304
|
-
|
|
305
|
-
logging.info(f"FAISS index construction completed. Total vectors: {self.index.ntotal}")
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
def main():
|
|
309
|
-
"""主函数"""
|
|
310
|
-
import argparse
|
|
311
|
-
|
|
312
|
-
parser = argparse.ArgumentParser(description='Build FAISS index for COF-ROS retrieval')
|
|
313
|
-
parser.add_argument('--config', type=str, default='config/config.yaml',
|
|
314
|
-
help='Path to config file')
|
|
315
|
-
args = parser.parse_args()
|
|
316
|
-
|
|
317
|
-
# 加载配置
|
|
318
|
-
config_path = Path(args.config)
|
|
319
|
-
with open(config_path, 'r', encoding='utf-8') as f:
|
|
320
|
-
config = yaml.safe_load(f)
|
|
321
|
-
|
|
322
|
-
# 运行构建
|
|
323
|
-
builder = FAISSIndexBuilder(config)
|
|
324
|
-
builder.run()
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
if __name__ == '__main__':
|
|
328
|
-
main()
|