Harden vision vector search and embedding jobs.

Add point search, circuit-breaking, and safer gateway limits so artwork similarity lookups fail closed instead of hanging the request path.
This commit is contained in:
2026-09-20 14:49:19 +02:00
parent 04b8160d9e
commit 1053a38eee
12 changed files with 903 additions and 64 deletions
@@ -95,13 +95,34 @@ final class AiArtworkVectorSearchService
*/
private function searchMatchesForArtwork(Artwork $artwork, int $limit): array
{
$this->client->assertCircuitClosed();
/*
* Reuse the artwork's already-indexed Qdrant vector when available.
* A missing point is expected for older/rebuilt indexes, so retain the
* existing image/CLIP fallback below.
*/
try {
$pointMatches = $this->client->searchByPointId((int) $artwork->id, $limit);
if ($pointMatches !== null) {
return $pointMatches;
}
} catch (VectorGatewayException $pointFailure) {
$this->client->tripIfCircuitWorthy(
[$pointFailure],
'similar_ai_point',
['artwork_id' => (int) $artwork->id],
);
throw $pointFailure;
}
$url = $this->imageUrl->fromArtwork($artwork);
if ($url === null || $url === '') {
return [];
}
$this->client->assertCircuitClosed();
$fileFailure = null;
$fileGatewayAttempted = false;
+10 -3
View File
@@ -26,14 +26,19 @@ final class ArtworkEmbeddingClient
$retries = (int) config('recommendations.embedding.retries', 1);
$delay = (int) config('recommendations.embedding.retry_delay_ms', 200);
$apiKey = trim(
(string) config('vision.clip.api_key', '')
);
$response = Http::acceptJson()
->withHeaders([
'X-API-Key' => $apiKey,
])
->connectTimeout(max(1, $connectTimeout))
->timeout(max(1, $timeout))
->retry(max(0, $retries), max(0, $delay), throw: false)
->post($url, [
'image_url' => $imageUrl,
'artwork_id' => $artworkId,
'hash' => $sourceHash,
'url' => $imageUrl,
]);
if (! $response->ok()) {
@@ -53,6 +58,8 @@ final class ArtworkEmbeddingClient
if (is_array($json) && $this->isNumericVector($json)) {
$candidate = $json;
} elseif (is_array($json) && isset($json['vector']) && is_array($json['vector'])) {
$candidate = $json['vector'];
} elseif (is_array($json) && isset($json['embedding']) && is_array($json['embedding'])) {
$candidate = $json['embedding'];
} elseif (is_array($json) && isset($json['data']['embedding']) && is_array($json['data']['embedding'])) {
@@ -59,6 +59,31 @@ final class ArtworkVectorIndexService
return $payload;
}
/**
* Store a vector that has already been generated for this artwork.
*
* This avoids downloading and embedding the artwork a second time.
*
* @param array<int, float> $vector
* @return array{url: string, metadata: array<string, mixed>}
*/
public function upsertArtworkVector(Artwork $artwork, array $vector): array
{
$payload = $this->payloadForArtwork($artwork);
$this->client->upsertByVector(
$vector,
(int) $artwork->id,
$payload['metadata']
);
$artwork->forceFill([
'last_vector_indexed_at' => now(),
])->save();
return $payload;
}
/**
* @return array{contents: string, filename: string}|null
*/
@@ -74,6 +74,43 @@ final class VectorGatewayClient
return is_array($json) ? $json : [];
}
/**
* Store an already-computed vector directly in Qdrant.
*
* @param array<int, float> $vector
* @param array<string, mixed> $metadata
*/
public function upsertByVector(array $vector, int|string $id, array $metadata = []): array
{
$response = $this->postJson(
$this->request(),
$this->url(
(string) config(
'vision.vector_gateway.upsert_vector_endpoint',
'/vectors/upsert/vector'
)
),
[
'vector' => array_values($vector),
'id' => (string) $id,
'metadata' => $metadata,
]
);
if ($response->failed()) {
throw new RuntimeException(
$this->failureMessage(
'Vector upsert',
$response
)
);
}
$json = $response->json();
return is_array($json) ? $json : [];
}
public function upsertByFileContents(string $contents, string $filename, int|string $id, array $metadata = []): array
{
$response = $this->request()
@@ -224,6 +261,46 @@ final class VectorGatewayClient
return is_array($json) ? $json : [];
}
/**
* Search using an artwork vector that is already stored in Qdrant.
*
* Returns null when the source point does not exist so the caller may fall
* back to the existing image/CLIP search path.
*
* @return list<array{id: int|string, score: float, metadata: array<string, mixed>}>|null
*/
public function searchByPointId(int $artworkId, int $limit = 5): ?array
{
try {
$response = $this->postJson(
$this->searchRequest(),
$this->url(
(string) config(
'vision.vector_gateway.search_point_endpoint',
'/vectors/search/point'
)
),
[
'id' => $artworkId,
'limit' => $this->clampSearchLimit($limit),
]
);
} catch (Throwable $e) {
throw $this->classifyThrowable('search_point', $e);
}
// Missing indexed vector is expected and is not a gateway failure.
if ($response->status() === 404) {
return null;
}
if ($response->failed()) {
throw $this->classifyHttpFailure('search_point', $response);
}
return $this->extractMatches($response->json());
}
/**
* Used by upsert/delete — only ever called from queued/console indexing
* jobs, so a more generous budget is fine.