From bc786b1c49f38061122466cd4560b396161de69e Mon Sep 17 00:00:00 2001 From: sriram Date: Fri, 18 Sep 2026 17:50:14 +0530 Subject: [PATCH] GET api update for image vector --- app/api/routers/search.py | 41 +++++++++++++++++++++- app/services/image_match.py | 63 ++++++++++++++++++++++++++++++++++ docs/IMAGE_SEARCH_API.md | 47 ++++++++++++++++++++++++- serve.py | 8 +++++ tests/test_image_match.py | 46 +++++++++++++++++++++++++ tests/test_image_search_api.py | 52 ++++++++++++++++++++++++++++ 6 files changed, 255 insertions(+), 2 deletions(-) diff --git a/app/api/routers/search.py b/app/api/routers/search.py index b6ae979..7a1acb8 100644 --- a/app/api/routers/search.py +++ b/app/api/routers/search.py @@ -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"), diff --git a/app/services/image_match.py b/app/services/image_match.py index c691893..23acc65 100644 --- a/app/services/image_match.py +++ b/app/services/image_match.py @@ -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 # --------------------------------------------------------------------------- diff --git a/docs/IMAGE_SEARCH_API.md b/docs/IMAGE_SEARCH_API.md index c6acb68..545702a 100644 --- a/docs/IMAGE_SEARCH_API.md +++ b/docs/IMAGE_SEARCH_API.md @@ -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=&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 diff --git a/serve.py b/serve.py index 79f8780..9673e6f 100644 --- a/serve.py +++ b/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 diff --git a/tests/test_image_match.py b/tests/test_image_match.py index bad08f5..8b84f65 100644 --- a/tests/test_image_match.py +++ b/tests/test_image_match.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/test_image_search_api.py b/tests/test_image_search_api.py index 4a9ce8b..462c530 100644 --- a/tests/test_image_search_api.py +++ b/tests/test_image_search_api.py @@ -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"])