Soumik-404 commited on
Commit
99c519a
·
1 Parent(s): d764ccd

add vision embedding

Browse files
Dockerfile CHANGED
@@ -24,7 +24,7 @@ RUN pip install --no-cache-dir "youtube-transcript-api>=1.2.4"
24
 
25
  COPY --chown=appuser:appuser . .
26
 
27
- RUN mkdir -p /app/models && python3 -c "from huggingface_hub import snapshot_download; snapshot_download(repo_id='ibm-granite/granite-embedding-small-english-r2', local_dir='/app/models/bge-384'); snapshot_download(repo_id='nomic-ai/modernbert-embed-base', local_dir='/app/models/bge-768'); snapshot_download(repo_id='lightonai/modernbert-embed-large', local_dir='/app/models/bge-1024')" && chown -R appuser:appuser /app/models
28
 
29
  RUN mkdir -p /app/logs && \
30
  chown -R appuser:appuser /app/logs
 
24
 
25
  COPY --chown=appuser:appuser . .
26
 
27
+ RUN mkdir -p /app/models && python3 -c "from huggingface_hub import snapshot_download; snapshot_download(repo_id='ibm-granite/granite-embedding-small-english-r2', local_dir='/app/models/bge-384'); snapshot_download(repo_id='nomic-ai/modernbert-embed-base', local_dir='/app/models/bge-768'); snapshot_download(repo_id='lightonai/modernbert-embed-large', local_dir='/app/models/bge-1024'); snapshot_download(repo_id='nomic-ai/nomic-embed-vision-v1.5', local_dir='/app/models/vision')" && chown -R appuser:appuser /app/models
28
 
29
  RUN mkdir -p /app/logs && \
30
  chown -R appuser:appuser /app/logs
app/api/server.py CHANGED
@@ -40,7 +40,9 @@ async def lifespan(app: FastAPI):
40
  _logger.info("Initializing embedding service (loading all models)...")
41
  loop = asyncio.get_running_loop()
42
  await loop.run_in_executor(None, _embedding_service.load_all_models)
43
- _logger.info("Embedding service initialized with dims: %s", _embedding_service.loaded_dimensions)
 
 
44
 
45
  asyncio.create_task(_self_ping())
46
  yield
 
40
  _logger.info("Initializing embedding service (loading all models)...")
41
  loop = asyncio.get_running_loop()
42
  await loop.run_in_executor(None, _embedding_service.load_all_models)
43
+ await loop.run_in_executor(None, _embedding_service.load_vision_model)
44
+ _logger.info("Embedding service initialized with dims: %s, vision=%s",
45
+ _embedding_service.loaded_dimensions, _embedding_service._vision_loaded)
46
 
47
  asyncio.create_task(_self_ping())
48
  yield
app/api/v1/embeddings.py CHANGED
@@ -2,20 +2,54 @@ from __future__ import annotations
2
 
3
  import asyncio
4
  import concurrent.futures
 
5
  import os
6
  import time
 
7
 
8
- from fastapi import APIRouter, Depends, HTTPException
 
 
9
 
10
  from app.api.deps import require_auth, get_embeddings_service
 
11
  from app.core.logger import get_logger
12
- from app.models.schemas import EmbeddingItem, EmbeddingRequest, EmbeddingResponse
13
  from app.services.embeddings_service import EmbeddingService
14
 
15
  router = APIRouter()
16
  _logger = get_logger(__name__)
 
17
  _MAX_WORKERS = min(32, (os.cpu_count() or 1) + 4)
18
  _thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=_MAX_WORKERS)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
 
