@lzhzzzzwill/cofos 1.0.0 → 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,328 @@
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()