vector-inspector 0.2.4.3__tar.gz → 0.2.6__tar.gz

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.
Files changed (38) hide show
  1. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/PKG-INFO +5 -2
  2. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/README.md +3 -0
  3. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/pyproject.toml +2 -2
  4. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/core/connections/chroma_connection.py +28 -2
  5. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/core/connections/qdrant_connection.py +61 -1
  6. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/services/settings_service.py +1 -1
  7. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/services/visualization_service.py +24 -17
  8. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/ui/main_window.py +62 -6
  9. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/ui/views/connection_view.py +43 -8
  10. vector_inspector-0.2.6/src/vector_inspector/ui/views/info_panel.py +287 -0
  11. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/ui/views/metadata_view.py +65 -25
  12. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/ui/views/visualization_view.py +6 -2
  13. vector_inspector-0.2.6/src/vector_inspector/utils/__init__.py +1 -0
  14. vector_inspector-0.2.6/src/vector_inspector/utils/lazy_imports.py +49 -0
  15. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/__init__.py +0 -0
  16. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/__main__.py +0 -0
  17. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/core/__init__.py +0 -0
  18. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/core/connections/__init__.py +0 -0
  19. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/core/connections/base_connection.py +0 -0
  20. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/core/connections/template_connection.py +0 -0
  21. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/main.py +0 -0
  22. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/services/__init__.py +0 -0
  23. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/services/backup_restore_service.py +0 -0
  24. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/services/filter_service.py +0 -0
  25. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/services/import_export_service.py +0 -0
  26. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/ui/__init__.py +0 -0
  27. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/ui/components/__init__.py +0 -0
  28. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/ui/components/backup_restore_dialog.py +0 -0
  29. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/ui/components/filter_builder.py +0 -0
  30. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/ui/components/item_dialog.py +0 -0
  31. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/ui/components/loading_dialog.py +0 -0
  32. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/ui/views/__init__.py +0 -0
  33. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/ui/views/collection_browser.py +0 -0
  34. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/src/vector_inspector/ui/views/search_view.py +0 -0
  35. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/tests/test_connections.py +0 -0
  36. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/tests/test_filter_service.py +0 -0
  37. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/tests/test_settings_service.py +0 -0
  38. {vector_inspector-0.2.4.3 → vector_inspector-0.2.6}/tests/vector_inspector.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: vector-inspector
3
- Version: 0.2.4.3
3
+ Version: 0.2.6
4
4
  Summary: A comprehensive desktop application for visualizing, querying, and managing vector database data
5
5
  Author-Email: Anthony Dawson <anthonypdawson+github@gmail.com>
6
6
  License: MIT
@@ -8,7 +8,7 @@ Project-URL: Homepage, https://vector-inspector.divinedevops.com
8
8
  Project-URL: Source, https://github.com/anthonypdawson/vector-inspector
9
9
  Project-URL: Issues, https://github.com/anthonypdawson/vector-inspector/issues
10
10
  Project-URL: Documentation, https://github.com/anthonypdawson/vector-inspector#readme
11
- Requires-Python: ==3.12.*
11
+ Requires-Python: <3.13,>=3.10
12
12
  Requires-Dist: chromadb>=0.4.22
13
13
  Requires-Dist: qdrant-client>=1.7.0
14
14
  Requires-Dist: pyside6>=6.6.0
@@ -26,6 +26,9 @@ Description-Content-Type: text/markdown
26
26
 
27
27
 
28
28
  # Vector Inspector