21
  @router.post(
@@ -59,11 +93,7 @@ async def create_embeddings(
59
  failed_count=len(body.content),
60
  error_message=str(exc),
61
  results=[
62
- EmbeddingItem(
63
- success=False,
64
- time_ms=0,
65
- error_message=str(exc),
66
- )
67
  for _ in body.content
68
  ],
69
  )
@@ -89,3 +119,184 @@ async def create_embeddings(
89
  failed_count=0,
90
  results=results,
91
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import asyncio
4
  import concurrent.futures
5
+ import io
6
  import os
7
  import time
8
+ from typing import Annotated, List, Optional
9
 
10
+ import httpx
11
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
12
+ from PIL import Image
13
 
14
  from app.api.deps import require_auth, get_embeddings_service
15
+ from app.config import get_settings
16
  from app.core.logger import get_logger
17
+ from app.models.schemas import EmbeddingItem, EmbeddingRequest, EmbeddingResponse, VisionUrlRequest
18
  from app.services.embeddings_service import EmbeddingService
19
 
20
  router = APIRouter()
21
  _logger = get_logger(__name__)
22
+ _settings = get_settings()
23
  _MAX_WORKERS = min(32, (os.cpu_count() or 1) + 4)
24
  _thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=_MAX_WORKERS)
25
+ _MAX_VISION_ITEMS = 5
26
+ _MAX_IMAGE_BYTES = 15 * 1024 * 1024
27
+
28
+
29
+ def _validate_image(raw: bytes, source: str) -> Image.Image:
30
+ if len(raw) > _MAX_IMAGE_BYTES:
31
+ raise ValueError(f"Image {source} exceeds 15 MB limit")
32
+ try:
33
+ img = Image.open(io.BytesIO(raw))
34
+ img.load()
35
+ if img.mode != "RGB":
36
+ img = img.convert("RGB")
37
+ return img
38
+ except Exception as exc:
39
+ raise ValueError(f"Invalid image {source}: {exc}")
40
+
41
+
42
+ async def _download_image(url: str) -> bytes:
43
+ try:
44
+ async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
45
+ resp = await client.get(url)
46
+ resp.raise_for_status()
47
+ ctype = resp.headers.get("content-type", "")
48
+ if not ctype.startswith("image/"):
49
+ raise ValueError(f"URL {url} returned non-image Content-Type: {ctype}")
50
+ return resp.content
51
+ except httpx.HTTPError as exc:
52
+ raise ValueError(f"Failed to download {url}: {exc}")
53
 
54
 
55
  @router.post(
 
93
  failed_count=len(body.content),
94
  error_message=str(exc),
95
  results=[
96
+ EmbeddingItem(success=False, time_ms=0, error_message=str(exc))
 
 
 
 
97
  for _ in body.content
98
  ],
99
  )
 
119
  failed_count=0,
120
  results=results,
121
  )
