Updates on Image search using vectors

This commit is contained in:
sriram
2026-09-28 15:44:01 +05:30
parent 6628207810
commit c0489d89d6
16 changed files with 1184 additions and 37 deletions

View File

@@ -27,7 +27,7 @@ from app.infrastructure.settings import (
SEARCH_DEFAULT_TOP_K,
SEARCH_MAX_TOP_K,
)
from app.services import capture_discovery, image_embedder
from app.services import capture_discovery, image_embedder, image_search_log
from app.services.catalog_search import search_catalog
from app.services.image_match import (
ImageSearchResult,
@@ -105,12 +105,28 @@ def _to_image_search_out(result: ImageSearchResult) -> ImageSearchOut:
min_score=result.min_score,
top_k=result.top_k,
query_text=result.query_text,
match_confidence=result.match_confidence if matches else "none",
margin=None if result.margin is None else round(float(result.margin), 4),
)
def _identify_confidence(result: IdentifyResult) -> str:
"""The ladder's verdict in match_confidence terms: confirmed exactly when
product_identify.py's client rule says so."""
if not result.search.rows:
return "none"
if capture_discovery.is_confirmed(result.matched_by, result.fallback_reason):
return "confirmed"
return "low"
def _to_identify_out(result: IdentifyResult) -> IdentifyOut:
fields = _to_image_search_out(result.search).model_dump()
fields["match_confidence"] = _identify_confidence(result)
if result.matched_by != "image_vector":
fields["margin"] = None # text rows: a MiniLM score, no image margin
return IdentifyOut(
**_to_image_search_out(result.search).model_dump(),
**fields,
matched_by=result.matched_by,
ocr_text=result.ocr_text,
ocr_source=result.ocr_source,
@@ -119,6 +135,28 @@ def _to_identify_out(result: IdentifyResult) -> IdentifyOut:
)
def _log_search(route: str, result: ImageSearchResult, *, vector, text, brand, category, top_k,
photo: Optional[bytes] = None, text_fallback: Optional[bool] = None) -> None:
image_search_log.record(
route, vector=vector, text=text, brand=brand, category=category, top_k=top_k,
rows=result.rows, detected_brand=result.detected_brand, scoped_to_brand=result.scoped_to_brand,
match_confidence=result.match_confidence, margin=result.margin,
matched_by="image_vector" if result.rows else "none", photo=photo, text_fallback=text_fallback,
)
def _log_identify(route: str, result: IdentifyResult, *, vector, text, brand, category, top_k,
photo: Optional[bytes] = None, text_fallback: Optional[bool] = None) -> None:
image_search_log.record(
route, vector=vector, text=text, brand=brand, category=category, top_k=top_k,
rows=result.search.rows, detected_brand=result.search.detected_brand,
scoped_to_brand=result.search.scoped_to_brand, match_confidence=_identify_confidence(result),
margin=result.search.margin if result.matched_by == "image_vector" else None,
matched_by=result.matched_by, fallback_reason=result.fallback_reason,
photo=photo, text_fallback=text_fallback,
)
@router.post("/search/image-vector", response_model=ImageSearchOut)
def image_vector_search_endpoint(body: ImageVectorSearchRequest):
"""Products that look like the photo whose embedding is `vector`.
@@ -131,20 +169,26 @@ def image_vector_search_endpoint(body: ImageVectorSearchRequest):
filter; a brand recognised from `text` falls back to every brand when it
finds nothing (`scope_fallback`).
With `text_fallback: true` the request runs the /search/identify ladder
instead: when the best image score is under IMAGE_IDENTIFY_MIN_IMAGE_SCORE
the label `text` is resolved against the catalogue, and the response is
an IdentifyOut (this response plus matched_by, fallback_reason, ...).
Off by default so the app's existing calls are answered exactly as before.
With `text_fallback` the request runs the /search/identify ladder
instead: when the image match cannot be confirmed (best score under
IMAGE_IDENTIFY_MIN_IMAGE_SCORE, or another photo within
IMAGE_SEARCH_MIN_MARGIN) the label `text` is resolved against the
catalogue, and the response is an IdentifyOut (this response plus
matched_by, fallback_reason, ...). Left out, it is on whenever `text` is
sent; `false` keeps the image-only ranking, where text only breaks ties.
"""
ladder = body.text_fallback if body.text_fallback is not None else bool((body.text or "").strip())
log_fields = dict(vector=body.vector, text=body.text, brand=body.brand, category=body.category,
top_k=body.top_k, text_fallback=body.text_fallback)
try:
if body.text_fallback:
if ladder:
identified = identify_product(
vector=body.vector, image_bytes=None, text=body.text, brand=body.brand,
category=body.category, top_k=body.top_k, min_score=body.min_score,
)
_log_identify("image-vector", identified, **log_fields)
# A Response bypasses response_model, which is the point: the
# default path keeps its declared ImageSearchOut contract.
# image-only path keeps its declared ImageSearchOut contract.
return JSONResponse(content=_to_identify_out(identified).model_dump(mode="json"))
result = search_by_vector(
body.vector, text=body.text, brand=body.brand, category=body.category,
@@ -152,6 +196,7 @@ def image_vector_search_endpoint(body: ImageVectorSearchRequest):
)
except InvalidVectorError as exc:
raise HTTPException(status_code=422, detail=str(exc))
_log_search("image-vector", result, **log_fields)
return _to_image_search_out(result)
@@ -185,6 +230,8 @@ def image_vector_search_get_endpoint(
)
except InvalidVectorError as exc:
raise HTTPException(status_code=422, detail=str(exc))
_log_search("image-vector:get", result, vector=values, text=text, brand=brand,
category=category, top_k=top_k)
return _to_image_search_out(result)
@@ -230,6 +277,8 @@ async def image_search_endpoint(
)
except InvalidVectorError as exc: # cannot happen for a model output, but the route must not 500
raise HTTPException(status_code=422, detail=str(exc))
_log_search("image", result, vector=vector, text=text, brand=brand, category=category,
top_k=top_k, photo=content)
return _to_image_search_out(result)
@@ -295,6 +344,8 @@ async def identify_endpoint(
"deployment. Send the label as `text`, or embed the photo client-side and POST "
"the vector to /api/search/image-vector.",
)
_log_identify("identify", result, vector=vector, text=text, brand=brand, category=category,
top_k=top_k, photo=content)
out = _to_identify_out(result)
if settings.ENABLE_CAPTURE_DISCOVERY and not capture_discovery.is_confirmed(
result.matched_by, result.fallback_reason
@@ -327,10 +378,14 @@ def _apply_capture_outcome(out: IdentifyOut, outcome: capture_discovery.CaptureO
out.results = [ImageMatchOut(**card.model_dump(), score=1.0, text_overlap=1.0)]
out.total = 1
out.matched_by = capture_discovery.MATCHED_BY_LABEL_EXACT
out.match_confidence = "confirmed"
out.margin = None
elif outcome.status == capture_discovery.PENDING:
out.results = []
out.total = 0
out.matched_by = capture_discovery.MATCHED_BY_DISCOVERY
out.match_confidence = "none"
out.margin = None
@router.get("/search/identify/jobs/{job_id}", response_model=CaptureJobOut)

View File

@@ -263,15 +263,19 @@ class ImageVectorSearchRequest(BaseModel):
top_k: int = Field(IMAGE_SEARCH_DEFAULT_TOP_K, ge=1, le=IMAGE_SEARCH_MAX_TOP_K)
min_score: float = Field(IMAGE_SEARCH_DEFAULT_MIN_SCORE, ge=-1.0, le=1.0,
description="Drop matches with cosine similarity below this")
# Opt-in, so a client that never asked for it gets exactly the response
# it always did. With it, the route runs the identify ladder: when the
# best image score is under IMAGE_IDENTIFY_MIN_IMAGE_SCORE, `text` is
# resolved against the catalogue instead, and the response is an
# IdentifyOut (ImageSearchOut plus matched_by / fallback_reason / ...).
text_fallback: bool = Field(
False,
description="Fall back to resolving `text` when the image match is below the identify floor; "
"the response then carries the IdentifyOut fields",
# With it, the route runs the identify ladder: when the best image score
# is under IMAGE_IDENTIFY_MIN_IMAGE_SCORE (or another photo is within
# IMAGE_SEARCH_MIN_MARGIN of it), `text` is resolved against the
# catalogue instead, and the response is an IdentifyOut (ImageSearchOut
# plus matched_by / fallback_reason / ...). Left out, it is ON whenever
# `text` is sent: a label that names the product must not lose to a 0.5
# cosine, which it did when text only broke exact ties. `false` keeps the
# old image-only ranking.
text_fallback: Optional[bool] = Field(
None,
description="Resolve `text` when the image match cannot be confirmed; the response then "
"carries the IdentifyOut fields. Default: on when `text` is sent. "
"false = image-only ranking, text breaks ties only",
)
@field_validator("vector")
@@ -301,6 +305,14 @@ class ImageSearchOut(BaseModel):
min_score: float = 0.0
top_k: int = 0
query_text: Optional[str] = None
# "confirmed" only when the best match clears IMAGE_IDENTIFY_MIN_IMAGE_SCORE
# AND leads the best different photo by IMAGE_SEARCH_MIN_MARGIN (on
# /identify: when the ladder confirmed it). "low": show the results as a
# list to pick from, never as the answer. "none": no results.
match_confidence: str = "none"
# Best score minus the best DIFFERENT photo's; None when there was no rival.
# Image space only - None when the rows came from the text rung.
margin: Optional[float] = None
class IdentifyOut(ImageSearchOut):

View File

@@ -432,6 +432,21 @@ IMAGE_SEARCH_DEFAULT_MIN_SCORE = float(os.getenv("IMAGE_SEARCH_DEFAULT_MIN_SCORE
# product share an image and tie, so more than top_k must come back), and
# the floor for hnsw.ef_search on that query so the index does not drop them.
IMAGE_SEARCH_MAX_FETCH_K = int(os.getenv("IMAGE_SEARCH_MAX_FETCH_K", "100"))
# How far the best image match must lead the best DIFFERENT photo for the
# answer to count as confirmed (`match_confidence`). A phone photo of a card on
# a screen measured 0.507 against its own product and 0.419 against a
# stranger; with glare that gap closes, and a flipped order is a wrong
# product shown with confidence. Pack sizes sharing one photo tie exactly and
# are never each other's competitor. Unmeasured start: tune with
# scripts/eval_identify.py.
IMAGE_SEARCH_MIN_MARGIN = float(os.getenv("IMAGE_SEARCH_MIN_MARGIN", "0.05"))
# Diagnosis: when set, every image-search request (vector, text, brand and the
# top matches) is written here as one JSON file, for
# scripts/replay_image_query.py. Blank = off. At most IMAGE_SEARCH_CAPTURE_MAX
# files are kept; the oldest go first. The routes are public, so leave it off
# except while chasing a report.
IMAGE_SEARCH_CAPTURE_DIR = os.getenv("IMAGE_SEARCH_CAPTURE_DIR", "").strip()
IMAGE_SEARCH_CAPTURE_MAX = int(os.getenv("IMAGE_SEARCH_CAPTURE_MAX", "200"))
# ---------------------------------------------------------------------------
# Identify a product from a phone photo - POST /api/search/identify

View File

@@ -53,10 +53,12 @@ from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
from app.infrastructure.settings import (
IMAGE_IDENTIFY_MIN_IMAGE_SCORE,
IMAGE_SEARCH_DEFAULT_MIN_SCORE,
IMAGE_SEARCH_DEFAULT_TOP_K,
IMAGE_SEARCH_MAX_FETCH_K,
IMAGE_SEARCH_MAX_TOP_K,
IMAGE_SEARCH_MIN_MARGIN,
)
from app.services.image_embedder import EMBED_DIM
@@ -90,6 +92,8 @@ class ImageSearchResult:
min_score: float = 0.0
query_text: Optional[str] = None
top_k: int = 0
match_confidence: str = "none" # "confirmed" | "low" | "none"
margin: Optional[float] = None # top score minus the best different photo's
# ---------------------------------------------------------------------------
@@ -226,16 +230,102 @@ def text_overlap(words: Set[str], sizes: Set[str], row: Dict[str, Any]) -> float
return _SIZE_WEIGHT * len(sizes & row_sizes) + _WORD_WEIGHT * len(words & row_words)
def size_in_name(sizes: Set[str], row: Dict[str, Any]) -> bool:
"""Whether a pack size the label printed is in the row's OWN name.
`text_overlap` also counts `size_variants`, and on some products every
pack-size row carries the SAME list (all Amul Cream rows say
["90g", "1kg"]), so a "1kg" label ties every sibling and the tie fell to
alphabetical order - "Amul Cream 125 ml" was shown, confirmed, for a
photo of the 1kg pack. The name is per row; this breaks that tie.
"""
if not sizes:
return False
name_sizes = tokens(" ".join([str(row.get("product_name") or ""), str(row.get("title") or "")]))[1]
return bool(sizes & name_sizes)
def rank_key(row: Dict[str, Any]) -> tuple:
"""Best first. Score rounded to 3 dp so siblings sharing a photo tie."""
return (
-round(float(row.get("score", 0.0)), 3),
-float(row.get("text_overlap", 0.0)),
-int(bool(row.get("size_in_name", False))),
str(row.get("product_name") or row.get("title") or "").lower(),
str(row.get("image_id") or ""),
)
# ---------------------------------------------------------------------------
# confidence
# ---------------------------------------------------------------------------
# Pack sizes that share one catalog photo score identically (to pgvector's
# float precision); anything further apart than this is a different picture.
_SAME_PHOTO_EPS = 5e-4
CONFIRMED = "confirmed"
LOW = "low"
NONE = "none"
def image_margin(rows: Sequence[Dict[str, Any]]) -> Optional[float]:
"""How far the best row leads the best row with a DIFFERENT photo.
`rows` are best first by score. Siblings sharing the winner's photo are
skipped: they are the same product in another size, which the label text
separates, not a rival. None when nothing else is in the list - no rival
was seen, which is not the same as a clear lead, so the caller treats it
as unconfirmable only when the score itself is low.
"""
if not rows:
return None
try:
top = float(rows[0].get("score", 0.0))
except (TypeError, ValueError):
return None
for row in rows[1:]:
try:
score = float(row.get("score", 0.0))
except (TypeError, ValueError):
continue
if top - score > _SAME_PHOTO_EPS:
return top - score
return None
def match_confidence(
rows: Sequence[Dict[str, Any]],
min_image_score: Optional[float] = None,
min_margin: Optional[float] = None,
) -> Tuple[str, Optional[float]]:
"""("confirmed" | "low" | "none", margin) for image rows, best first.
Confirmed needs BOTH a score at the identify floor and a lead over the
next different photo. A photo of a card on a phone screen scores ~0.5
against its own product, and a stranger can sit within a few hundredths
of it: shown as the answer, that is how "Dairy Milk Lickables" came back
as someone else's product. A client should offer the list, not pick one,
when this is not "confirmed". The thresholds default to this module's
settings, read at call time so scripts/eval_identify.py can vary them.
"""
if min_image_score is None:
min_image_score = IMAGE_IDENTIFY_MIN_IMAGE_SCORE
if min_margin is None:
min_margin = IMAGE_SEARCH_MIN_MARGIN
if not rows:
return NONE, None
margin = image_margin(rows)
try:
top = float(rows[0].get("score", 0.0))
except (TypeError, ValueError):
return LOW, margin
if top < min_image_score:
return LOW, margin
if margin is not None and margin < min_margin:
return LOW, margin
return CONFIRMED, margin
# ---------------------------------------------------------------------------
# the search
# ---------------------------------------------------------------------------
@@ -345,7 +435,11 @@ def search_by_vector(
words, sizes = tokens(label)
for row in rows:
row["text_overlap"] = text_overlap(words, sizes, row)
row["size_in_name"] = size_in_name(sizes, row)
rows.sort(key=rank_key)
# Judged on every candidate, not the top_k shown: with top_k=1 the rival
# would otherwise never be seen.
confidence, margin = match_confidence(rows)
winners = _hydrate(rows[:top_k])
return ImageSearchResult(
@@ -356,4 +450,6 @@ def search_by_vector(
min_score=min_score,
query_text=label,
top_k=top_k,
match_confidence=confidence,
margin=margin,
)

View File

@@ -0,0 +1,133 @@
"""One log line per search-by-photo request, and - when asked - the request itself.
WHY
---
A colleague photographed the "Cadbury Dairy Milk Lickables" card and got
"Milk Toned"; "Aachi Sambar Powder 100g" came back as Sakthi's "Sambar powder
50g". Replayed on the server, both photos rank the right product first, so
the difference is in what the app SENDS - the vector (is its on-device model
the same as ours?), whether any OCR `text` came with it, which route it hit.
None of that was visible. This module makes it visible:
* `record()` logs, for every /search/image*, /search/identify request: the
route, the text, brand, detected brand, a short hash and the norm of the
vector, the top three (brand, name, score) and the lead over the best
different photo. Always on; it is one INFO line.
* With IMAGE_SEARCH_CAPTURE_DIR set, the whole request - the 1024 floats,
the fields, the photo when there was one - is also written there, for
`python -m scripts.replay_image_query`. Off by default: these routes are
public. The newest IMAGE_SEARCH_CAPTURE_MAX requests are kept.
Never raises: a diagnostic must not fail a search.
"""
from __future__ import annotations
import hashlib
import json
import logging
import math
import struct
import time
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence
from app.infrastructure import settings
logger = logging.getLogger(__name__)
_TEXT_LOG_CHARS = 120
def vector_fingerprint(vector: Optional[Sequence[float]]) -> Optional[str]:
"""10 hex characters identifying a vector: equal for the same float32 values."""
if vector is None:
return None
packed = struct.pack(f"<{len(vector)}f", *[float(v) for v in vector])
return hashlib.sha1(packed).hexdigest()[:10]
def _top(rows: Sequence[Dict[str, Any]], n: int = 3) -> List[Dict[str, Any]]:
out = []
for row in list(rows)[:n]:
try:
score = round(float(row.get("score", 0.0)), 4)
except (TypeError, ValueError):
score = None
out.append({
"brand": row.get("brand") or row.get("brand_table"),
"product_name": row.get("product_name") or row.get("title"),
"image_id": row.get("image_id"),
"score": score,
})
return out
def _prune(folder: Path, keep: int) -> None:
files = sorted(folder.glob("*.json"), key=lambda p: p.stat().st_mtime)
for stale in files[:max(0, len(files) - keep)]:
for sibling in folder.glob(stale.stem + ".*"):
sibling.unlink(missing_ok=True)
def record(
route: str,
*,
vector: Optional[Sequence[float]],
text: Optional[str],
brand: Optional[str],
category: Optional[str],
top_k: int,
rows: Sequence[Dict[str, Any]],
detected_brand: Optional[str] = None,
scoped_to_brand: bool = False,
match_confidence: Optional[str] = None,
margin: Optional[float] = None,
matched_by: Optional[str] = None,
fallback_reason: Optional[str] = None,
text_fallback: Optional[bool] = None,
photo: Optional[bytes] = None,
) -> None:
"""Log the request and its answer; save it when capture is on."""
try:
norm = math.sqrt(sum(float(v) * float(v) for v in vector)) if vector is not None else None
top = _top(rows)
logger.info(
"[IMAGE_SEARCH] route=%s text=%r brand=%r detected=%r scoped=%s vec=%s norm=%s "
"matched_by=%s reason=%s confidence=%s margin=%s top=%s",
route, (text or "")[:_TEXT_LOG_CHARS] or None, brand, detected_brand, scoped_to_brand,
vector_fingerprint(vector), None if norm is None else round(norm, 4),
matched_by, fallback_reason, match_confidence,
None if margin is None else round(margin, 4),
[(t["brand"], t["product_name"], t["score"]) for t in top],
)
folder_name = settings.IMAGE_SEARCH_CAPTURE_DIR
if not folder_name:
return
folder = Path(folder_name)
folder.mkdir(parents=True, exist_ok=True)
stem = time.strftime("%Y%m%dT%H%M%S") + "_" + uuid.uuid4().hex[:6]
payload = {
"route": route,
"received_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"request": {
"vector": None if vector is None else [float(v) for v in vector],
"text": text, "brand": brand, "category": category, "top_k": top_k,
"text_fallback": text_fallback,
},
"vector_fingerprint": vector_fingerprint(vector),
"answer": {
"matched_by": matched_by, "fallback_reason": fallback_reason,
"match_confidence": match_confidence, "margin": margin,
"detected_brand": detected_brand, "scoped_to_brand": scoped_to_brand, "top": top,
},
"photo": None,
}
if photo:
photo_path = folder / f"{stem}.img"
photo_path.write_bytes(photo)
payload["photo"] = photo_path.name
(folder / f"{stem}.json").write_text(json.dumps(payload), encoding="utf-8")
_prune(folder, max(1, settings.IMAGE_SEARCH_CAPTURE_MAX))
except Exception as exc: # noqa: BLE001 - a diagnostic must never fail the search
logger.warning("image search log skipped: %s", exc)

View File

@@ -59,7 +59,7 @@ from app.infrastructure.settings import (
IMAGE_SEARCH_MAX_TOP_K,
OCR_MAX_CHARS,
)
from app.services.image_match import _STOP, ImageSearchResult, _row_text, text_overlap, tokens
from app.services.image_match import _STOP, ImageSearchResult, _row_text, size_in_name, text_overlap, tokens
logger = logging.getLogger(__name__)
@@ -197,6 +197,7 @@ def label_rank_key(row: Dict[str, Any]) -> tuple:
"""
return (
-float(row.get("text_overlap", 0.0)),
-int(bool(row.get("size_in_name", False))), # the label's size in THIS row's name
int(row.get("text_extra", 0)),
-round(float(row.get("score", 0.0)), 3),
str(row.get("product_name") or row.get("title") or "").lower(),
@@ -336,6 +337,7 @@ def resolve_label(
for row in rows:
row["text_overlap"] = text_overlap(words, sizes, row)
row["text_extra"] = text_extra(words, row)
row["size_in_name"] = size_in_name(sizes, row)
rows.sort(key=label_rank_key)
return rows

View File

@@ -19,6 +19,8 @@ THE LADDER
`fallback_reason` is the reason for the LAST step the ladder took:
image_below_threshold the image arm ran but did not clear the floor
image_ambiguous it cleared the floor, but another photo scored
within IMAGE_SEARCH_MIN_MARGIN of it
no_image_match the image arm ran and found nothing at all
image_embedder_unavailable no vector could be computed (no model here)
no_text no label to fall back to and no photo to read
@@ -46,6 +48,7 @@ from app.infrastructure.settings import (
IMAGE_IDENTIFY_MIN_IMAGE_SCORE,
IMAGE_SEARCH_DEFAULT_MIN_SCORE,
IMAGE_SEARCH_DEFAULT_TOP_K,
IMAGE_SEARCH_MIN_MARGIN,
)
from app.services import ocr_service
from app.services.image_match import ImageSearchResult, search_by_vector
@@ -89,9 +92,11 @@ def _image_rung(
top = float(result.rows[0].get("score", 0.0))
except (TypeError, ValueError):
top = 0.0
if top >= min_image_score:
return result, top, None
return result, top, "image_below_threshold"
if top < min_image_score:
return result, top, "image_below_threshold"
if result.margin is not None and result.margin < IMAGE_SEARCH_MIN_MARGIN:
return result, top, "image_ambiguous"
return result, top, None
def identify_product(

View File

@@ -1861,7 +1861,14 @@ def lexical_search(
finally:
conn.close()
results.sort(key=lambda r: (r.get("lex_tier", 9), r.get("distance", 9.0)))
# A row with a NULL `embedding` comes back with distance None (the key IS
# present), and one None among floats made this sort raise - which threw
# away every row of an unscoped search, the label resolver's lexical arm.
def _distance(r: Dict[str, Any]) -> float:
d = r.get("distance")
return 9.0 if d is None else float(d)
results.sort(key=lambda r: (r.get("lex_tier", 9), _distance(r)))
return results[:limit]