tugaa commited on
Commit
2816c7b
·
verified ·
1 Parent(s): 848bb66

Create ragsys03.py

Browse files
Files changed (1) hide show
  1. ragsys03.py +741 -0
ragsys03.py ADDED
@@ -0,0 +1,741 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from sentence_transformers import SentenceTransformer
3
+ import faiss
4
+ import time
5
+ import logging
6
+ import json
7
+ import pickle
8
+ from typing import List, Optional, Dict, Union, TypedDict, Tuple
9
+ from pathlib import Path
10
+ import hashlib
11
+ from datetime import datetime
12
+ import uuid
13
+ import os
14
+ import shutil
15
+
16
+ # ロギング設定
17
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
18
+ logger = logging.getLogger(__name__)
19
+
20
+ class QueryResult(TypedDict):
21
+ llm_prompt: str # LLMに渡すための整形されたプロンプト
22
+ retrieved_documents: List[str] # 検索された生文書
23
+ search_time: float # 検索にかかった時間
24
+ details: List[Dict[str, Union[str, float, int]]] # 各検索結果の詳細情報
25
+
26
+ class RAGSystem:
27
+ """
28
+ Retrieval Augmented Generation (RAG) システムを実装するクラス。
29
+ SentenceTransformerを用いて文書のエンベディングを生成し、FAISSで効率的な類似性検索を行う。
30
+ """
31
+
32
+ DEFAULT_MODEL_NAME = 'all-MiniLM-L6-v2'
33
+ DEFAULT_INDEX_TYPE = 'ivf'
34
+ DEFAULT_N_CLUSTERS = 100 # IVFインデックスのデフォルトクラスタ数
35
+ DEFAULT_NPROBE_RATIO = 0.1 # クラスタ数の10%をnprobeとしてデフォルト設定
36
+ DEFAULT_INDEX_BASE_DIR = Path('rag_data') # インデックスを保存するベースディレクトリ
37
+ DEFAULT_BATCH_SIZE = 64 # エンベディング生成時のデフォルトバッチサイズ
38
+ MIN_DOCS_FOR_IVF = 100 # IVFインデックスを推奨する最小文書数
39
+
40
+ def __init__(self, model_name: str = DEFAULT_MODEL_NAME,
41
+ index_type: str = DEFAULT_INDEX_TYPE,
42
+ n_clusters: int = DEFAULT_N_CLUSTERS,
43
+ nprobe: Optional[int] = None,
44
+ index_base_dir: Union[str, Path] = DEFAULT_INDEX_BASE_DIR,
45
+ load_latest: bool = True):
46
+ """
47
+ RAGシステムの初期化
48
+ Args:
49
+ model_name (str): SentenceTransformerのモデル名
50
+ index_type (str): FAISSインデックスのタイプ('flat'または'ivf')
51
+ n_clusters (int): IndexIVFFlat使用時のクラスタ数 (IVF選択時のみ有効)
52
+ nprobe (int, optional): IVF検索時の探索クラスタ数。未指定時はクラスタ数の10%
53
+ index_base_dir (Union[str, Path]): インデックスや文書データを保存するベースディレクトリ
54
+ load_latest (bool): Trueの場合、指定されたベースディレクトリ内の最新のインデックスと文書を自動的にロードする。
55
+ Falseの場合、新しいインデックスディレクトリを作成する。
56
+ """
57
+ self.model: Optional[SentenceTransformer] = None
58
+ self.index: Optional[faiss.Index] = None
59
+ self.index_type = index_type.lower()
60
+ self.n_clusters = n_clusters
61
+ self.nprobe = nprobe
62
+ self.documents: List[str] = []
63
+ self.dimension: Optional[int] = None
64
+ self.documents_hash: Optional[str] = None
65
+
66
+ if self.index_type not in ['flat', 'ivf']:
67
+ raise ValueError(f"サポートされていないインデックスタイプです: {index_type}。'flat'または'ivf'である必要があります。")
68
+
69
+ self.index_base_dir = Path(index_base_dir)
70
+ self.current_index_dir: Optional[Path] = None
71
+ self.index_path: Optional[Path] = None
72
+ self.documents_path: Optional[Path] = None
73
+ self.metadata_path: Optional[Path] = None
74
+
75
+ # モデルの初期化
76
+ self._initialize_model(model_name)
77
+
78
+ if load_latest:
79
+ self.load_latest_state()
80
+ else:
81
+ # 新しいインデックスディレクトリを作成
82
+ self._create_new_index_dir()
83
+
84
+ def _initialize_model(self, model_name: str) -> None:
85
+ """SentenceTransformerモデルの初期化"""
86
+ try:
87
+ logger.info("埋め込みモデルをロード中: %s", model_name)
88
+ self.model = SentenceTransformer(model_name)
89
+ self.dimension = self.model.get_sentence_embedding_dimension()
90
+ logger.info("モデルのロードに成功しました。埋め込み次元数: %d", self.dimension)
91
+ except Exception as e:
92
+ logger.exception("モデル '%s' のロードに失敗しました: %s", model_name, e)
93
+ raise RuntimeError(f"モデルの初期化に失敗しました: {e}") from e
94
+
95
+ def _create_new_index_dir(self) -> None:
96
+ """新しいユニークなインデックスディレクトリを作成し、パスを設定する"""
97
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
98
+ unique_id = str(uuid.uuid4())[:8]
99
+ self.current_index_dir = self.index_base_dir / f"{timestamp}_{unique_id}"
100
+ self.current_index_dir.mkdir(parents=True, exist_ok=True)
101
+
102
+ self.index_path = self.current_index_dir / 'faiss_index.bin'
103
+ self.documents_path = self.current_index_dir / 'documents.json'
104
+ self.metadata_path = self.current_index_dir / 'metadata.json'
105
+ logger.info("新しいRAGデータディレクトリを作成しました: %s", self.current_index_dir)
106
+
107
+ def _find_latest_index_dir(self) -> Optional[Path]:
108
+ """ベースディレクトリ内で最新のインデックスディレクトリを見つける"""
109
+ if not self.index_base_dir.exists():
110
+ return None
111
+
112
+ subdirs = []
113
+ for d in self.index_base_dir.iterdir():
114
+ if d.is_dir() and len(d.name) >= 15: # YYYYMMDD_HHMMSS_* 形式を期待
115
+ try:
116
+ datetime.strptime(d.name[:15], '%Y%m%d_%H%M%S')
117
+ subdirs.append(d)
118
+ except ValueError:
119
+ continue
120
+
121
+ if not subdirs:
122
+ return None
123
+
124
+ # 最新のディレクトリ(作成日時でソート)
125
+ subdirs.sort(key=lambda x: x.stat().st_mtime, reverse=True)
126
+ return subdirs[0]
127
+
128
+ def load_latest_state(self) -> bool:
129
+ """最新のインデックスと文書データを自動的にロードする"""
130
+ latest_dir = self._find_latest_index_dir()
131
+ if latest_dir:
132
+ self.current_index_dir = latest_dir
133
+ self.index_path = self.current_index_dir / 'faiss_index.bin'
134
+ self.documents_path = self.current_index_dir / 'documents.json'
135
+ self.metadata_path = self.current_index_dir / 'metadata.json'
136
+ logger.info("最新の状態をロードしようとしています: %s", self.current_index_dir)
137
+
138
+ try:
139
+ self.load_documents() # デフォルトパスから文書をロード
140
+ self.load_index() # デフォルトパスからインデックスをロード
141
+ return True
142
+ except (FileNotFoundError, RuntimeError) as e:
143
+ logger.warning("最新の状態を %s から完全にロードできませんでした: %s。新しい状態を作成します。", latest_dir, e)
144
+ self._create_new_index_dir()
145
+ return False
146
+ else:
147
+ logger.info("既存のRAGデータディレクトリが見つかりませんでした。新しい状態を作成します。")
148
+ self._create_new_index_dir()
149
+ return False
150
+
151
+ def _calculate_documents_hash(self, documents: List[str]) -> str:
152
+ """文書リストのハッシュ値を計算"""
153
+ if not documents:
154
+ return ""
155
+ # 安定したハッシュのために、ソートしてからエンコード
156
+ return hashlib.sha256(
157
+ json.dumps(sorted(documents), sort_keys=True, ensure_ascii=False).encode('utf-8')
158
+ ).hexdigest()
159
+
160
+ def _save_metadata(self) -> None:
161
+ """メタデータを保存"""
162
+ if not self.current_index_dir or not self.metadata_path:
163
+ logger.error("current_index_dirまたはmetadata_pathが設定されていません。メタデータを保存できません。")
164
+ return
165
+
166
+ metadata = {
167
+ 'index_type': self.index_type,
168
+ 'n_clusters': self.n_clusters,
169
+ 'nprobe': self.nprobe,
170
+ 'dimension': self.dimension,
171
+ 'documents_count': len(self.documents),
172
+ 'documents_hash': self.documents_hash,
173
+ 'created_at': datetime.now().isoformat(),
174
+ 'model_name': self.model.name_or_path if self.model else "unknown"
175
+ }
176
+ try:
177
+ with self.metadata_path.open('w', encoding='utf-8') as f:
178
+ json.dump(metadata, f, ensure_ascii=False, indent=2)
179
+ logger.debug("メタデータを %s に保存しました", self.metadata_path)
180
+ except Exception as e:
181
+ logger.warning("メタデータを %s に保存できませんでした: %s", self.metadata_path, e)
182
+
183
+ def _load_metadata(self) -> Dict:
184
+ """メタデータを読み込み"""
185
+ if not self.metadata_path or not self.metadata_path.exists():
186
+ return {}
187
+ try:
188
+ with self.metadata_path.open('r', encoding='utf-8') as f:
189
+ return json.load(f)
190
+ except Exception as e:
191
+ logger.warning("メタデータを %s からロードできませんでした: %s", self.metadata_path, e)
192
+ return {}
193
+
194
+ def load_documents(self, file_path: Optional[Union[str, Path]] = None) -> None:
195
+ """
196
+ 外部JSONファイルから文書データを読み込むか、現在のディレクトリの文書ファイルをロードする。
197
+ Args:
198
+ file_path (Union[str, Path], optional): 文書データを含むJSONファイルのパス。
199
+ 指定がない場合は現在のRAGデータディレクトリのdocuments.jsonから読み込む。
200
+ """
201
+ target_path = Path(file_path) if file_path else self.documents_path
202
+
203
+ if not target_path:
204
+ logger.warning("ドキュメントファイルのパスが提供されておらず、current_index_dirも設定されていません。文書をロードできません。")
205
+ return
206
+
207
+ if not target_path.exists():
208
+ if file_path: # 明示的にファイルパスが指定されたのに見つからない場合
209
+ raise FileNotFoundError(f"ドキュメントファイルが見つかりません: {target_path}")
210
+ else: # デフォルトパスが見つからない場合
211
+ logger.info("%s に documents.json が見つかりませんでした。文書はロードされません。", target_path)
212
+ self.documents = []
213
+ self.documents_hash = ""
214
+ return
215
+
216
+ try:
217
+ with target_path.open('r', encoding='utf-8') as f:
218
+ data = json.load(f)
219
+
220
+ if not isinstance(data, dict) or 'documents' not in data:
221
+ raise ValueError("JSONファイルは 'documents' キーを持ち、その値は文字列のリストである必要があります。")
222
+
223
+ documents_from_file = data['documents']
224
+ if not isinstance(documents_from_file, list):
225
+ raise ValueError("'documents' はリストである必要があります。")
226
+
227
+ valid_documents = []
228
+ for i, doc in enumerate(documents_from_file):
229
+ if isinstance(doc, str) and doc.strip():
230
+ valid_documents.append(doc.strip())
231
+ elif isinstance(doc, str):
232
+ logger.warning("ファイル %s のインデックス %d にある空の文書をスキップします。", target_path, i)
233
+ else:
234
+ logger.warning("ファイル %s のインデックス %d にある非文字列の文書をスキップします: %s", target_path, i, type(doc))
235
+
236
+ if not valid_documents:
237
+ raise ValueError(f"ファイル {target_path} に有効な文書が見つかりませんでした。")
238
+
239
+ self.documents = valid_documents
240
+ self.documents_hash = self._calculate_documents_hash(self.documents)
241
+
242
+ # もし外部ファイルから読み込んだ場合、現在のRAGデータディレクトリに保存し直す
243
+ if file_path and self.documents_path and Path(file_path).resolve() != self.documents_path.resolve():
244
+ with self.documents_path.open('w', encoding='utf-8') as f:
245
+ json.dump({'documents': self.documents}, f, ensure_ascii=False, indent=2)
246
+ logger.info("文書を '%s' から '%s' にコピーしました (%d件)", file_path, self.documents_path, len(self.documents))
247
+
248
+ logger.info("'%s' から %d 件の有効な文書をロードしました。", target_path, len(self.documents))
249
+
250
+ except json.JSONDecodeError as e:
251
+ logger.exception("ファイル '%s' のJSON形式が不正です: %s", target_path, e)
252
+ raise ValueError(f"不正なJSONファイルです: {target_path}") from e
253
+ except Exception as e:
254
+ logger.exception("ファイル '%s' から文書をロードできませんでした: %s", target_path, e)
255
+ raise
256
+
257
+ def build_index(self, batch_size: int = DEFAULT_BATCH_SIZE, force_rebuild: bool = False) -> None:
258
+ """
259
+ 現在ロードされている文書のエンベディングを生成し、FAISSインデックスを構築する。
260
+ Args:
261
+ batch_size (int): エンベディング生成時のバッチサイズ。
262
+ force_rebuild (bool): Trueの場合、既存のインデックスを強制的に再構築。
263
+ """
264
+ if not self.documents:
265
+ raise ValueError("インデックス作成用の文書がありません。まず load_documents() を使用して文書をロードしてください。")
266
+
267
+ if not self.current_index_dir or not self.index_path or not self.metadata_path:
268
+ raise RuntimeError("RAGシステムのディレクトリが初期化されていません。load_latest_state()を呼び出すか、load_latest=Falseで初期化してください。")
269
+
270
+ if self.model is None or self.dimension is None:
271
+ raise RuntimeError("SentenceTransformerモデルが初期化されていません。")
272
+
273
+ # バッチサイズの検証
274
+ if batch_size <= 0:
275
+ logger.warning("batch_size は正の値である必要があります。デフォルト値 %d を使用します。", self.DEFAULT_BATCH_SIZE)
276
+ batch_size = self.DEFAULT_BATCH_SIZE
277
+ batch_size = min(batch_size, len(self.documents))
278
+
279
+ # 既存インデックスのチェック
280
+ if self.index_path.exists() and not force_rebuild:
281
+ metadata = self._load_metadata()
282
+ # ハッシュだけでなく、次元数、インデックスタイプ、クラスタ数も一致するか確認
283
+ if metadata.get('documents_hash') == self.documents_hash and \
284
+ metadata.get('dimension') == self.dimension and \
285
+ metadata.get('index_type') == self.index_type and \
286
+ metadata.get('n_clusters') == self.n_clusters:
287
+ try:
288
+ self.load_index()
289
+ logger.info("既存のインデックスが文書ハッシュとパラメータに一致しました。force_rebuild=True を使用して再構築できます。")
290
+ return
291
+ except Exception as e:
292
+ logger.warning("既存のインデックスのロードに失敗しました: %s。再構築します。", e)
293
+ else:
294
+ logger.info("文書ハッシュの不一致、またはメタデータパラメータの変更がありました。インデックスを再構築します。")
295
+
296
+ try:
297
+ logger.info("%d 個の文書に対して埋め込みを作成中 (バッチサイズ: %d)", len(self.documents), batch_size)
298
+
299
+ embeddings = self.model.encode(
300
+ self.documents,
301
+ batch_size=batch_size,
302
+ show_progress_bar=True,
303
+ convert_to_tensor=False,
304
+ normalize_embeddings=True # コサイン類似度ベースの検索には必須
305
+ ).astype(np.float32)
306
+
307
+ # インデックスの初期化
308
+ if self.index_type == 'flat' or len(embeddings) < self.MIN_DOCS_FOR_IVF:
309
+ if self.index_type == 'ivf' and len(embeddings) < self.MIN_DOCS_FOR_IVF:
310
+ logger.warning("IVFインデックスには文書数が少なすぎます (%d 件、最小: %d 件)。Flatインデックスにフォールバックします。",
311
+ len(embeddings), self.MIN_DOCS_FOR_IVF)
312
+ self.index_type = 'flat'
313
+ self.index = faiss.IndexFlatL2(self.dimension)
314
+ logger.info("FAISS IndexFlatL2 を次元数 %d で初期化しました。", self.dimension)
315
+
316
+ elif self.index_type == 'ivf':
317
+ # クラスタ数の調整: 設定されたn_clustersと、文書数/5(経験値)の小さい方を採用。
318
+ # ただし、クラスタ数は文書総数以下である必要があり、最低1つ。
319
+ optimal_n_clusters = min(self.n_clusters, max(1, len(embeddings) // 5, 1)) # 最低1クラスタを保証
320
+ optimal_n_clusters = min(optimal_n_clusters, len(embeddings)) # 文書総数を超えないように
321
+
322
+ if optimal_n_clusters <= 1:
323
+ logger.warning("IVFの最適なクラスタ数 (%d) が少なすぎます。Flatインデックスにフォールバックします。", optimal_n_clusters)
324
+ self.index = faiss.IndexFlatL2(self.dimension)
325
+ self.index_type = 'flat'
326
+ else:
327
+ self.n_clusters = optimal_n_clusters
328
+ quantizer = faiss.IndexFlatL2(self.dimension)
329
+ self.index = faiss.IndexIVFFlat(quantizer, self.dimension, self.n_clusters, faiss.METRIC_L2)
330
+
331
+ if not self.index.is_trained: # 訓練済みでない場合のみ訓練
332
+ logger.info("FAISS IndexIVFFlat を %d 個のクラスタで訓練中 (%d 個の文書を使用)",
333
+ self.n_clusters, len(embeddings))
334
+ self.index.train(embeddings)
335
+
336
+ # nprobeの設定: デフォルトはクラスタ数の10%。ただし最低1、最大でクラスタ数まで。
337
+ # 文書総数も考慮に入れる
338
+ self.nprobe = self.nprobe or max(1, min(int(self.n_clusters * self.DEFAULT_NPROBE_RATIO), self.n_clusters))
339
+ self.nprobe = min(self.nprobe, self.n_clusters) # nprobeはn_clustersを超えてはならない
340
+
341
+ self.index.nprobe = self.nprobe
342
+ logger.info("nprobe を %d に設定しました。", self.nprobe)
343
+
344
+ # エンベディングの追加
345
+ if self.index:
346
+ self.index.add(embeddings)
347
+ logger.info("FAISSインデックスが正常に構築されました (%d 個のベクトル, タイプ: %s)",
348
+ self.index.ntotal, self.index_type)
349
+ else:
350
+ raise RuntimeError("FAISSインデックスの初期化に失敗しました。")
351
+
352
+ # インデックスとメタデータの保存
353
+ faiss.write_index(self.index, str(self.index_path))
354
+ self._save_metadata()
355
+ logger.info("インデックスとメタデータを %s に保存しました。", self.current_index_dir)
356
+
357
+ except Exception as e:
358
+ logger.exception("インデックスの構築に失敗しました: %s", e)
359
+ raise RuntimeError(f"インデックスの構築に失敗しました: {e}") from e
360
+
361
+ def load_index(self) -> None:
362
+ """
363
+ 現在のRAGデータディレクトリに保存済みのFAISSインデックスを読み込む。
364
+ """
365
+ if not self.index_path or not self.index_path.exists():
366
+ raise FileNotFoundError(f"インデックスファイルが見つかりません: {self.index_path}。まずインデックスを構築してください。")
367
+
368
+ try:
369
+ self.index = faiss.read_index(str(self.index_path))
370
+
371
+ # メタデータの読み込みと適用
372
+ metadata = self._load_metadata()
373
+ if metadata:
374
+ self.index_type = metadata.get('index_type', self.index_type)
375
+ self.n_clusters = metadata.get('n_clusters', self.n_clusters)
376
+ self.nprobe = metadata.get('nprobe', self.nprobe)
377
+ self.documents_hash = metadata.get('documents_hash', self.documents_hash)
378
+
379
+ # IVFインデックスの場合、nprobeを再設定
380
+ if self.index_type == 'ivf' and hasattr(self.index, 'nprobe'):
381
+ self.index.nprobe = self.nprobe
382
+
383
+ logger.info("%s からFAISSインデックスをロードしました (タイプ: %s, ベクトル数: %d)",
384
+ self.index_path, self.index_type, self.index.ntotal)
385
+
386
+ except Exception as e:
387
+ logger.exception("ファイル '%s' からインデックスをロードできませんでした: %s", self.index_path, e)
388
+ raise RuntimeError(f"インデックスのロードに失敗しました: {e}") from e
389
+
390
+ def clean_old_indices(self, keep_latest_n: int = 5) -> None:
391
+ """
392
+ 古いインデックスディレクトリをクリーンアップ。
393
+ Args:
394
+ keep_latest_n (int): 保持する最新のディレクトリ数。
395
+ """
396
+ if keep_latest_n <= 0:
397
+ logger.warning("keep_latest_n は正の値である必要があります。クリーンアップをスキップします。")
398
+ return
399
+
400
+ if not self.current_index_dir:
401
+ logger.warning("現在のインデックスディレクトリが設定されていません。クリーンアップをスキップします。")
402
+ return
403
+
404
+ try:
405
+ if not self.index_base_dir.exists():
406
+ logger.warning("ベースディレクトリが存在しません: %s", self.index_base_dir)
407
+ return
408
+
409
+ # タイムスタンプ形式のディレクトリを取得
410
+ subdirs = []
411
+ for d in self.index_base_dir.iterdir():
412
+ if d.is_dir() and len(d.name) >= 15: # YYYYMMDD_HHMMSS_* 形式を期待
413
+ try:
414
+ datetime.strptime(d.name[:15], '%Y%m%d_%H%M%S')
415
+ subdirs.append(d)
416
+ except ValueError:
417
+ continue
418
+
419
+ # 作成日時でソート(新しい順)
420
+ subdirs.sort(key=lambda x: x.stat().st_mtime, reverse=True)
421
+
422
+ # 古いディレクトリを削除
423
+ removed_count = 0
424
+ for old_dir in subdirs[keep_latest_n:]:
425
+ if old_dir != self.current_index_dir: # 現在使用中のディレクトリは削除しない
426
+ try:
427
+ shutil.rmtree(old_dir)
428
+ removed_count += 1
429
+ logger.info("古いインデックスディレクトリを削除しました: %s", old_dir)
430
+ except Exception as e:
431
+ logger.warning("古いインデックスディレクトリ %s の削除に失敗しました: %s", old_dir, e)
432
+
433
+ if removed_count > 0:
434
+ logger.info("%s 内の古いインデックスディレクトリを %d 件クリーンアップしました。", self.index_base_dir, removed_count)
435
+ else:
436
+ logger.info("%s にクリーンアップする古いディレクトリはありませんでした。", self.index_base_dir)
437
+
438
+ except Exception as e:
439
+ logger.exception("%s 内の古いインデックスのクリーンアップに失敗しました: %s", self.index_base_dir, e)
440
+
441
+ def query(self, query_text: str, top_k: int = 2, similarity_threshold: Optional[float] = None) -> QueryResult:
442
+ """
443
+ クエリに対するRAG検索とプロンプト生成。
444
+ Args:
445
+ query_text (str): 検索クエリ。
446
+ top_k (int): 取得する文書の数。
447
+ similarity_threshold (float, optional): コサイン類似度の閾値 (0.0から1.0)。
448
+ この値より低い類似度の文書は除外される。
449
+ Returns:
450
+ QueryResult: プロンプト、取得文書、検索時間、詳細を含む辞書。
451
+ """
452
+ # 入力検証
453
+ if not query_text or not query_text.strip():
454
+ logger.warning("空または空白のみのクエリが提供されました。")
455
+ return {
456
+ "llm_prompt": "質問が入力されていません。有効な質問を入力してください。",
457
+ "retrieved_documents": [],
458
+ "search_time": 0.0,
459
+ "details": []
460
+ }
461
+
462
+ query_text = query_text.strip()
463
+
464
+ if self.index is None or self.index.ntotal == 0:
465
+ logger.error("FAISSインデックスが初期化されていないか、空です。")
466
+ return {
467
+ "llm_prompt": "エラー: インデックスが初期化されていないか、文書がインデックス化されていません。文書をロードしてインデックスを構築してください。",
468
+ "retrieved_documents": [],
469
+ "search_time": 0.0,
470
+ "details": []
471
+ }
472
+
473
+ if not self.documents:
474
+ logger.error("文書がロードされていません。")
475
+ return {
476
+ "llm_prompt": "エラー: 文書がロードされていません。ロードされた文書がないと検索できません。",
477
+ "retrieved_documents": [],
478
+ "search_time": 0.0,
479
+ "details": []
480
+ }
481
+
482
+ if top_k <= 0:
483
+ logger.warning("top_k は正の値である必要があります。1に設定します。")
484
+ top_k = 1
485
+
486
+ top_k = min(top_k, self.index.ntotal) # 利用可能なインデックスの総数に制限
487
+
488
+ if self.model is None:
489
+ raise RuntimeError("SentenceTransformerモデルが初期化されていません。")
490
+
491
+ try:
492
+ query_embedding = self.model.encode(
493
+ [query_text],
494
+ convert_to_tensor=False,
495
+ normalize_embeddings=True # 正規化された埋め込みを使用
496
+ ).astype(np.float32)
497
+
498
+ start_time = time.time()
499
+ # FAISSはL2距離で検索するため、結果はL2距離
500
+ distances, indices = self.index.search(query_embedding, top_k)
501
+ search_time = time.time() - start_time
502
+
503
+ logger.info("FAISS検索が %.4f 秒で完了しました。クエリ: '%.50s...'",
504
+ search_time, query_text)
505
+
506
+ retrieved_docs_text: List[str] = []
507
+ retrieval_details: List[Dict[str, Union[str, float, int]]] = []
508
+
509
+ for i, (l2_dist, idx) in enumerate(zip(distances[0], indices[0])):
510
+ if not (0 <= idx < len(self.documents)):
511
+ logger.warning("FAISSによって返された無効なインデックス %d (総文書数: %d)。スキップします。",
512
+ idx, len(self.documents))
513
+ continue
514
+
515
+ # 正規化された埋め込みの場合、L2距離 d とコサイン類似度 s の関係は d^2 = 2 - 2s
516
+ # よって、s = 1 - d^2 / 2
517
+ cosine_sim = 1 - (l2_dist**2) / 2.0
518
+
519
+ if similarity_threshold is not None and cosine_sim < similarity_threshold:
520
+ logger.debug("文書 #%d はフィルタリングされました (コサイン類似度: %.4f < %.4f)",
521
+ i+1, cosine_sim, similarity_threshold)
522
+ continue
523
+
524
+ doc_text = self.documents[idx]
525
+ retrieved_docs_text.append(doc_text)
526
+ retrieval_details.append({
527
+ "document": doc_text,
528
+ "l2_distance": float(l2_dist),
529
+ "cosine_similarity": float(cosine_sim),
530
+ "original_index": int(idx),
531
+ "rank": i + 1
532
+ })
533
+
534
+ logger.debug("取得した文書 #%d (インデックス: %d, ランク: %d): '%.50s...' (L2: %.4f, CosSim: %.4f)",
535
+ i+1, idx, i+1, doc_text, l2_dist, cosine_sim)
536
+
537
+ if not retrieved_docs_text:
538
+ logger.info("クエリ '%.50s...' に関連する文書は見つかりませんでした (top_k=%d, 類似度閾値=%.2f)",
539
+ query_text, top_k, similarity_threshold or 0.0)
540
+ llm_prompt = (f"質問: {query_text}\n\n回答: 申し訳ございませんが、関連する情報が見つかりませんでした。"
541
+ "別の表現で質問していただくか、より具体的な内容で質問してください。")
542
+ return {
543
+ "llm_prompt": llm_prompt,
544
+ "retrieved_documents": [],
545
+ "search_time": search_time,
546
+ "details": []
547
+ }
548
+
549
+ context = "\n\n".join([f"[文書{i+1}]\n{doc}" for i, doc in enumerate(retrieved_docs_text)])
550
+ llm_prompt = f"""以下の関連文書を参考にして、質問に正確かつ詳細に答えてください。
551
+
552
+ === 関連文書 ===
553
+ {context}
554
+
555
+ === 質問 ===
556
+ {query_text}
557
+
558
+ === 回答 ===
559
+ 上記の文書に基づいて回答します:"""
560
+
561
+ logger.info("クエリに対するLLMプロンプトを生成しました (取得文書数: %d): '%.50s...'",
562
+ len(retrieved_docs_text), query_text)
563
+
564
+ return {
565
+ "llm_prompt": llm_prompt,
566
+ "retrieved_documents": retrieved_docs_text,
567
+ "search_time": search_time,
568
+ "details": retrieval_details
569
+ }
570
+
571
+ except Exception as e:
572
+ logger.exception("クエリ処理中にエラーが発生しました: '%.50s...'", query_text)
573
+ return {
574
+ "llm_prompt": f"エラー: クエリ処理中に問題が発生しました - {str(e)}",
575
+ "retrieved_documents": [],
576
+ "search_time": 0.0,
577
+ "details": []
578
+ }
579
+
580
+ def get_stats(self) -> Dict[str, Union[int, str, float]]:
581
+ """システムの統計情報を取得"""
582
+ stats: Dict[str, Union[int, str, float]] = {
583
+ "documents_count": len(self.documents),
584
+ "index_type": self.index_type,
585
+ "dimension": self.dimension if self.dimension is not None else 0,
586
+ "index_total_vectors": self.index.ntotal if self.index else 0,
587
+ "current_index_dir": str(self.current_index_dir) if self.current_index_dir else "N/A"
588
+ }
589
+
590
+ if self.index_type == 'ivf':
591
+ stats.update({
592
+ "n_clusters": self.n_clusters,
593
+ "nprobe": self.nprobe if self.nprobe is not None else "N/A"
594
+ })
595
+
596
+ if self.index_path and self.index_path.exists():
597
+ stats["index_file_size_mb"] = round(self.index_path.stat().st_size / (1024 * 1024), 2)
598
+
599
+ return stats
600
+
601
+ if __name__ == "__main__":
602
+ # サンプル文書データ
603
+ sample_documents = [
604
+ "RAG(Retrieval Augmented Generation)は、大規模言語モデルの課題、特に幻覚や情報鮮度の問題を解決するために考案された強力なAIフレームワークです。このシステムでは、外部のデータベースから関連情報を検索し、それを基に回答を生成します。",
605
+ "LLMの幻覚(Hallucination)は、大規模言語モデルが事実と異なる情報を生成してしまう問題であり、RAGのようなフレームワークがその対策として注目されています。幻覚は訓練データにない情報や、古い情報に基づく回答で発生しやすいです。",
606
+ "ベクトルデータベースは、高次元のベクトルデータを効率的に保存、管理、検索するための特殊なデータベースです。近似最近傍探索(ANN)アルゴリズムを使用して、類似度の高いベクトルを高速に検索できます。",
607
+ "Faissは、Facebook AIが開発したオープンソースの効率的な類似性検索ライブラリで、特に大規模なベクトルデータセットにおいて高速な検索を実現します。CPU版とGPU版があり、様々なインデックス構造をサポートしています。",
608
+ "プロンプトエンジニアリングは、大規模言語モデルから最適な出力を得るための技術であり、質問の仕方や文脈の与え方が重要です。適切なプロンプト設計により、モデルの性能を大幅に向上させることができます。",
609
+ "RAGシステムの実運用における課題としては、計算コストとレイテンシ(応答遅延)が挙げられます。リアルタイム性が求められるアプリケーションでは、検索速度とエンベディング生成速度の最適化が重要になります。",
610
+ "インデックスの最適化は、検索速度を向上させるための重要なステップです。適切なインデックス構造の選択、パラメータチューニング、データの前処理によって大幅な性能改善が期待できます。",
611
+ "セマンティック検索は、意味的な類似性に基づいて情報を検索する技術です。従来のキーワードベースの検索とは異なり、文脈や意図を理解して関連度の高い結果を返すことができます。",
612
+ "モデルの軽量化や量子化は、推論速度とメモリ使用量を改善する重要な技術です。精度を保ちながらモデルサイズを削減することで、リソース制約のある環境でも効率的に動作させることができます。",
613
+ "キャッシュは、頻繁にアクセスされるデータを一時的に保存し、高速なアクセスを可能に��る仕組みです。RAGシステムでは、エンベディングキャッシュや検索結果キャッシュによって応答速度を大幅に改善できます。",
614
+ "自然言語処理(NLP)は、コンピュータが人間の言語を理解、解釈、生成する能力を研究するAIの分野です。機械翻訳やテキスト要約などが含まれます。",
615
+ "トランスフォーマーモデルは、自然言語処理の分野で大きな進歩をもたらしたニューラルネットワークアーキテクチャです。Attention機構が特徴です。",
616
+ "強化学習は、エージェントが環境と相互作用し、試行錯誤を通じて最適な行動戦略を学習する機械学習の一分野です。報酬を最大化するように学習します。",
617
+ "教師あり学習は、入力データとそれに対応する正解ラベルのペアを用いてモデルを訓練する機械学習の最も一般的な手法です。分類や回帰問題に用いられます。",
618
+ "非教師あり学習は、ラベルなしのデータからパターンや構造を自動的に見つけ出す機械学習の手法です。クラスタリングや次元削減などが含まれます。"
619
+ ]
620
+
621
+ # テスト用ディレクトリの作成
622
+ test_base_dir = Path('test_rag_systems')
623
+ if test_base_dir.exists():
624
+ shutil.rmtree(test_base_dir) # 以前のテストデータを削除
625
+ test_base_dir.mkdir(parents=True, exist_ok=True)
626
+
627
+ # サンプル文書をJSONファイルに保存
628
+ # 複数回使用することを想定し、固定のファイル名にする
629
+ test_doc_file = test_base_dir / 'shared_sample_documents.json'
630
+ with test_doc_file.open('w', encoding='utf-8') as f:
631
+ json.dump({'documents': sample_documents}, f, ensure_ascii=False, indent=2)
632
+
633
+ print("=== RAG System Test ===")
634
+
635
+ # ケース1: 新しいRAGシステムを作成し、文書をロードしてインデックスを構築
636
+ print("\n--- ケース1: 新しいIVFインデックスの構築 ---")
637
+ try:
638
+ rag_ivf_1 = RAGSystem(
639
+ model_name='all-MiniLM-L6-v2',
640
+ index_type='ivf',
641
+ n_clusters=50, # 文書数が少ないので、実際には調整される
642
+ index_base_dir=test_base_dir,
643
+ load_latest=False # 新しいディレクトリを作成
644
+ )
645
+ rag_ivf_1.load_documents(test_doc_file)
646
+ rag_ivf_1.build_index()
647
+ stats_1 = rag_ivf_1.get_stats()
648
+ print(f"構築後の統計1: {stats_1}")
649
+
650
+ query_result_1 = rag_ivf_1.query("RAGシステムの主な利点は何ですか?", top_k=3)
651
+ print("\nクエリ1 (IVF):")
652
+ print(f"LLMプロンプト:\n{query_result_1['llm_prompt']}")
653
+ print(f"取得文書: {[d[:50] + '...' for d in query_result_1['retrieved_documents']]}")
654
+ print(f"検索時間: {query_result_1['search_time']:.4f}秒")
655
+ print(f"詳細: {query_result_1['details']}")
656
+
657
+ except Exception as e:
658
+ logger.error(f"ケース1でエラーが発生しました: {e}")
659
+
660
+ # ケース2: 同じベースディレクトリで新しいRAGシステムを作成し、最新の状態をロード
661
+ print("\n--- ケース2: 最新インデックスのロード ---")
662
+ try:
663
+ rag_ivf_2 = RAGSystem(
664
+ model_name='all-MiniLM-L6-v2',
665
+ index_type='ivf', # この設定はロードされるメタデータで上書きされる可能性あり
666
+ n_clusters=100,
667
+ index_base_dir=test_base_dir,
668
+ load_latest=True # 最新のディレクトリをロード
669
+ )
670
+ stats_2 = rag_ivf_2.get_stats()
671
+ print(f"ロード後の統計2: {stats_2}")
672
+
673
+ query_result_2 = rag_ivf_2.query("LLMの幻覚問題を解決する方法は?", top_k=2, similarity_threshold=0.8)
674
+ print("\nクエリ2 (ロードされたIVF):")
675
+ print(f"LLMプロンプト:\n{query_result_2['llm_prompt']}")
676
+ print(f"取得文書: {[d[:50] + '...' for d in query_result_2['retrieved_documents']]}")
677
+ print(f"検索時間: {query_result_2['search_time']:.4f}秒")
678
+ print(f"詳細: {query_result_2['details']}")
679
+ except Exception as e:
680
+ logger.error(f"ケース2でエラーが発生しました: {e}")
681
+
682
+ # ケース3: Flatインデックスのテスト(新しいディレクトリで)
683
+ print("\n--- ケース3: 新しいFlatインデックスの構築 ---")
684
+ try:
685
+ rag_flat = RAGSystem(
686
+ model_name='all-MiniLM-L6-v2',
687
+ index_type='flat',
688
+ index_base_dir=test_base_dir,
689
+ load_latest=False # 新しいディレクトリを作成
690
+ )
691
+ rag_flat.load_documents(test_doc_file)
692
+ rag_flat.build_index()
693
+ stats_3 = rag_flat.get_stats()
694
+ print(f"構築後の統計3: {stats_3}")
695
+
696
+ query_result_3 = rag_flat.query("ベクトルデータベースとは何ですか?", top_k=1)
697
+ print("\nクエリ3 (Flat):")
698
+ print(f"LLMプロンプト:\n{query_result_3['llm_prompt']}")
699
+ print(f"取得文書: {[d[:50] + '...' for d in query_result_3['retrieved_documents']]}")
700
+ print(f"検索時間: {query_result_3['search_time']:.4f}秒")
701
+ print(f"詳細: {query_result_3['details']}")
702
+ except Exception as e:
703
+ logger.error(f"ケース3でエラーが発生しました: {e}")
704
+
705
+ # ケース4: 存在しない文書ファイルをロードしようとする
706
+ print("\n--- ケース4: 存在しないドキュメントファイルのロード ---")
707
+ try:
708
+ rag_err = RAGSystem(index_base_dir=test_base_dir, load_latest=False)
709
+ rag_err.load_documents(Path('non_existent_file.json'))
710
+ except FileNotFoundError as e:
711
+ print(f"想定されたエラーを捕捉しました: {e}")
712
+ except Exception as e:
713
+ logger.error(f"ケース4で予期せぬエラーが発生しました: {e}")
714
+
715
+ # ケース5: 空のクエリ
716
+ print("\n--- ケース5: 空のクエリ ---")
717
+ try:
718
+ # 既存のRAGSystemをロード(最新のものを自動的に選択)
719
+ rag_existing = RAGSystem(index_base_dir=test_base_dir, load_latest=True)
720
+ query_result_empty = rag_existing.query("")
721
+ print(f"空のクエリ結果LLMプロンプト: {query_result_empty['llm_prompt']}")
722
+ except Exception as e:
723
+ logger.error(f"ケース5で予期せぬエラーが発生しました: {e}")
724
+
725
+ # ケース6: 古いインデックスのクリーンアップ
726
+ print("\n--- ケース6: 古いインデックスのクリーンアップ ---")
727
+ try:
728
+ # 新しいインデックスをいくつか作成し、現在のもの以外の古いものを削除するようにする
729
+ _ = RAGSystem(index_base_dir=test_base_dir, load_latest=False) # 新しいディレクトリ1
730
+ time.sleep(0.1) # タイムスタンプが異なるように少し待つ
731
+ _ = RAGSystem(index_base_dir=test_base_dir, load_latest=False) # 新しいディレクトリ2
732
+ time.sleep(0.1)
733
+ rag_clean = RAGSystem(index_base_dir=test_base_dir, load_latest=False) # 新しいディレクトリ3 (これが最新になる)
734
+ rag_clean.clean_old_indices(keep_latest_n=2) # 最新2つを残して削除
735
+ print("古いインデックスがクリーンアップされました。'test_rag_systems' ディレクトリを確認してください。")
736
+ except Exception as e:
737
+ logger.error(f"ケース6でエラーが発生しました: {e}")
738
+
739
+ # テスト終了後のクリーンアップ(オプション)
740
+ # shutil.rmtree(test_base_dir)
741
+ # print(f"\nテストディレクトリ '{test_base_dir}' を削除しました。")