122
+
123
+
124
+ @router.post(
125
+ "/embeddings/vision/file",
126
+ response_model=EmbeddingResponse,
127
+ summary="Generate embeddings from uploaded images",
128
+ )
129
+ async def create_vision_embeddings_file(
130
+ files: Annotated[List[UploadFile], File(description="Image files to embed (max 5)")],
131
+ token: str = Depends(require_auth),
132
+ embedding_service: EmbeddingService = Depends(get_embeddings_service),
133
+ ) -> EmbeddingResponse:
134
+ if not files:
135
+ raise HTTPException(status_code=400, detail={"success": False, "message": "No files provided."})
136
+ if len(files) > _MAX_VISION_ITEMS:
137
+ raise HTTPException(status_code=400, detail={"success": False, "message": f"Maximum {_MAX_VISION_ITEMS} images per request."})
138
+
139
+ if not embedding_service._vision_loaded:
140
+ raise HTTPException(status_code=503, detail={"success": False, "message": "Vision model not loaded."})
141
+
142
+ _logger.info("Vision embedding file request: files=%s", len(files))
143
+
144
+ dim = embedding_service.vision_dimension
145
+ start = time.perf_counter()
146
+ images: List[Image.Image] = []
147
+ item_results: List[EmbeddingItem] = []
148
+
149
+ for f in files:
150
+ t0 = time.perf_counter()
151
+ try:
152
+ raw = await f.read()
153
+ img = await asyncio.get_running_loop().run_in_executor(_thread_pool, _validate_image, raw, f.filename or "unknown")
154
+ images.append(img)
155
+ except Exception as exc:
156
+ elapsed = (time.perf_counter() - t0) * 1000
157
+ item_results.append(EmbeddingItem(
158
+ success=False,
159
+ time_ms=round(elapsed, 3),
160
+ error_message=str(exc),
161
+ ))
162
+
163
+ if not images:
164
+ total_ms = (time.perf_counter() - start) * 1000
165
+ return EmbeddingResponse(
166
+ success=False,
167
+ time_ms=round(total_ms, 3),
168
+ success_count=0,
169
+ failed_count=len(item_results),
170
+ error_message="No valid images could be processed.",
171
+ results=item_results,
172
+ )
173
+
174
+ try:
175
+ loop = asyncio.get_running_loop()
176
+ vectors = await loop.run_in_executor(
177
+ _thread_pool,
178
+ embedding_service.generate_image_embedding,
179
+ images,
180
+ )
181
+ except Exception as exc:
182
+ elapsed = (time.perf_counter() - start) * 1000
183
+ _logger.error("Vision embedding error: %s", exc)
184
+ for _ in range(len(images) - len(item_results)):
185
+ item_results.append(EmbeddingItem(success=False, time_ms=0, error_message=str(exc)))
186
+ return EmbeddingResponse(
187
+ success=False,
188
+ time_ms=round(elapsed, 3),
189
+ success_count=0,
190
+ failed_count=len(item_results),
191
+ error_message=str(exc),
192
+ results=item_results,
193
+ )
194
+
195
+ total_ms = (time.perf_counter() - start) * 1000
196
+ for i, vec in enumerate(vectors):
197
+ item_results.append(EmbeddingItem(
198
+ success=True,
199
+ time_ms=round(total_ms / len(vectors), 3),
200
+ embeddings=vec,
201
+ dimension=dim,
202
+ ))
203
+
204
+ success_count = sum(1 for r in item_results if r.success)
205
+ failed_count = len(item_results) - success_count
206
+ _logger.info("Vision embedding success: items=%s, success=%s, failed=%s, total_ms=%s",
207
+ len(item_results), success_count, failed_count, round(total_ms, 3))
208
+ return EmbeddingResponse(
209
+ success=failed_count == 0,
210
+ time_ms=round(total_ms, 3),
211
+ success_count=success_count,
212
+ failed_count=failed_count,
213
+ results=item_results,
214
+ )
215
+
216
+
217
+ @router.post(
218
+ "/embeddings/vision/url",
219
+ response_model=EmbeddingResponse,
220
+ summary="Generate embeddings from image URLs",
221
+ )
222
+ async def create_vision_embeddings_url(
223
+ body: VisionUrlRequest,
224
+ token: str = Depends(require_auth),
225
+ embedding_service: EmbeddingService = Depends(get_embeddings_service),
226
+ ) -> EmbeddingResponse:
227
+ if not embedding_service._vision_loaded:
228
+ raise HTTPException(status_code=503, detail={"success": False, "message": "Vision model not loaded."})
229
+
230
+ _logger.info("Vision embedding URL request: urls=%s", len(body.urls))
231
+
232
+ dim = embedding_service.vision_dimension
233
+ start = time.perf_counter()
234
+ images: List[Image.Image] = []
235
+ item_results: List[EmbeddingItem] = []
236
+
237
+ for url in body.urls:
238
+ t0 = time.perf_counter()
239
+ try:
240
+ raw = await _download_image(url)
241
+ img = await asyncio.get_running_loop().run_in_executor(_thread_pool, _validate_image, raw, url)
242
+ images.append(img)
243
+ except Exception as exc:
244
+ elapsed = (time.perf_counter() - t0) * 1000
245
+ item_results.append(EmbeddingItem(
246
+ success=False,
247
+ time_ms=round(elapsed, 3),
248
+ error_message=str(exc),
249
+ ))
250
+
251
+ if not images:
252
+ total_ms = (time.perf_counter() - start) * 1000
253
+ return EmbeddingResponse(
254
+ success=False,
255
+ time_ms=round(total_ms, 3),
256
+ success_count=0,
257
+ failed_count=len(item_results),
258
+ error_message="No valid images could be downloaded.",
259
+ results=item_results,
260
+ )
261
+
262
+ try:
263
+ loop = asyncio.get_running_loop()
264
+ vectors = await loop.run_in_executor(
265
+ _thread_pool,
266
+ embedding_service.generate_image_embedding,
267
+ images,
268
+ )
269
+ except Exception as exc:
270
+ elapsed = (time.perf_counter() - start) * 1000
271
+ _logger.error("Vision embedding error: %s", exc)
272
+ for _ in range(len(images) - len(item_results)):
273
+ item_results.append(EmbeddingItem(success=False, time_ms=0, error_message=str(exc)))
274
+ return EmbeddingResponse(
275
+ success=False,
276
+ time_ms=round(elapsed, 3),
277
+ success_count=0,
278
+ failed_count=len(item_results),
279
+ error_message=str(exc),
280
+ results=item_results,
281
+ )
282
+
283
+ total_ms = (time.perf_counter() - start) * 1000
284
+ for i, vec in enumerate(vectors):
285
+ item_results.append(EmbeddingItem(
286
+ success=True,
287
+ time_ms=round(total_ms / len(vectors), 3),
288
+ embeddings=vec,
289
+ dimension=dim,
290
+ ))
291
+
292
+ success_count = sum(1 for r in item_results if r.success)
293
+ failed_count = len(item_results) - success_count
294
+ _logger.info("Vision embedding success: items=%s, success=%s, failed=%s, total_ms=%s",
295
+ len(item_results), success_count, failed_count, round(total_ms, 3))
296
+ return EmbeddingResponse(
297
+ success=failed_count == 0,
298
+ time_ms=round(total_ms, 3),
299
+ success_count=success_count,
300
+ failed_count=failed_count,
301
+ results=item_results,
302
+ )
app/models/__init__.py CHANGED
@@ -15,6 +15,7 @@ from app.models.schemas import (
15
  SpacyLabelsResponse,
16
  SupportedFormatsResponse,
17
  UrlRequest,
 
18
  )
