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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -11,6 +11,7 @@ product cards.
|
||||
|
||||
```
|
||||
POST /api/search/image-vector JSON {vector[1024], text?, brand?, category?, top_k?, min_score?}
|
||||
GET /api/search/image-vector query string: vector=<base64 or csv>&text=...&top_k=... (see "GET variant")
|
||||
POST /api/search/image multipart file + the same optional fields as form fields
|
||||
```
|
||||
|
||||
@@ -114,13 +115,57 @@ Every result is the full product card (`ProductOut`) plus `score` and
|
||||
`text_overlap`. `detected_brand` is the brand the search was scoped to,
|
||||
whether it came from `brand` or from the text.
|
||||
|
||||
## GET variant — the vector in the query string
|
||||
|
||||
For clients that can only issue a GET:
|
||||
|
||||
```
|
||||
GET /api/search/image-vector?vector=<...>&text=...&brand=...&category=...&top_k=10&min_score=0
|
||||
```
|
||||
|
||||
Same parameters, same ranking, same response as the POST. `vector` takes
|
||||
either of two encodings, told apart by the presence of a comma:
|
||||
|
||||
| Encoding | Size in the URL | Works through |
|
||||
|---|---|---|
|
||||
| **base64** of 1024 little-endian float32, urlsafe alphabet, padding optional (recommended) | **5.5 KB** | every layer (Traefik, uvicorn, and the app domain's nginx with its 8 KB request-line cap) |
|
||||
| comma-separated decimals, 5 dp (brackets / whitespace tolerated) | 8.3 KB raw, ~10.3 KB once commas are `%2C`-encoded | `mcp.nearle.ai.in` (Traefik → uvicorn, 64 KB); **not** the app domain (nginx `414`) |
|
||||
|
||||
Anything larger or more sensitive than that belongs in the POST body. GET
|
||||
URLs also land in access logs in full (~6–10 KB per request).
|
||||
|
||||
```bash
|
||||
# base64 (Python: struct.pack('<1024f', *v) → base64.urlsafe_b64encode)
|
||||
curl -sG https://mcp.nearle.ai.in/api/search/image-vector --data-urlencode "vector=$B64" --data-urlencode "text=Britannia Marie Gold 300 g" -d top_k=5
|
||||
|
||||
# comma-separated
|
||||
curl -sG https://mcp.nearle.ai.in/api/search/image-vector --data-urlencode "vector=0.03172,0.06823,-0.0223,...(1024 values)" -d top_k=5
|
||||
```
|
||||
|
||||
Producing the base64 form:
|
||||
|
||||
```python
|
||||
# Python
|
||||
import base64, struct
|
||||
b64 = base64.urlsafe_b64encode(struct.pack("<1024f", *vector)).decode().rstrip("=")
|
||||
```
|
||||
|
||||
```dart
|
||||
// Dart / Flutter - `vector` is the app's Float32List(1024) after L2 normalisation
|
||||
final b64 = base64Url.encode(vector.buffer.asUint8List()).replaceAll('=', '');
|
||||
// Float32List is little-endian on every platform Flutter ships to.
|
||||
```
|
||||
|
||||
A malformed `vector` (wrong count, not a number, bad base64, all zeros)
|
||||
is a 422 whose `detail` says which value or what length was wrong.
|
||||
|
||||
## Errors
|
||||
|
||||
| Code | Cause | What to do |
|
||||
|---|---|---|
|
||||
| 400 | `/image`: the upload is empty | send the file |
|
||||
| 413 | `/image`: file over 8 MB | crop or downscale |
|
||||
| 422 | wrong vector length, NaN, all zeros, `top_k` out of 1–50, `min_score` out of −1…1, undecodable image | `detail` names the field |
|
||||
| 422 | wrong vector length, NaN, all zeros, unparseable GET `vector`, `top_k` out of 1–50, `min_score` out of −1…1, undecodable image | `detail` names the field |
|
||||
| 503 | `/image`: this deployment has no embedding model | embed client-side and use `/image-vector`; `GET /api/health` → `image_vectors.model_present` says whether this can happen |
|
||||
|
||||
## Good to know
|
||||
|
||||
8
serve.py
8
serve.py
@@ -117,6 +117,14 @@ def _run() -> int:
|
||||
# an https request.
|
||||
proxy_headers=True,
|
||||
forwarded_allow_ips="*",
|
||||
# Request line + headers ceiling. h11's default is 16 KB, and the GET
|
||||
# form of the image search (GET /api/search/image-vector) carries a
|
||||
# 1024-float vector in the query string: 5.5 KB as base64, 8-10 KB
|
||||
# comma-separated - which with a browser's cookies and headers can
|
||||
# cross 16 KB and be dropped before the app sees it. 64 KB keeps that
|
||||
# working and is still a tight bound against header abuse. (The
|
||||
# keyword is uvicorn's; a rename would fail loudly here at boot.)
|
||||
h11_max_incomplete_event_size=64 * 1024,
|
||||
)
|
||||
uvicorn.Server(config).run(sockets=sockets)
|
||||
return 0
|
||||
|
||||
@@ -93,6 +93,52 @@ def test_unsearchable_vectors_are_refused_with_a_reason(bad, msg):
|
||||
im.normalise_vector(bad)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the query-string forms of the vector (GET /search/image-vector)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_base64_round_trips_to_float32_precision():
|
||||
v = _unit()
|
||||
encoded = im.encode_vector_b64(v)
|
||||
|
||||
assert len(encoded) < 5_500 and "=" not in encoded # 5,462 chars, unpadded
|
||||
back = im.parse_vector_param(encoded)
|
||||
assert len(back) == 1024 and max(abs(a - b) for a, b in zip(v, back)) < 1e-6
|
||||
|
||||
|
||||
def test_the_standard_base64_alphabet_and_padding_are_accepted_too():
|
||||
import base64, struct
|
||||
v = _unit()
|
||||
standard = base64.b64encode(struct.pack("<1024f", *v)).decode() # '+', '/', '=' padding
|
||||
|
||||
assert max(abs(a - b) for a, b in zip(v, im.parse_vector_param(standard))) < 1e-6
|
||||
|
||||
|
||||
def test_comma_separated_values_tolerate_brackets_whitespace_and_newlines():
|
||||
v = _unit()
|
||||
text = "[ " + ("," + chr(10) + " ").join(f"{x:.6f}" for x in v) + " ]"
|
||||
|
||||
back = im.parse_vector_param(text)
|
||||
assert len(back) == 1024 and max(abs(a - b) for a, b in zip(v, back)) < 1e-6
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw, msg", [
|
||||
("", "empty"),
|
||||
(" ", "empty"),
|
||||
("0.1, 0.2, x, 0.4", "value 3"),
|
||||
("MTIz", "decodes to 3 bytes"),
|
||||
("!!!not base64!!!", "comma-separated numbers or base64"),
|
||||
])
|
||||
def test_unparseable_query_vectors_say_what_is_wrong(raw, msg):
|
||||
with pytest.raises(im.InvalidVectorError, match=msg):
|
||||
im.parse_vector_param(raw)
|
||||
|
||||
|
||||
def test_a_short_csv_vector_is_caught_by_the_same_length_rule_as_the_post_body():
|
||||
with pytest.raises(im.InvalidVectorError, match="1024 values"):
|
||||
im.normalise_vector(im.parse_vector_param(",".join(["0.1"] * 1023)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the label text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -116,6 +116,57 @@ def test_a_service_rejection_is_422_not_500(client, monkeypatch):
|
||||
assert res.status_code == 422 and "all zeros" in res.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /search/image-vector
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_with_a_base64_vector_returns_the_same_cards_and_stays_under_8kb(client, fake_search):
|
||||
from app.services.image_match import encode_vector_b64
|
||||
v = _unit()
|
||||
params = {"vector": encode_vector_b64(v), "text": "Britannia Marie Gold 300 g", "top_k": 5, "min_score": 0.1}
|
||||
|
||||
res = client.get("/api/search/image-vector", params=params)
|
||||
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["results"][0]["product_name"] == "Britannia Marie Gold 300g"
|
||||
call = fake_search[0]
|
||||
assert len(call["vector"]) == 1024 and max(abs(a - b) for a, b in zip(v, call["vector"])) < 1e-6
|
||||
assert call["text"] == "Britannia Marie Gold 300 g" and call["top_k"] == 5 and call["min_score"] == 0.1
|
||||
# the nginx on the app domain allows an 8 KB request line; base64 must fit it
|
||||
assert len(str(res.request.url)) < 8_000
|
||||
|
||||
|
||||
def test_get_with_comma_separated_values_works_on_the_api_host(client, fake_search):
|
||||
v = _unit()
|
||||
res = client.get("/api/search/image-vector", params={"vector": ",".join(f"{x:.5f}" for x in v)})
|
||||
|
||||
assert res.status_code == 200, res.text
|
||||
assert len(fake_search[0]["vector"]) == 1024
|
||||
assert max(abs(a - b) for a, b in zip(v, fake_search[0]["vector"])) < 1e-5
|
||||
|
||||
|
||||
@pytest.mark.parametrize("params, fragment", [
|
||||
({}, "vector"),
|
||||
({"vector": "0.1,0.2,x"}, "value 3"),
|
||||
({"vector": "MTIz"}, "decodes to 3 bytes"),
|
||||
({"vector": ",".join(["0.1"] * 1023)}, "1024 values"),
|
||||
({"vector": ",".join(["0.0"] * 1024)}, "all zeros"),
|
||||
({"vector": ",".join(["0.1"] * 1024), "top_k": 51}, "top_k"),
|
||||
])
|
||||
def test_get_rejects_bad_input_with_422_and_a_reason(client, fake_search, params, fragment):
|
||||
res = client.get("/api/search/image-vector", params=params)
|
||||
assert res.status_code == 422 and fragment in res.text
|
||||
assert fake_search == []
|
||||
|
||||
|
||||
def test_get_and_post_agree(client, fake_search):
|
||||
from app.services.image_match import encode_vector_b64
|
||||
v = _unit()
|
||||
a = client.get("/api/search/image-vector", params={"vector": encode_vector_b64(v), "top_k": 3}).json()
|
||||
b = client.post("/api/search/image-vector", json={"vector": v, "top_k": 3}).json()
|
||||
assert [r["image_id"] for r in a["results"]] == [r["image_id"] for r in b["results"]]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /search/image
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -172,3 +223,4 @@ def test_both_routes_are_documented(client):
|
||||
paths = client.get("/openapi.json").json()["paths"]
|
||||
assert "/api/search/image-vector" in paths and "/api/search/image" in paths
|
||||
assert "post" in paths["/api/search/image"] and "get" in paths["/api/search"]
|
||||
assert {"get", "post"} <= set(paths["/api/search/image-vector"])
|
||||
|
||||
Reference in New Issue
Block a user