29
+
30
+ > **Disclaimer:** This tool is currently under active development and is **not production ready**. Not all features have been thoroughly tested and code is released frequently. Use with caution in critical or production environments.
31
+
29
32
  ![PyPI](https://img.shields.io/pypi/v/vector-inspector)
30
33
  [![PyPI Downloads](https://static.pepy.tech/personalized-badge/vector-inspector?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/vector-inspector)
31
34
 
@@ -1,5 +1,8 @@
1
1
 
2
2
  # Vector Inspector
3
+
4
+ > **Disclaimer:** This tool is currently under active development and is **not production ready**. Not all features have been thoroughly tested and code is released frequently. Use with caution in critical or production environments.
5
+
3
6
  ![PyPI](https://img.shields.io/pypi/v/vector-inspector)
4
7
  [![PyPI Downloads](https://static.pepy.tech/personalized-badge/vector-inspector?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/vector-inspector)
5
8
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "vector-inspector"
3
- version = "0.2.4.3"
3
+ version = "0.2.6"
4
4
  description = "A comprehensive desktop application for visualizing, querying, and managing vector database data"
5
5
  authors = [
6
6
  { name = "Anthony Dawson", email = "anthonypdawson+github@gmail.com" },
@@ -20,7 +20,7 @@ dependencies = [
20
20
  "pyarrow>=14.0.0",
21
21
  "pinecone>=8.0.0",
22
22
  ]
23
- requires-python = "==3.12.*"
23
+ requires-python = ">=3.10,<3.13"
24
24
  readme = "README.md"
25
25
 
26
26
  [project.license]
@@ -125,16 +125,42 @@ class ChromaDBConnection(VectorDBConnection):
125
125
 
126
126
  try:
127
127
  count = collection.count()
128
- # Get a sample to determine metadata fields
129
- sample = collection.get(limit=1, include=["metadatas"])
128
+ # Get a sample to determine metadata fields and vector dimensions
129
+ sample = collection.get(limit=1, include=["metadatas", "embeddings"])
130
130
  metadata_fields = []
131
+ vector_dimension = "Unknown"
132
+
131
133
  if sample and sample["metadatas"]:
132
134
  metadata_fields = list(sample["metadatas"][0].keys()) if sample["metadatas"][0] else []
133
135
 
136
+ # Determine vector dimensions from embeddings
137
+ embeddings = sample.get("embeddings") if sample else None
138
+ if embeddings is not None and len(embeddings) > 0 and embeddings[0] is not None:
139
+ vector_dimension = len(embeddings[0])
140
+
141
+ # ChromaDB uses cosine distance by default (or can be configured)
142
+ # Try to get metadata from collection if available
143
+ distance_metric = "Cosine (default)"
144
+ try:
145
+ # ChromaDB collections may have metadata about distance function
146
+ col_metadata = collection.metadata
147
+ if col_metadata and "hnsw:space" in col_metadata:
148
+ space = col_metadata["hnsw:space"]
149
+ if space == "l2":
150
+ distance_metric = "Euclidean (L2)"
151
+ elif space == "ip":
152
+ distance_metric = "Inner Product"
153
+ elif space == "cosine":
154
+ distance_metric = "Cosine"
155
+ except:
156
+ pass # Use default if unable to determine
157
+
134
158
  return {
135
159
  "name": name,
136
160
  "count": count,
137
161
  "metadata_fields": metadata_fields,
162
+ "vector_dimension": vector_dimension,
163
+ "distance_metric": distance_metric,
138
164
  }
139
165
  except Exception as e:
140
166
  print(f"Failed to get collection info: {e}")
@@ -191,11 +191,71 @@ class QdrantConnection(VectorDBConnection):
191
191
  # Extract metadata fields, excluding 'document' if present
192
192
  metadata_fields = [k for k in point.payload.keys() if k != 'document']
193
193
 
194
- return {
194
+ # Extract vector configuration
195
+ vector_dimension = "Unknown"
196
+ distance_metric = "Unknown"
197
+ config_details = {}
198
+
199
+ if collection_info.config:
200
+ # Get vector parameters
201
+ if hasattr(collection_info.config, 'params'):
202
+ params = collection_info.config.params
203
+ if hasattr(params, 'vectors'):
204
+ vectors = params.vectors
205
+ # Handle both dict and object access
206
+ if isinstance(vectors, dict):
207
+ # Named vectors
208
+ first_vector = next(iter(vectors.values()), None)
209
+ if first_vector:
210
+ vector_dimension = getattr(first_vector, 'size', 'Unknown')
211
+ distance = getattr(first_vector, 'distance', None)
212
+ else:
213
+ # Single vector config
214
+ vector_dimension = getattr(vectors, 'size', 'Unknown')
215
+ distance = getattr(vectors, 'distance', None)
216
+
217
+ # Map distance enum to readable name
218
+ if distance:
219
+ distance_str = str(distance)
220
+ if 'COSINE' in distance_str.upper():
221
+ distance_metric = "Cosine"
222
+ elif 'EUCLID' in distance_str.upper():
223
+ distance_metric = "Euclidean"
224
+ elif 'DOT' in distance_str.upper():
225
+ distance_metric = "Dot Product"
226
+ elif 'MANHATTAN' in distance_str.upper():
227
+ distance_metric = "Manhattan"
228
+ else:
229
+ distance_metric = distance_str
230
+
231
+ # Get HNSW config if available
232
+ if hasattr(collection_info.config, 'hnsw_config'):
233
+ hnsw = collection_info.config.hnsw_config
234
+ config_details['hnsw_config'] = {
235
+ 'm': getattr(hnsw, 'm', None),
236
+ 'ef_construct': getattr(hnsw, 'ef_construct', None),
237
+ }
238
+
239
+ # Get optimizer config if available
240
+ if hasattr(collection_info.config, 'optimizer_config'):
241
+ opt = collection_info.config.optimizer_config
242
+ config_details['optimizer_config'] = {
243
+ 'indexing_threshold': getattr(opt, 'indexing_threshold', None),
244
+ }
245
+
246
+ result = {
195
247
  "name": name,
196
248
  "count": collection_info.points_count,
197
249
  "metadata_fields": metadata_fields,
250
+ "vector_dimension": vector_dimension,
251
+ "distance_metric": distance_metric,
198
252
  }
253
+
254
+ if config_details:
255
+ result['config'] = config_details
256
+
257
+ return result
258
+
199
259
  except Exception as e:
200
260
  print(f"Failed to get collection info: {e}")
201
261
  return None
@@ -10,7 +10,7 @@ class SettingsService:
10
10
 
11
11
  def __init__(self):
12
12
  """Initialize settings service."""
13
- self.settings_dir = Path.home() / ".vector-viewer"
13
+ self.settings_dir = Path.home() / ".vector-inspector"
14
14
  self.settings_file = self.settings_dir / "settings.json"
15
15
  self.settings: Dict[str, Any] = {}
16
16
  self._load_settings()
@@ -1,10 +1,7 @@
1
1
  """Visualization service for dimensionality reduction."""
2
2
 
3
- from typing import Optional, List, Tuple
4
- import numpy as np
5
- from sklearn.decomposition import PCA
6
- from sklearn.manifold import TSNE
7
- import umap
3
+ from typing import Optional, List, Tuple, Any
4
+ import warnings
8
5
 
9
6
 
10
7
  class VisualizationService:
@@ -16,7 +13,7 @@ class VisualizationService:
16
13
  method: str = "pca",
17
14
  n_components: int = 2,
18
15
  **kwargs
19
- ) -> Optional[np.ndarray]:
16
+ ) -> Optional[Any]:
20
17
  """
21
18
  Reduce dimensionality of embeddings.
22
19
 
@@ -33,13 +30,19 @@ class VisualizationService:
33
30
  return None
34
31
 
35
32
  try:
33
+ # Lazy import numpy and models
34
+ from vector_inspector.utils.lazy_imports import get_numpy, get_sklearn_model
35
+ np = get_numpy()
36
+
36
37
  X = np.array(embeddings)
37
38
 
38
- if method == "pca":
39
+ if method.lower() == "pca":
40
+ PCA = get_sklearn_model('PCA')
39
41
  reducer = PCA(n_components=n_components)
40
42
  reduced = reducer.fit_transform(X)
41
43
 
42
- elif method == "tsne":
44
+ elif method.lower() in ["t-sne", "tsne"]:
45
+ TSNE = get_sklearn_model('TSNE')
43
46
  perplexity = kwargs.get("perplexity", min(30, len(embeddings) - 1))
44
47
  reducer = TSNE(
45
48
  n_components=n_components,
@@ -48,14 +51,18 @@ class VisualizationService:
48
51
  )
49
52
  reduced = reducer.fit_transform(X)
50
53
 
51
- elif method == "umap":
54
+ elif method.lower() == "umap":
55
+ UMAP = get_sklearn_model('UMAP')
52
56
  n_neighbors = kwargs.get("n_neighbors", min(15, len(embeddings) - 1))
53
- reducer = umap.UMAP(
54
- n_components=n_components,
55
- n_neighbors=n_neighbors,
56
- random_state=42
57
- )
58
- reduced = reducer.fit_transform(X)
57
+ # Suppress n_jobs warning when using random_state
58
+ with warnings.catch_warnings():
59
+ warnings.filterwarnings("ignore", message=".*n_jobs.*overridden.*")
60
+ reducer = UMAP(
61
+ n_components=n_components,
62
+ n_neighbors=n_neighbors,
63
+ random_state=42
64
+ )
65
+ reduced = reducer.fit_transform(X)
59
66
 
60
67
  else:
61
68
  print(f"Unknown method: {method}")
@@ -69,11 +76,11 @@ class VisualizationService:
69
76
 
70
77
  @staticmethod
71
78
  def prepare_plot_data(
72
- reduced_embeddings: np.ndarray,
79
+ reduced_embeddings: Any,
73
80
  labels: Optional[List[str]] = None,
74
81
  metadata: Optional[List[dict]] = None,
75
82
  color_by: Optional[str] = None
76
- ) -> Tuple[np.ndarray, List[str], List[str]]:
83
+ ) -> Tuple[Any, List[str], List[str]]:
77
84
  """
78
85
  Prepare data for plotting.
79
86
 
@@ -5,17 +5,18 @@ from PySide6.QtWidgets import (
5
5
  QSplitter, QTabWidget, QStatusBar, QToolBar,
6
6
  QMessageBox, QInputDialog, QFileDialog
7
7
  )
8
- from PySide6.QtCore import Qt, Signal
8
+ from PySide6.QtCore import Qt, Signal, QTimer
9
9
  from PySide6.QtGui import QAction
10
10
 
11
11
  from vector_inspector.core.connections.base_connection import VectorDBConnection
12
12
  from vector_inspector.core.connections.chroma_connection import ChromaDBConnection
13
13
  from vector_inspector.ui.views.connection_view import ConnectionView
14
14
  from vector_inspector.ui.views.collection_browser import CollectionBrowser
15
+ from vector_inspector.ui.views.info_panel import InfoPanel
15
16
  from vector_inspector.ui.views.metadata_view import MetadataView
16
17
  from vector_inspector.ui.views.search_view import SearchView
17
- from vector_inspector.ui.views.visualization_view import VisualizationView
18
18
  from vector_inspector.ui.components.backup_restore_dialog import BackupRestoreDialog
19
+ from vector_inspector.ui.components.loading_dialog import LoadingDialog
19
20
 
20
21
 
21
22
  class MainWindow(QMainWindow):
@@ -27,6 +28,7 @@ class MainWindow(QMainWindow):
27
28
  super().__init__()
28
29
  self.connection: VectorDBConnection = ChromaDBConnection()
29
30
  self.current_collection: str = ""
31
+ self.loading_dialog = LoadingDialog("Loading collection...", self)
30
32
 
31
33
  self.setWindowTitle("Vector Inspector")
32
34
  self.setGeometry(100, 100, 1400, 900)
@@ -63,13 +65,21 @@ class MainWindow(QMainWindow):
63
65
  # Right panel - Tabbed views
64
66
  self.tab_widget = QTabWidget()
65
67
 
68
+ self.info_panel = InfoPanel(self.connection)
66
69
  self.metadata_view = MetadataView(self.connection)
67
70
  self.search_view = SearchView(self.connection)
68
- self.visualization_view = VisualizationView(self.connection)
71
+ self.visualization_view = None # Lazy loaded
69
72
 
73
+ self.tab_widget.addTab(self.info_panel, "Info")
70
74
  self.tab_widget.addTab(self.metadata_view, "Data Browser")
71
75
  self.tab_widget.addTab(self.search_view, "Search")
72
- self.tab_widget.addTab(self.visualization_view, "Visualization")
76
+ self.tab_widget.addTab(QWidget(), "Visualization") # Placeholder
77
+
78
+ # Set Info tab as default
79
+ self.tab_widget.setCurrentIndex(0)
80
+
81
+ # Connect to tab change to lazy load visualization
82
+ self.tab_widget.currentChanged.connect(self._on_tab_changed)
73
83
 
74
84
  # Add panels to splitter
75
85
  main_splitter.addWidget(left_panel)
@@ -167,14 +177,34 @@ class MainWindow(QMainWindow):
167
177
  self.connection_view.connection_created.connect(self._on_connection_created)
168
178
  self.collection_browser.collection_selected.connect(self._on_collection_selected)
169
179
 
180
+ def _on_tab_changed(self, index: int):
181
+ """Handle tab change - lazy load visualization tab."""
182
+ if index == 3 and self.visualization_view is None:
183
+ # Lazy load visualization view
184
+ from vector_inspector.ui.views.visualization_view import VisualizationView
185
+ self.visualization_view = VisualizationView(self.connection)
186
+ # Replace placeholder with actual view
187
+ self.tab_widget.removeTab(3)
188
+ self.tab_widget.insertTab(3, self.visualization_view, "Visualization")
189
+ self.tab_widget.setCurrentIndex(3)
190
+ # Set collection if one is already selected
191
+ if self.current_collection:
192
+ self.visualization_view.set_collection(self.current_collection)
193
+
170
194
  def _on_connection_created(self, new_connection: VectorDBConnection):
171
195
  """Handle when a new connection instance is created."""
172
196
  self.connection = new_connection
173
197
  # Update all views with new connection
174
198
  self.collection_browser.connection = new_connection
199
+ self.info_panel.connection = new_connection
175
200
  self.metadata_view.connection = new_connection
176
201
  self.search_view.connection = new_connection
177
- self.visualization_view.connection = new_connection
202
+ # Only update visualization if it's been created
203
+ if self.visualization_view is not None:
204
+ self.visualization_view.connection = new_connection
205
+ # Refresh the collection browser to show new database's collections
206
+ if new_connection.is_connected:
207
+ self.collection_browser.refresh()
178
208
 
179
209
  def _on_connect(self):
180
210
  """Handle connect action."""
@@ -187,6 +217,11 @@ class MainWindow(QMainWindow):
187
217
  self.statusBar.showMessage("Disconnected")
188
218
  self.connection_changed.emit(False)
189
219
  self.collection_browser.clear()
220
+ # Clear info panel
221
+ self.info_panel.refresh_database_info()
222
+ else:
223
+ # Always clear collection browser on disconnect
224
+ self.collection_browser.clear()
190
225
 
191
226
  def _on_connection_status_changed(self, connected: bool):
192
227
  """Handle connection status change."""
@@ -194,24 +229,45 @@ class MainWindow(QMainWindow):
194
229
  self.statusBar.showMessage("Connected")
195
230
  self.connection_changed.emit(True)
196
231
  self._on_refresh_collections()
232
+ # Refresh info panel with new connection
233
+ self.info_panel.refresh_database_info()
197
234
  else:
198
235
  self.statusBar.showMessage("Connection failed")
199
236
  self.connection_changed.emit(False)
237
+ # Clear info panel and collection browser
238
+ self.info_panel.refresh_database_info()
239
+ self.collection_browser.clear()
200
240
 
201
241
  def _on_collection_selected(self, collection_name: str):
202
242
  """Handle collection selection."""
203
243
  self.current_collection = collection_name
204
244
  self.statusBar.showMessage(f"Collection: {collection_name}")
205
245
 
246
+ # Show loading dialog immediately
247
+ self.loading_dialog.show_loading(f"Loading collection '{collection_name}'...")
248
+
249
+ # Update views with new collection - use QTimer to allow loading dialog to appear first
250
+ QTimer.singleShot(10, lambda: self._update_views_for_collection(collection_name))
251
+
252
+ def _update_views_for_collection(self, collection_name: str):
253
+ """Update all views with the selected collection."""
206
254
  # Update all views with new collection
255
+ self.info_panel.set_collection(collection_name)
207
256
  self.metadata_view.set_collection(collection_name)
208
257
  self.search_view.set_collection(collection_name)
209
- self.visualization_view.set_collection(collection_name)
258
+ # Only update visualization if it's been created
259
+ if self.visualization_view is not None:
260
+ self.visualization_view.set_collection(collection_name)
261
+
262
+ # Hide loading dialog
263
+ self.loading_dialog.hide_loading()
210
264
 
211
265
  def _on_refresh_collections(self):
212
266
  """Refresh collection list."""
213
267
  if self.connection.is_connected:
214
268
  self.collection_browser.refresh()
269
+ # Also refresh database info (collection count may have changed)
270
+ self.info_panel.refresh_database_info()
215
271
 
216
272
  def _on_new_collection(self):
217
273
  """Create a new collection."""
@@ -5,7 +5,7 @@ from PySide6.QtWidgets import (
5
5
  QPushButton, QDialog, QFormLayout, QLineEdit,
6
6
  QRadioButton, QButtonGroup, QGroupBox, QFileDialog, QComboBox, QApplication, QCheckBox
7
7
  )
8
- from PySide6.QtCore import Signal
8
+ from PySide6.QtCore import Signal, QThread
9
9
 
10
10
  from vector_inspector.core.connections.base_connection import VectorDBConnection
11
11
  from vector_inspector.core.connections.chroma_connection import ChromaDBConnection
@@ -14,6 +14,28 @@ from vector_inspector.ui.components.loading_dialog import LoadingDialog
14
14
  from vector_inspector.services.settings_service import SettingsService
15
15
 
16
16
 
17
+ class ConnectionThread(QThread):
18
+ """Background thread for connecting to database."""
19
+
20
+ finished = Signal(bool, list) # success, collections
21
+
22
+ def __init__(self, connection):
23
+ super().__init__()
24
+ self.connection = connection
25
+
26
+ def run(self):
27
+ """Connect to database and get collections."""
28
+ try:
29
+ success = self.connection.connect()
30
+ if success:
31
+ collections = self.connection.list_collections()
32
+ self.finished.emit(True, collections)
33
+ else:
34
+ self.finished.emit(False, [])
35
+ except Exception:
36
+ self.finished.emit(False, [])
37
+
38
+
17
39
  class ConnectionDialog(QDialog):
18
40
  """Dialog for configuring database connection."""
19
41
 
@@ -175,6 +197,9 @@ class ConnectionDialog(QDialog):
175
197
 
176
198
  def get_connection_config(self):
177
199
  """Get connection configuration from dialog."""
200
+ # Get current provider from combo box to ensure it's up to date
201
+ self.provider = self.provider_combo.currentData()
202
+
178
203
  config = {"provider": self.provider}
179
204
 
180
205
  if self.persistent_radio.isChecked():
@@ -294,6 +319,7 @@ class ConnectionView(QWidget):
294
319
  self.connection = connection
295
320
  self.loading_dialog = LoadingDialog("Connecting to database...", self)
296
321
  self.settings_service = SettingsService()
322
+ self.connection_thread = None
297
323
  self._setup_ui()
298
324
 
299
325
  # Try to auto-connect if enabled in settings
@@ -339,7 +365,6 @@ class ConnectionView(QWidget):
339
365
  def _connect_with_config(self, config: dict):
340
366
  """Connect to database with given configuration."""
341
367
  self.loading_dialog.show_loading("Connecting to database...")
342
- QApplication.processEvents()
343
368
 
344
369
  provider = config.get("provider", "chromadb")
345
370
  conn_type = config.get("type")
@@ -367,11 +392,25 @@ class ConnectionView(QWidget):
367
392
  else: # ephemeral
368
393
  self.connection = ChromaDBConnection()
369
394
 
395
+ # Store config for later use
396
+ self._pending_config = config
397
+
370
398
  # Notify parent that connection instance changed
371
399
  self.connection_created.emit(self.connection)
372
- success = self.connection.connect()
373
-
400
+
401
+ # Start background thread to connect
402
+ self.connection_thread = ConnectionThread(self.connection)
403
+ self.connection_thread.finished.connect(self._on_connection_finished)
404
+ self.connection_thread.start()
405
+
406
+ def _on_connection_finished(self, success: bool, collections: list):
407
+ """Handle connection thread completion."""
408
+ self.loading_dialog.hide_loading()
409
+
374
410
  if success:
411
+ config = self._pending_config
412
+ provider = config.get("provider", "chromadb")
413
+
375
414
  # Show provider, path/host + collection count for clarity
376
415
  details = []
377
416
  details.append(f"provider: {provider}")
@@ -380,7 +419,6 @@ class ConnectionView(QWidget):
380
419
  if hasattr(self.connection, 'host') and self.connection.host:
381
420
  port = getattr(self.connection, 'port', None)
382
421
  details.append(f"host: {self.connection.host}:{port}")
383
- collections = self.connection.list_collections()
384
422
  count_text = f"collections: {len(collections)}"
385
423
  info = ", ".join(details)
386
424
  self.status_label.setText(f"Status: Connected ({info}, {count_text})")
@@ -401,9 +439,6 @@ class ConnectionView(QWidget):
401
439
  self.disconnect_button.setEnabled(False)
402
440
  self.connection_changed.emit(False)
403
441
 
404
- # Close loading dialog after everything is complete
405
- self.loading_dialog.hide_loading()
406
-
407
442
  def _disconnect(self):
408
443
  """Disconnect from database."""
409
444
  self.connection.disconnect()
@@ -0,0 +1,287 @@
1
+ """Information panel for displaying database and collection metadata."""
2
+
3
+ from typing import Optional, Dict, Any
4
+ from PySide6.QtWidgets import (
5
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel,
6
+ QGroupBox, QScrollArea, QFrame
7
+ )
8
+ from PySide6.QtCore import Qt, QObject
9
+
10
+ from vector_inspector.core.connections.base_connection import VectorDBConnection
11
+ from vector_inspector.core.connections.chroma_connection import ChromaDBConnection
12
+ from vector_inspector.core.connections.qdrant_connection import QdrantConnection
13
+
14
+
15
+ class InfoPanel(QWidget):
16
+ """Panel for displaying database and collection information."""
17
+
18
+ def __init__(self, connection: VectorDBConnection, parent=None):
19
+ super().__init__(parent)
20
+ self.connection = connection
21
+ self.current_collection: str = ""
22
+ self._setup_ui()
23
+
24
+ def _setup_ui(self):
25
+ """Setup widget UI."""
26
+ layout = QVBoxLayout(self)
27
+
28
+ # Create scroll area for content
29
+ scroll = QScrollArea()
30
+ scroll.setWidgetResizable(True)
31
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
32
+
33
+ # Container for all info sections
34
+ container = QWidget()
35
+ container_layout = QVBoxLayout(container)
36
+ container_layout.setSpacing(10)
37
+
38
+ # Database Information Section
39
+ self.db_group = QGroupBox("Database Information")
40
+ db_layout = QVBoxLayout()
41
+
42
+ self.provider_label = self._create_info_row("Provider:", "Not connected")
43
+ self.connection_type_label = self._create_info_row("Connection Type:", "N/A")
44
+ self.endpoint_label = self._create_info_row("Endpoint:", "N/A")
45
+ self.api_key_label = self._create_info_row("API Key:", "N/A")
46
+ self.status_label = self._create_info_row("Status:", "Disconnected")
47
+ self.collections_count_label = self._create_info_row("Total Collections:", "0")
48
+
49
+ db_layout.addWidget(self.provider_label)
50
+ db_layout.addWidget(self.connection_type_label)
51
+ db_layout.addWidget(self.endpoint_label)
52
+ db_layout.addWidget(self.api_key_label)
53
+ db_layout.addWidget(self.status_label)
54
+ db_layout.addWidget(self.collections_count_label)
55
+
56
+ self.db_group.setLayout(db_layout)
57
+ container_layout.addWidget(self.db_group)
58
+
59
+ # Collection Information Section
60
+ self.collection_group = QGroupBox("Collection Information")
61
+ collection_layout = QVBoxLayout()
62
+
63
+ self.collection_name_label = self._create_info_row("Name:", "No collection selected")
64
+ self.vector_dim_label = self._create_info_row("Vector Dimension:", "N/A")
65
+ self.distance_metric_label = self._create_info_row("Distance Metric:", "N/A")
66
+ self.total_points_label = self._create_info_row("Total Points:", "0")
67
+
68
+ collection_layout.addWidget(self.collection_name_label)
69
+ collection_layout.addWidget(self.vector_dim_label)
70
+ collection_layout.addWidget(self.distance_metric_label)
71
+ collection_layout.addWidget(self.total_points_label)
72
+
73
+ # Payload Schema subsection
74
+ schema_label = QLabel("<b>Payload Schema:</b>")
75
+ collection_layout.addWidget(schema_label)
76
+
77
+ self.schema_label = QLabel("N/A")
78
+ self.schema_label.setWordWrap(True)
79
+ self.schema_label.setStyleSheet("color: gray; padding-left: 20px;")
80
+ collection_layout.addWidget(self.schema_label)
81
+
82
+ # Provider-specific details
83
+ provider_details_label = QLabel("<b>Provider-Specific Details:</b>")
84
+ collection_layout.addWidget(provider_details_label)
85
+
86
+ self.provider_details_label = QLabel("N/A")
87
+ self.provider_details_label.setWordWrap(True)
88
+ self.provider_details_label.setStyleSheet("color: gray; padding-left: 20px;")
89
+ collection_layout.addWidget(self.provider_details_label)
90
+
91
+ self.collection_group.setLayout(collection_layout)
92
+ container_layout.addWidget(self.collection_group)
93
+
94
+ # Add stretch to push content to top
95
+ container_layout.addStretch()
96
+
97
+ scroll.setWidget(container)
98
+ layout.addWidget(scroll)
99
+
100
+ # Initial state
101
+ self.refresh_database_info()
102
+
103
+ def _create_info_row(self, label: str, value: str) -> QWidget:
104
+ """Create a row with label and value."""
105
+ row = QWidget()
106
+ row_layout = QHBoxLayout(row)
107
+ row_layout.setContentsMargins(0, 2, 0, 2)
108
+
109
+ label_widget = QLabel(f"<b>{label}</b>")
110
+ label_widget.setMinimumWidth(150)
111
+ row_layout.addWidget(label_widget)
112
+
113
+ value_widget = QLabel(value)
114
+ value_widget.setWordWrap(True)
115
+ value_widget.setStyleSheet("color: white;")
116
+ row_layout.addWidget(value_widget, stretch=1)
117
+
118
+ # Store value widget for later updates (use setProperty for type safety)
119
+ row.setProperty("value_label", value_widget)
120
+
121
+ return row
122
+
123
+ def refresh_database_info(self):
124
+ """Refresh database connection information."""
125
+ if not self.connection or not self.connection.is_connected:
126
+ self._update_label(self.provider_label, "Not connected")
127
+ self._update_label(self.connection_type_label, "N/A")
128
+ self._update_label(self.endpoint_label, "N/A")
129
+ self._update_label(self.api_key_label, "N/A")
130
+ self._update_label(self.status_label, "Disconnected")
131
+ self._update_label(self.collections_count_label, "0")
132
+ # Also clear collection info
133
+ self._update_label(self.collection_name_label, "No collection selected")
134
+ self._update_label(self.vector_dim_label, "N/A")
135
+ self._update_label(self.distance_metric_label, "N/A")
136
+ self._update_label(self.total_points_label, "0")
137
+ self.schema_label.setText("N/A")
138
+ self.provider_details_label.setText("N/A")
139
+ return
140
+
141
+ # Get provider name
142
+ provider_name = self.connection.__class__.__name__.replace("Connection", "")
143
+ self._update_label(self.provider_label, provider_name)
144
+
145
+ # Get connection details
146
+ if isinstance(self.connection, ChromaDBConnection):
147
+ if self.connection.path:
148
+ self._update_label(self.connection_type_label, "Persistent (Local)")
149
+ self._update_label(self.endpoint_label, self.connection.path)
150
+ elif self.connection.host and self.connection.port:
151
+ self._update_label(self.connection_type_label, "HTTP (Remote)")
152
+ self._update_label(self.endpoint_label, f"{self.connection.host}:{self.connection.port}")
153
+ else:
154
+ self._update_label(self.connection_type_label, "Ephemeral (In-Memory)")
155
+ self._update_label(self.endpoint_label, "N/A")
156
+ self._update_label(self.api_key_label, "Not required")
157
+
158
+ elif isinstance(self.connection, QdrantConnection):
159
+ if self.connection.path:
160
+ self._update_label(self.connection_type_label, "Embedded (Local)")
161
+ self._update_label(self.endpoint_label, self.connection.path)
162
+ elif self.connection.url:
163
+ self._update_label(self.connection_type_label, "Remote (URL)")
164
+ self._update_label(self.endpoint_label, self.connection.url)
165
+ elif self.connection.host:
166
+ self._update_label(self.connection_type_label, "Remote (Host)")
167
+ self._update_label(self.endpoint_label, f"{self.connection.host}:{self.connection.port}")
168
+ else:
169
+ self._update_label(self.connection_type_label, "In-Memory")
170
+ self._update_label(self.endpoint_label, "N/A")
171
+
172
+ if self.connection.api_key:
173
+ self._update_label(self.api_key_label, "Present (hidden)")
174
+ else:
175
+ self._update_label(self.api_key_label, "Not configured")
176
+ else:
177
+ self._update_label(self.connection_type_label, "Unknown")
178
+ self._update_label(self.endpoint_label, "N/A")
179
+ self._update_label(self.api_key_label, "Unknown")
180
+
181
+ # Status
182
+ self._update_label(self.status_label, "Connected" if self.connection.is_connected else "Disconnected")
183
+
184
+ # Count collections
185
+ try:
186
+ collections = self.connection.list_collections()
187
+ self._update_label(self.collections_count_label, str(len(collections)))
188
+ except Exception as e:
189
+ self._update_label(self.collections_count_label, "Error")
190
+
191
+ def refresh_collection_info(self):
192
+ """Refresh collection-specific information."""
193
+ if not self.current_collection or not self.connection or not self.connection.is_connected:
194
+ self._update_label(self.collection_name_label, "No collection selected")
195
+ self._update_label(self.vector_dim_label, "N/A")
196
+ self._update_label(self.distance_metric_label, "N/A")
197
+ self._update_label(self.total_points_label, "0")
198
+ self.schema_label.setText("N/A")
199
+ self.provider_details_label.setText("N/A")
200
+ return
201
+
202
+ try:
203
+ # Get collection info
204
+ collection_info = self.connection.get_collection_info(self.current_collection)
205
+
206
+ if not collection_info:
207
+ self._update_label(self.collection_name_label, self.current_collection)
208
+ self._update_label(self.vector_dim_label, "Unable to retrieve")
209
+ self._update_label(self.distance_metric_label, "Unable to retrieve")
210
+ self._update_label(self.total_points_label, "Unable to retrieve")
211
+ self.schema_label.setText("Unable to retrieve collection info")
212
+ self.provider_details_label.setText("N/A")
213
+ return
214
+
215
+ # Update basic info
216
+ self._update_label(self.collection_name_label, self.current_collection)
217
+
218
+ # Vector dimension
219
+ vector_dim = collection_info.get("vector_dimension", "Unknown")
220
+ self._update_label(self.vector_dim_label, str(vector_dim))
221
+
222
+ # Distance metric
223
+ distance = collection_info.get("distance_metric", "Unknown")
224
+ self._update_label(self.distance_metric_label, distance)
225
+
226
+ # Total points
227
+ count = collection_info.get("count", 0)
228
+ self._update_label(self.total_points_label, f"{count:,}")
229
+
230
+ # Metadata schema
231
+ metadata_fields = collection_info.get("metadata_fields", [])
232
+ if metadata_fields:
233
+ schema_text = "\n".join([f"• {field}" for field in sorted(metadata_fields)])
234
+ self.schema_label.setText(schema_text)
235
+ self.schema_label.setStyleSheet("color: white; padding-left: 20px; font-family: monospace;")
236
+ else:
237
+ self.schema_label.setText("No metadata fields found")
238
+ self.schema_label.setStyleSheet("color: gray; padding-left: 20px;")
239
+
240
+ # Provider-specific details
241
+ details_list = []
242
+
243
+ if isinstance(self.connection, ChromaDBConnection):
244
+ details_list.append("• Provider: ChromaDB")
245
+ details_list.append("• Supports: Documents, Metadata, Embeddings")
246
+ details_list.append("• Default embedding: all-MiniLM-L6-v2")
247
+
248
+ elif isinstance(self.connection, QdrantConnection):
249
+ details_list.append("• Provider: Qdrant")
250
+ details_list.append("• Supports: Points, Payload, Vectors")
251
+ # Get additional Qdrant-specific info if available
252
+ if "config" in collection_info:
253
+ config = collection_info["config"]
254
+ if "hnsw_config" in config:
255
+ hnsw = config["hnsw_config"]
256
+ details_list.append(f"• HNSW M: {hnsw.get('m', 'N/A')}")
257
+ details_list.append(f"• HNSW ef_construct: {hnsw.get('ef_construct', 'N/A')}")
258
+ if "optimizer_config" in config:
259
+ opt = config["optimizer_config"]
260
+ details_list.append(f"• Indexing threshold: {opt.get('indexing_threshold', 'N/A')}")
261
+
262
+ if details_list:
263
+ self.provider_details_label.setText("\n".join(details_list))
264
+ self.provider_details_label.setStyleSheet("color: white; padding-left: 20px; font-family: monospace;")
265
+ else:
266
+ self.provider_details_label.setText("No additional details available")
267
+ self.provider_details_label.setStyleSheet("color: gray; padding-left: 20px;")
268
+
269
+ except Exception as e:
270
+ self._update_label(self.collection_name_label, self.current_collection)
271
+ self._update_label(self.vector_dim_label, "Error")
272
+ self._update_label(self.distance_metric_label, "Error")
273
+ self._update_label(self.total_points_label, "Error")
274
+ self.schema_label.setText(f"Error: {str(e)}")
275
+ self.schema_label.setStyleSheet("color: red; padding-left: 20px;")
276
+ self.provider_details_label.setText("N/A")
277
+
278
+ def set_collection(self, collection_name: str):
279
+ """Set the current collection and refresh its information."""
280
+ self.current_collection = collection_name
281
+ self.refresh_collection_info()
282
+
283
+ def _update_label(self, row_widget: QWidget, value: str):
284
+ """Update the value label in an info row."""
285
+ value_label = row_widget.property("value_label")
286
+ if value_label and isinstance(value_label, QLabel):
287
+ value_label.setText(value)
@@ -7,7 +7,7 @@ from PySide6.QtWidgets import (
7
7
  QLineEdit, QComboBox, QGroupBox, QHeaderView, QMessageBox, QDialog,
8
8
  QFileDialog, QMenu
9
9
  )
10
- from PySide6.QtCore import Qt, QTimer
10
+ from PySide6.QtCore import Qt, QTimer, QThread, Signal
11
11
 
12
12
  from vector_inspector.core.connections.base_connection import VectorDBConnection
13
13
  from vector_inspector.ui.components.item_dialog import ItemDialog
@@ -19,6 +19,37 @@ from vector_inspector.services.settings_service import SettingsService
19
19
  from PySide6.QtWidgets import QApplication
20
20
 
21
21
 
22
+ class DataLoadThread(QThread):
23
+ """Background thread for loading collection data."""
24
+
25
+ finished = Signal(dict)
26
+ error = Signal(str)
27
+
28
+ def __init__(self, connection, collection, page_size, offset, server_filter):
29
+ super().__init__()
30
+ self.connection = connection
31
+ self.collection = collection
32
+ self.page_size = page_size
33
+ self.offset = offset
34
+ self.server_filter = server_filter
35
+
36
+ def run(self):
37
+ """Load data from database."""
38
+ try:
39
+ data = self.connection.get_all_items(
40
+ self.collection,
41
+ limit=self.page_size,
42
+ offset=self.offset,
43
+ where=self.server_filter
44
+ )
45
+ if data:
46
+ self.finished.emit(data)
47
+ else:
48
+ self.error.emit("Failed to load data")
49
+ except Exception as e:
50
+ self.error.emit(str(e))
51
+
52
+
22
53
  class MetadataView(QWidget):
23
54
  """View for browsing collection data and metadata."""
24
55
 
@@ -31,6 +62,7 @@ class MetadataView(QWidget):
31
62
  self.current_page = 0
32
63
  self.loading_dialog = LoadingDialog("Loading data...", self)
33
64
  self.settings_service = SettingsService()
65
+ self.load_thread: Optional[DataLoadThread] = None
34
66
 
35
67
  # Debounce timer for filter changes
36
68
  self.filter_reload_timer = QTimer()
@@ -145,21 +177,11 @@ class MetadataView(QWidget):
145
177
  self.current_collection = collection_name
146
178
  self.current_page = 0
147
179
 
148
- # Show loading dialog at the start
149
- self.loading_dialog.show_loading("Loading collection data...")
150
- QApplication.processEvents()
180
+ # Update filter builder with supported operators
181
+ operators = self.connection.get_supported_filter_operators()
182
+ self.filter_builder.set_operators(operators)
151
183
 
152
- try:
153
- # Update filter builder with supported operators
154
- operators = self.connection.get_supported_filter_operators()
155
- self.filter_builder.set_operators(operators)
156
-
157
- self._load_data_internal()
158
-
159
- # Ensure UI is fully updated before hiding loading dialog
160
- QApplication.processEvents()
161
- finally:
162
- self.loading_dialog.hide_loading()
184
+ self._load_data_internal()
163
185
 
164
186
  def _load_data(self):
165
187
  """Load data from current collection (with loading dialog)."""
@@ -182,35 +204,53 @@ class MetadataView(QWidget):
182
204
  self.table.setRowCount(0)
183
205
  return
184
206
 
207
+ # Cancel any existing load thread
208
+ if self.load_thread and self.load_thread.isRunning():
209
+ self.load_thread.quit()
210
+ self.load_thread.wait()
211
+
185
212
  offset = self.current_page * self.page_size
186
213
 
187
214
  # Get filters split into server-side and client-side
188
215
  server_filter = None
189
- client_filters = []
216
+ self.client_filters = []
190
217
  if self.filter_group.isChecked() and self.filter_builder.has_filters():
191
- server_filter, client_filters = self.filter_builder.get_filters_split()
218
+ server_filter, self.client_filters = self.filter_builder.get_filters_split()
192
219
 
193
- data = self.connection.get_all_items(
220
+ # Start background thread to load data
221
+ self.load_thread = DataLoadThread(
222
+ self.connection,
194
223
  self.current_collection,
195
- limit=self.page_size,
196
- offset=offset,
197
- where=server_filter
224
+ self.page_size,
225
+ offset,
226
+ server_filter
198
227
  )
199
-
228
+ self.load_thread.finished.connect(self._on_data_loaded)
229
+ self.load_thread.error.connect(self._on_load_error)
230
+ self.load_thread.start()
231
+
232
+ def _on_data_loaded(self, data: Dict[str, Any]):
233
+ """Handle data loaded from background thread."""
200
234
  # Apply client-side filters if any
201
- if client_filters and data:
202
- data = apply_client_side_filters(data, client_filters)
235
+ if self.client_filters and data:
236
+ data = apply_client_side_filters(data, self.client_filters)
203
237
 
204
238
  if not data:
205
- self.status_label.setText("Failed to load data")
239
+ self.status_label.setText("No data after filtering")
206
240
  self.table.setRowCount(0)
207
241
  return
242
+
208
243
  self.current_data = data
209
244
  self._populate_table(data)
210
245
  self._update_pagination_controls()
211
246
 
212
247
  # Update filter builder with available metadata fields
213
248
  self._update_filter_fields(data)
249
+
250
+ def _on_load_error(self, error_msg: str):
251
+ """Handle error from background thread."""
252
+ self.status_label.setText(f"Failed to load data: {error_msg}")
253
+ self.table.setRowCount(0)
214
254
 
215
255
  def _update_filter_fields(self, data: Dict[str, Any]):
216
256
  """Update filter builder with available metadata field names."""
@@ -1,5 +1,6 @@
1
1
  """Vector visualization view with dimensionality reduction."""
2
2
 
3
+ from __future__ import annotations
3
4
  from typing import Optional, Dict, Any
4
5
  import traceback
5
6
  from PySide6.QtWidgets import (
@@ -8,7 +9,6 @@ from PySide6.QtWidgets import (
8
9
  )
9
10
  from PySide6.QtCore import Qt, QThread, Signal
10
11
  from PySide6.QtWebEngineWidgets import QWebEngineView
11
- import plotly.graph_objects as go
12
12
  import numpy as np
13
13
 
14
14
  from vector_inspector.core.connections.base_connection import VectorDBConnection
@@ -154,7 +154,7 @@ class VisualizationView(QWidget):
154
154
  self.visualization_thread.error.connect(self._on_reduction_error)
155
155
  self.visualization_thread.start()
156
156
 
157
- def _on_reduction_finished(self, reduced_data: np.ndarray):
157
+ def _on_reduction_finished(self, reduced_data: Any):
158
158
  """Handle dimensionality reduction completion."""
159
159
  self.reduced_data = reduced_data
160
160
  self._create_plot()
@@ -172,6 +172,10 @@ class VisualizationView(QWidget):
172
172
  """Create plotly visualization."""
173
173
  if self.reduced_data is None or self.current_data is None:
174
174
  return
175
+
176
+ # Lazy import plotly
177
+ from vector_inspector.utils.lazy_imports import get_plotly
178
+ go = get_plotly()
175
179
 
176
180
  ids = self.current_data.get("ids", [])
177
181
  documents = self.current_data.get("documents", [])
@@ -0,0 +1 @@
1
+ """Utilities for Vector Inspector."""
@@ -0,0 +1,49 @@
1
+ """Lazy import utilities for performance optimization."""
2
+
3
+ from typing import Any
4
+
5
+ _plotly_cache = None
6
+ _sklearn_cache = {}
7
+ _numpy_cache = None
8
+
9
+
10
+ def get_plotly():
11
+ """Lazy import plotly graph_objects."""
12
+ global _plotly_cache
13
+ if _plotly_cache is None:
14
+ import plotly.graph_objects as go
15
+ _plotly_cache = go
16
+ return _plotly_cache
17
+
18
+
19
+ def get_numpy():
20
+ """Lazy import numpy."""
21
+ global _numpy_cache
22
+ if _numpy_cache is None:
23
+ import numpy as np
24
+ _numpy_cache = np
25
+ return _numpy_cache
26
+
27
+
28
+ def get_sklearn_model(model_name: str) -> Any:
29
+ """
30
+ Lazy import sklearn models.
31
+
32
+ Args:
33
+ model_name: Name of the model ('PCA', 'TSNE', 'UMAP')
34
+
35
+ Returns:
36
+ The model class
37
+ """
38
+ global _sklearn_cache
39
+ if model_name not in _sklearn_cache:
40
+ if model_name == 'PCA':
41
+ from sklearn.decomposition import PCA
42
+ _sklearn_cache['PCA'] = PCA
43
+ elif model_name == 'TSNE':
44
+ from sklearn.manifold import TSNE
45
+ _sklearn_cache['TSNE'] = TSNE
46
+ elif model_name == 'UMAP':
47
+ import umap
48
+ _sklearn_cache['UMAP'] = umap.UMAP
49
+ return _sklearn_cache[model_name]