19
 
20
  __all__ = [
@@ -33,4 +34,5 @@ __all__ = [
33
  "InfoResponse",
34
  "SupportedFormatsResponse",
35
  "SpacyLabelsResponse",
 
36
  ]
 
15
  SpacyLabelsResponse,
16
  SupportedFormatsResponse,
17
  UrlRequest,
18
+ VisionUrlRequest,
19
  )
20
 
21
  __all__ = [
 
34
  "InfoResponse",
35
  "SupportedFormatsResponse",
36
  "SpacyLabelsResponse",
37
+ "VisionUrlRequest",
38
  ]
app/models/schemas.py CHANGED
@@ -254,3 +254,15 @@ class EmbeddingResponse(BaseModel):
254
  failed_count: int
255
  error_message: Optional[str] = None
256
  results: List[EmbeddingItem]
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  failed_count: int
255
  error_message: Optional[str] = None
256
  results: List[EmbeddingItem]
257
+
258
+
259
+ class VisionUrlRequest(BaseModel):
260
+ urls: List[str] = Field(..., min_length=1, max_length=5, description="Array of image URLs to embed (max 5)")
261
+
262
+ @field_validator("urls")
263
+ @classmethod
264
+ def validate_urls(cls, v: List[str]) -> List[str]:
265
+ for url in v:
266
+ if not url.startswith(("http://", "https://")):
267
+ raise ValueError(f"Invalid URL scheme: {url}")
268
+ return v
app/services/embeddings_service.py CHANGED
@@ -5,6 +5,7 @@ import os
5
  from typing import Dict, List, Optional
6
 
7
  import numpy as np
 
8
  from sentence_transformers import SentenceTransformer
9
 
10
  _logger = logging.getLogger(__name__)
