image vector dimensionality reduction
This commit is contained in:
@@ -4,11 +4,12 @@ Fill `img_vector` for the rows that already have a primary image.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
New writes get their pixel vector from the worker `upsert_brand_products`
|
||||
New writes get their image embedding from the worker `upsert_brand_products`
|
||||
schedules (app/services/image_vector.py). Rows written before the column
|
||||
existed never pass through that hook, and rows whose download failed at the
|
||||
time (a CDN that 403'd, a timeout) are left NULL on purpose. This script is
|
||||
the second chance for both.
|
||||
the second chance for both - and, with `--force`, the way to recompute every
|
||||
row after the model or its preprocessing changes.
|
||||
|
||||
WHAT IT DOES, PER TABLE
|
||||
-----------------------
|
||||
@@ -19,8 +20,10 @@ WHAT IT DOES, PER TABLE
|
||||
primary. `image_vector.primary_image_url` decides what "primary" means,
|
||||
the same way the product card does.
|
||||
3. Downloads each image with a small thread pool, no more than one request
|
||||
in flight per host and a pause between them, decodes it to 32x32 RGB and
|
||||
writes the 3072 values with a targeted UPDATE. `updated_at` is untouched.
|
||||
in flight per host and a pause between them, runs it through the
|
||||
MobileNetV3 embedder (inference is serialised on the embedder's lock; the
|
||||
downloads are what run in parallel) and writes the 1024 L2-normalised
|
||||
values with a targeted UPDATE. `updated_at` is untouched.
|
||||
|
||||
USAGE
|
||||
-----
|
||||
@@ -28,10 +31,12 @@ USAGE
|
||||
python -m scripts.backfill_image_vectors --brands Amul --apply
|
||||
python -m scripts.backfill_image_vectors --all --apply
|
||||
python -m scripts.backfill_image_vectors --all --recompute-stale --apply
|
||||
python -m scripts.backfill_image_vectors --all --force --apply # model changed
|
||||
|
||||
A dry run downloads nothing; it reports how many rows would be attempted and
|
||||
shows the first few URLs. No backup is written: nothing else writes this
|
||||
column, and `--recompute-stale` repairs any row.
|
||||
A dry run downloads nothing and needs no model; it reports how many rows would
|
||||
be attempted and shows the first few URLs. `--apply` refuses to start when the
|
||||
embedder cannot load. No backup is written: nothing else writes this column,
|
||||
and `--force` rebuilds any row.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -53,6 +58,7 @@ from app.infrastructure.settings import ( # noqa: E402
|
||||
DB_NAME,
|
||||
IMAGE_VECTOR_HOST_PAUSE_SECONDS,
|
||||
)
|
||||
from app.services import image_embedder # noqa: E402
|
||||
from app.services import image_vector as iv # noqa: E402
|
||||
from app.services.vector_store import _connect # noqa: E402
|
||||
from scripts.repair_brand_images import _brand_tables # noqa: E402
|
||||
@@ -76,7 +82,7 @@ class _HostPacer:
|
||||
with self._lock:
|
||||
return self._busy.setdefault(host, threading.Lock())
|
||||
|
||||
def fetch(self, url: str, timeout: Optional[float]) -> Tuple[Optional[List[int]], str]:
|
||||
def fetch(self, url: str, timeout: Optional[float]) -> Tuple[Optional[List[float]], str]:
|
||||
host = urlparse(url).netloc.lower()
|
||||
with self._host_lock(host):
|
||||
with self._lock:
|
||||
@@ -97,6 +103,7 @@ def backfill_table(
|
||||
*,
|
||||
apply: bool,
|
||||
recompute_stale: bool,
|
||||
force: bool,
|
||||
limit: Optional[int],
|
||||
workers: int,
|
||||
timeout: Optional[float],
|
||||
@@ -110,14 +117,15 @@ def backfill_table(
|
||||
entry["skipped"] = True
|
||||
return entry
|
||||
|
||||
rows = iv.rows_needing_vectors(cur, table, recompute_stale=recompute_stale, limit=limit)
|
||||
rows = iv.rows_needing_vectors(cur, table, recompute_stale=recompute_stale, limit=limit, force=force)
|
||||
entry["candidates"] = len(rows)
|
||||
if not rows:
|
||||
logger.info("%s: nothing to do", table)
|
||||
return entry
|
||||
|
||||
stale = sum(1 for r in rows if not r.get("missing"))
|
||||
logger.info("%s: %d row(s) to compute (%d missing, %d stale)", table, len(rows), len(rows) - stale, stale)
|
||||
logger.info("%s: %d row(s) to compute (%d missing, %d %s)", table, len(rows), len(rows) - stale, stale,
|
||||
"forced" if force else "stale")
|
||||
if not apply:
|
||||
for r in rows[:5]:
|
||||
logger.info(" %-42s %s", (r.get("product_name") or "")[:42], r["primary_url"][:90])
|
||||
@@ -125,7 +133,7 @@ def backfill_table(
|
||||
logger.info(" ... and %d more", len(rows) - 5)
|
||||
return entry
|
||||
|
||||
results: Dict[str, Tuple[Optional[List[int]], str]] = {}
|
||||
results: Dict[str, Tuple[Optional[List[float]], str]] = {}
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
futures = {
|
||||
pool.submit(pacer.fetch, r["primary_url"], timeout): r["image_id"] for r in rows
|
||||
@@ -161,6 +169,8 @@ def main() -> int:
|
||||
parser.add_argument("--limit", type=int, default=None, help="at most N rows per table")
|
||||
parser.add_argument("--recompute-stale", action="store_true",
|
||||
help="also redo rows whose vector came from a URL that is no longer the primary")
|
||||
parser.add_argument("--force", action="store_true",
|
||||
help="redo EVERY row with a primary image - after a model or preprocessing change")
|
||||
parser.add_argument("--workers", type=int, default=3, help=f"download threads (1-{MAX_WORKERS}, default 3)")
|
||||
parser.add_argument("--timeout", type=float, default=None, help="per-request timeout in seconds")
|
||||
parser.add_argument("--pause", type=float, default=IMAGE_VECTOR_HOST_PAUSE_SECONDS,
|
||||
@@ -172,6 +182,13 @@ def main() -> int:
|
||||
|
||||
logger.info("Target database: %s / %s", DB_HOST, DB_NAME)
|
||||
logger.info("Mode: %s", "APPLY - this writes" if args.apply else "DRY RUN - nothing is downloaded or written")
|
||||
if args.apply:
|
||||
# Fail before the first download, not after: a missing model would
|
||||
# otherwise cost every image fetch and write nothing.
|
||||
logger.info("Embedder: %s", image_embedder.describe())
|
||||
if not image_embedder.available():
|
||||
logger.error("The embedder cannot run - fix the above before --apply.")
|
||||
return 2
|
||||
|
||||
conn = _connect()
|
||||
if conn is None:
|
||||
@@ -186,7 +203,7 @@ def main() -> int:
|
||||
for _suffix, table in _brand_tables(cur, brands):
|
||||
report.append(backfill_table(
|
||||
cur, conn, table,
|
||||
apply=args.apply, recompute_stale=args.recompute_stale,
|
||||
apply=args.apply, recompute_stale=args.recompute_stale, force=args.force,
|
||||
limit=args.limit, workers=workers, timeout=args.timeout, pacer=pacer,
|
||||
))
|
||||
finally:
|
||||
|
||||
@@ -33,6 +33,13 @@ WHAT IT WILL NOT DO
|
||||
`barcode_last_updated` and REAL for the tax figures rather than the
|
||||
types those values look like they want. Type drift is REPORTED here,
|
||||
never silently "fixed".
|
||||
|
||||
THE ONE EXCEPTION is `img_vector`. A column of any dimension other than
|
||||
the embedder's (vector_store.IMG_VECTOR_DIMS) is DROPPED and re-added
|
||||
empty, `img_vector_src` is cleared, and the column gets its hnsw index -
|
||||
see `_ensure_img_vector_type`. That discards every stored vector on the
|
||||
table, on purpose: a vector of the wrong width is not data, and the
|
||||
backfill script rebuilds them. Those statements are shown as `~` lines.
|
||||
* It will not create a brand table that does not exist.
|
||||
* It will not touch `nutrition_facts` or any non-brand table.
|
||||
|
||||
@@ -108,13 +115,13 @@ _TYPE_ALIASES = {
|
||||
"JSONB": {"jsonb"},
|
||||
"DOUBLE PRECISION": {"double precision"},
|
||||
"vector(384)": {"USER-DEFINED"},
|
||||
"vector(3072)": {"USER-DEFINED"},
|
||||
"vector(1024)": {"USER-DEFINED"},
|
||||
}
|
||||
|
||||
# vector(3072) needs the 16000-dimension ceiling pgvector raised to in 0.4.0.
|
||||
# Below that, `_ensure_columns` logs and skips the column rather than failing
|
||||
# the write, and this script says so up front.
|
||||
_MIN_PGVECTOR_FOR_IMG_VECTOR = (0, 4, 0)
|
||||
# The hnsw index on img_vector needs pgvector 0.5.0. Below that,
|
||||
# `_ensure_img_vector_type` logs and skips the index (an exact scan still
|
||||
# works), and this script says so up front.
|
||||
_MIN_PGVECTOR_FOR_IMG_VECTOR = (0, 5, 0)
|
||||
|
||||
|
||||
def _pgvector_version(cur) -> Optional[str]:
|
||||
@@ -184,8 +191,8 @@ def main() -> int:
|
||||
pgv = _pgvector_version(cur)
|
||||
logger.info("pgvector : %s", pgv or "unknown (extension not found?)")
|
||||
if pgv and _version_tuple(pgv) < _MIN_PGVECTOR_FOR_IMG_VECTOR:
|
||||
logger.warning("pgvector %s is older than %s: img_vector vector(3072) will be "
|
||||
"skipped on this server (every other column still applies).",
|
||||
logger.warning("pgvector %s is older than %s: the hnsw index on img_vector will be "
|
||||
"skipped on this server (the column itself still applies).",
|
||||
pgv, ".".join(map(str, _MIN_PGVECTOR_FOR_IMG_VECTOR)))
|
||||
logger.info("")
|
||||
|
||||
@@ -198,6 +205,7 @@ def main() -> int:
|
||||
report: List[Dict[str, Any]] = []
|
||||
total_missing = 0
|
||||
total_drift = 0
|
||||
total_vec = 0
|
||||
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
@@ -213,6 +221,21 @@ def main() -> int:
|
||||
_ensure_columns(recorder, table)
|
||||
adds = [s for s in recorder.statements if "ADD COLUMN" in s]
|
||||
|
||||
# The img_vector retype/index (see the docstring's one
|
||||
# exception). A DROP means the ADD that follows is a re-add,
|
||||
# not a missing column - keep it out of that list.
|
||||
vec_ops = [
|
||||
s for s in recorder.statements
|
||||
if "img_vector" in s and (
|
||||
("DROP COLUMN" in s) or s.startswith("UPDATE") or s.startswith("CREATE INDEX")
|
||||
)
|
||||
]
|
||||
if any("DROP COLUMN img_vector" in s for s in vec_ops):
|
||||
readd = [s for s in adds if "IF NOT EXISTS img_vector " in s]
|
||||
adds = [s for s in adds if s not in readd]
|
||||
vec_ops = [s for s in vec_ops if "DROP COLUMN" in s] + readd + \
|
||||
[s for s in vec_ops if "DROP COLUMN" not in s]
|
||||
|
||||
# Type drift: a column that exists but whose type is not what
|
||||
# col_defs would have created. Reported, never altered.
|
||||
drift = []
|
||||
@@ -228,12 +251,15 @@ def main() -> int:
|
||||
"table": table,
|
||||
"missing_columns": [s.split("ADD COLUMN IF NOT EXISTS ")[1] for s in adds],
|
||||
"type_drift": drift,
|
||||
"img_vector": vec_ops,
|
||||
}
|
||||
report.append(entry)
|
||||
total_missing += len(adds)
|
||||
total_drift += len(drift)
|
||||
if vec_ops:
|
||||
total_vec += 1
|
||||
|
||||
if apply and adds:
|
||||
if apply and (adds or vec_ops):
|
||||
_ensure_columns(cur, table)
|
||||
|
||||
if apply:
|
||||
@@ -246,12 +272,14 @@ def main() -> int:
|
||||
return 0
|
||||
|
||||
width = max(len(e["table"]) for e in report)
|
||||
changed = [e for e in report if e["missing_columns"] or e["type_drift"]]
|
||||
changed = [e for e in report if e["missing_columns"] or e["type_drift"] or e["img_vector"]]
|
||||
for entry in sorted(changed, key=lambda e: -len(e["missing_columns"])):
|
||||
logger.info("%-*s %d column(s) missing", width, entry["table"],
|
||||
len(entry["missing_columns"]))
|
||||
for col in entry["missing_columns"]:
|
||||
logger.info("%-*s + %s", width, "", col)
|
||||
for stmt in entry["img_vector"]:
|
||||
logger.info("%-*s ~ %s", width, "", stmt)
|
||||
for d in entry["type_drift"]:
|
||||
logger.info("%-*s ! %s is %s, col_defs declares %s (NOT changed)",
|
||||
width, "", d["column"], d["actual"], d["declared"])
|
||||
@@ -261,7 +289,11 @@ def main() -> int:
|
||||
len(report), len(report) - len(changed))
|
||||
logger.info("%d column(s) %s, %d type mismatch(es) reported",
|
||||
total_missing, "added" if apply else "would be added", total_drift)
|
||||
if not apply and total_missing:
|
||||
if total_vec:
|
||||
logger.info("%d table(s) %s img_vector retype/index (stored vectors on a retyped "
|
||||
"table are discarded - run scripts/backfill_image_vectors afterwards)",
|
||||
total_vec, "had" if apply else "need")
|
||||
if not apply and (total_missing or total_vec):
|
||||
logger.info("")
|
||||
logger.info("Re-run with --apply to write these changes.")
|
||||
return 0
|
||||
|
||||
Reference in New Issue
Block a user