GET api update for image vector

This commit is contained in:
sriram
2026-09-18 17:50:14 +05:30
parent d6296bd1f0
commit bc786b1c49
6 changed files with 255 additions and 2 deletions

View File

@@ -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"),

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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

View File

@@ -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

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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"])