GET api update for image vector
This commit is contained in:
@@ -23,7 +23,13 @@ from app.infrastructure.settings import (
|
||||
)
|
||||
from app.services import image_embedder
|
||||
from app.services.catalog_search import search_catalog
|
||||
from app.services.image_match import ImageSearchResult, InvalidVectorError, search_by_vector
|
||||
from app.services.image_match import (
|
||||
ImageSearchResult,
|
||||
InvalidVectorError,
|
||||
normalise_vector,
|
||||
parse_vector_param,
|
||||
search_by_vector,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["search"])
|
||||
|
||||
@@ -117,6 +123,39 @@ def image_vector_search_endpoint(body: ImageVectorSearchRequest) -> ImageSearchO
|
||||
return _to_image_search_out(result)
|
||||
|
||||
|
||||
@router.get("/search/image-vector", response_model=ImageSearchOut)
|
||||
def image_vector_search_get_endpoint(
|
||||
vector: str = Query(
|
||||
..., min_length=1, max_length=16_000,
|
||||
description="The 1024-float embedding: comma-separated decimals, or urlsafe base64 of "
|
||||
"1024 little-endian float32 (recommended - 5.5 KB instead of 8-10 KB)",
|
||||
),
|
||||
text: Optional[str] = Query(None, max_length=500, description="OCR text read off the label"),
|
||||
brand: Optional[str] = Query(None, max_length=120, description="Restrict to one brand (no fallback)"),
|
||||
category: Optional[str] = Query(None, max_length=120),
|
||||
top_k: int = Query(IMAGE_SEARCH_DEFAULT_TOP_K, ge=1, le=IMAGE_SEARCH_MAX_TOP_K),
|
||||
min_score: float = Query(IMAGE_SEARCH_DEFAULT_MIN_SCORE, ge=-1.0, le=1.0),
|
||||
) -> ImageSearchOut:
|
||||
"""The POST above as a GET, for clients that can only pass a query string.
|
||||
|
||||
Same ranking, same response. The vector is 1024 floats, so the URL is
|
||||
5.5 KB as base64 or 8-10 KB comma-separated: fine through the API host
|
||||
(Traefik -> uvicorn, which serve.py gives 64 KB of request-line room),
|
||||
but the comma form exceeds the 8 KB nginx allows on the app domain. Use
|
||||
base64, or the POST, for anything that has to work everywhere.
|
||||
"""
|
||||
try:
|
||||
# Validated here, not only inside the service, so this route rejects a
|
||||
# short / NaN / zero vector exactly as the POST's schema does.
|
||||
values = normalise_vector(parse_vector_param(vector))
|
||||
result = search_by_vector(
|
||||
values, text=text, brand=brand, category=category, top_k=top_k, min_score=min_score,
|
||||
)
|
||||
except InvalidVectorError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
return _to_image_search_out(result)
|
||||
|
||||
|
||||
@router.post("/search/image", response_model=ImageSearchOut)
|
||||
async def image_search_endpoint(
|
||||
file: UploadFile = File(..., description="The product photo (JPEG/PNG/WebP), ideally cropped to the pack"),
|
||||
|
||||
@@ -43,9 +43,12 @@ Read-only: nothing here writes, and the pipeline stages are untouched.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import struct
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
|
||||
|
||||
@@ -117,6 +120,66 @@ def normalise_vector(vector: Sequence[float]) -> List[float]:
|
||||
return [v / norm for v in values]
|
||||
|
||||
|
||||
def encode_vector_b64(vector: Sequence[float]) -> str:
|
||||
"""The compact query-string form: urlsafe base64 of EMBED_DIM little-endian
|
||||
float32 values, no padding. 5,464 characters for 1024 floats, against
|
||||
8-10 KB for comma-separated decimals - the difference between fitting
|
||||
every proxy's request-line limit and not. Client-side equivalents are in
|
||||
docs/IMAGE_SEARCH_API.md."""
|
||||
values = [float(v) for v in vector]
|
||||
if len(values) != EMBED_DIM:
|
||||
raise InvalidVectorError(f"vector must have {EMBED_DIM} values, got {len(values)}")
|
||||
packed = struct.pack(f"<{EMBED_DIM}f", *values)
|
||||
return base64.urlsafe_b64encode(packed).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def parse_vector_param(raw: str) -> List[float]:
|
||||
"""The `vector` query parameter of GET /search/image-vector, as floats.
|
||||
|
||||
Two encodings, told apart by the presence of a comma:
|
||||
|
||||
* comma-separated decimals - what a log line or a print() gives you;
|
||||
surrounding brackets, whitespace and newlines are ignored;
|
||||
* base64 (urlsafe or standard alphabet, padding optional) of exactly
|
||||
EMBED_DIM little-endian float32 - what `encode_vector_b64` produces.
|
||||
|
||||
Only the shape is checked here; length, NaN and zero-norm are
|
||||
`normalise_vector`'s job, so every route reports them the same way.
|
||||
"""
|
||||
text = (raw or "").strip()
|
||||
if text[:1] == "[" and text[-1:] == "]":
|
||||
text = text[1:-1]
|
||||
if not text.strip():
|
||||
raise InvalidVectorError("vector is empty")
|
||||
|
||||
if "," in text:
|
||||
values: List[float] = []
|
||||
for position, piece in enumerate(text.split(","), start=1):
|
||||
piece = piece.strip()
|
||||
if not piece:
|
||||
continue
|
||||
try:
|
||||
values.append(float(piece))
|
||||
except ValueError:
|
||||
raise InvalidVectorError(f"value {position} ({piece[:20]!r}) is not a number") from None
|
||||
return values
|
||||
|
||||
compact = "".join(text.split())
|
||||
padded = compact + "=" * (-len(compact) % 4)
|
||||
try:
|
||||
packed = base64.urlsafe_b64decode(padded.replace("+", "-").replace("/", "_"))
|
||||
except (ValueError, binascii.Error):
|
||||
raise InvalidVectorError(
|
||||
f"vector must be {EMBED_DIM} comma-separated numbers or base64 of {EMBED_DIM} float32 values"
|
||||
) from None
|
||||
if len(packed) != EMBED_DIM * 4:
|
||||
raise InvalidVectorError(
|
||||
f"base64 vector decodes to {len(packed)} bytes, expected {EMBED_DIM * 4} "
|
||||
f"({EMBED_DIM} little-endian float32)"
|
||||
)
|
||||
return list(struct.unpack(f"<{EMBED_DIM}f", packed))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# label text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user