@@ -15,6 +16,9 @@ _MODEL_MAP: Dict[int, str] = {
15
  1024: "lightonai/modernbert-embed-large",
16
  }
17
 
 
 
 
18
 
19
  class EmbeddingService:
20
  def __init__(self, models_dir: Optional[str] = None) -> None:
@@ -29,6 +33,8 @@ class EmbeddingService:
29
  self._device = "cpu"
30
 
31
  self._loaded_dimensions: List[int] = []
 
 
32
 
33
  def load_model(self, dimension: int) -> None:
34
  if dimension in self._models:
@@ -53,6 +59,21 @@ class EmbeddingService:
53
  for dim in _MODEL_MAP:
54
  self.load_model(dim)
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  def generate_embedding(self, text: List[str], dimension: int) -> List[List[float]]:
57
  if dimension not in self._models:
58
  raise ValueError(f"Model for dimension {dimension} not loaded")
@@ -65,9 +86,23 @@ class EmbeddingService:
65
  )
66
  return result.tolist()
67
 
 
 
 
 
 
 
 
 
 
 
68
  @property
69
  def loaded_dimensions(self) -> List[int]:
70
  return list(self._loaded_dimensions)
71
 
72
  def is_loaded(self, dimension: int) -> bool:
73
  return dimension in self._models
 
 
 
 
 
5
  from typing import Dict, List, Optional
6
 
7
  import numpy as np
8
+ from PIL import Image
9
  from sentence_transformers import SentenceTransformer
10
 
11
  _logger = logging.getLogger(__name__)
 
16
  1024: "lightonai/modernbert-embed-large",
17
  }
18
 
19
+ _VISION_MODEL_NAME = "nomic-ai/nomic-embed-vision-v1.5"
20
+ _VISION_DIMENSION = 768
21
+
22
 
23
  class EmbeddingService:
24
  def __init__(self, models_dir: Optional[str] = None) -> None:
 
33
  self._device = "cpu"
34
 
35
  self._loaded_dimensions: List[int] = []
36
+ self._vision_model: Optional[SentenceTransformer] = None
37
+ self._vision_loaded = False
38
 
39
  def load_model(self, dimension: int) -> None:
40
  if dimension in self._models:
 
59
  for dim in _MODEL_MAP:
60
  self.load_model(dim)
61
 
62
+ def load_vision_model(self) -> None:
63
+ if self._vision_loaded:
64
+ return
65
+ local_path = os.path.join(self._models_dir, "vision")
66
+ source = local_path if os.path.isdir(local_path) else _VISION_MODEL_NAME
67
+ _logger.info("Loading vision embedding model from %s", source)
68
+ self._vision_model = SentenceTransformer(
69
+ source,
70
+ device=self._device,
71
+ trust_remote_code=True,
72
+ )
73
+ self._vision_model.eval()
74
+ self._vision_loaded = True
75
+ _logger.info("Loaded vision embedding model (device=%s)", self._device)
76
+
77
  def generate_embedding(self, text: List[str], dimension: int) -> List[List[float]]:
78
  if dimension not in self._models:
79
  raise ValueError(f"Model for dimension {dimension} not loaded")
 
86
  )
87
  return result.tolist()
88
 
89
+ def generate_image_embedding(self, images: List[Image.Image]) -> List[List[float]]:
90
+ if not self._vision_loaded or self._vision_model is None:
91
+ raise ValueError("Vision model not loaded")
92
+ result: np.ndarray = self._vision_model.encode(
93
+ images,
94
+ convert_to_numpy=True,
95
+ show_progress_bar=False,
96
+ )
97
+ return result.tolist()
98
+
99
  @property
100
  def loaded_dimensions(self) -> List[int]:
101
  return list(self._loaded_dimensions)
102
 
103
  def is_loaded(self, dimension: int) -> bool:
104
  return dimension in self._models
105
+
106
+ @property
107
+ def vision_dimension(self) -> int:
108
+ return _VISION_DIMENSION