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:
@@ -7,9 +7,8 @@ namespace App\Jobs;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkEmbedding;
|
||||
use App\Services\Vision\ArtworkEmbeddingClient;
|
||||
use App\Services\Vision\ArtworkVisionImageUrl;
|
||||
use App\Services\Vision\ArtworkVectorIndexService;
|
||||
use App\Services\Vision\VectorService;
|
||||
use App\Services\Vision\ArtworkVisionImageUrl;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@@ -49,8 +48,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
ArtworkEmbeddingClient $client,
|
||||
ArtworkVisionImageUrl $imageUrlBuilder,
|
||||
ArtworkVectorIndexService $vectors,
|
||||
): void
|
||||
{
|
||||
): void {
|
||||
if (! (bool) config('recommendations.embedding.enabled', true)) {
|
||||
return;
|
||||
}
|
||||
@@ -78,17 +76,73 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
->where('model_version', $modelVersion)
|
||||
->first();
|
||||
|
||||
if ($existing && (string) ($existing->source_hash ?? '') === $sourceHash) {
|
||||
if (
|
||||
$existing
|
||||
&& (string) ($existing->source_hash ?? '') === $sourceHash
|
||||
&& $this->vectorIndexCoversEmbedding(
|
||||
$artwork,
|
||||
$existing
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$lockKey = $this->lockKey($artwork->id, $model, $modelVersion);
|
||||
$lockKey = $this->lockKey(
|
||||
$artwork->id,
|
||||
$model,
|
||||
$modelVersion
|
||||
);
|
||||
|
||||
if (! $this->acquireLock($lockKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
/*
|
||||
* Another worker may have generated or indexed the
|
||||
* embedding between our first read and lock acquisition.
|
||||
* Re-read both records under the lock.
|
||||
*/
|
||||
if (! $this->force) {
|
||||
$artwork->refresh();
|
||||
|
||||
$existing = ArtworkEmbedding::query()
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('model', $model)
|
||||
->where('model_version', $modelVersion)
|
||||
->first();
|
||||
|
||||
if (
|
||||
$existing
|
||||
&& (string) ($existing->source_hash ?? '')
|
||||
=== $sourceHash
|
||||
) {
|
||||
if (
|
||||
$this->vectorIndexCoversEmbedding(
|
||||
$artwork,
|
||||
$existing
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$storedVector = $this->decodeStoredVector(
|
||||
$existing
|
||||
);
|
||||
|
||||
if ($storedVector !== []) {
|
||||
$this->upsertVectorIndex(
|
||||
$vectors,
|
||||
$artwork,
|
||||
$storedVector
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$imageUrl = $imageUrlBuilder->fromArtwork($artwork);
|
||||
if ($imageUrl === null) {
|
||||
return;
|
||||
@@ -121,23 +175,33 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
]
|
||||
);
|
||||
|
||||
$this->upsertVectorIndex($vectors, $artwork);
|
||||
$this->upsertVectorIndex(
|
||||
$vectors,
|
||||
$artwork,
|
||||
$normalized
|
||||
);
|
||||
} finally {
|
||||
$this->releaseLock($lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, float> $vector
|
||||
*/
|
||||
private function upsertVectorIndex(
|
||||
ArtworkVectorIndexService $vectors,
|
||||
Artwork $artwork
|
||||
): void
|
||||
{
|
||||
Artwork $artwork,
|
||||
array $vector
|
||||
): void {
|
||||
if (! $vectors->isConfigured()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$vectors->upsertArtwork($artwork);
|
||||
$vectors->upsertArtworkVector(
|
||||
$artwork,
|
||||
$vector
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('GenerateArtworkEmbeddingJob vector upsert failed', [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
@@ -146,8 +210,96 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
private function vectorIndexCoversEmbedding(
|
||||
Artwork $artwork,
|
||||
ArtworkEmbedding $embedding
|
||||
): bool {
|
||||
$indexedAt = $artwork->last_vector_indexed_at;
|
||||
$generatedAt = $embedding->generated_at;
|
||||
|
||||
if ($indexedAt === null || $generatedAt === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $indexedAt->greaterThanOrEqualTo(
|
||||
$generatedAt
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, float> $vector
|
||||
* @return array<int, float>
|
||||
*/
|
||||
private function decodeStoredVector(
|
||||
ArtworkEmbedding $embedding
|
||||
): array {
|
||||
try {
|
||||
$decoded = json_decode(
|
||||
(string) $embedding->embedding_json,
|
||||
true,
|
||||
512,
|
||||
JSON_THROW_ON_ERROR
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (! is_array($decoded) || $decoded === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$vector = [];
|
||||
|
||||
foreach ($decoded as $value) {
|
||||
if (! is_int($value) && ! is_float($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$value = (float) $value;
|
||||
|
||||
if (! is_finite($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$vector[] = $value;
|
||||
}
|
||||
|
||||
$count = count($vector);
|
||||
|
||||
$minDim = max(
|
||||
1,
|
||||
(int) config(
|
||||
'recommendations.embedding.min_dim',
|
||||
64
|
||||
)
|
||||
);
|
||||
|
||||
$maxDim = max(
|
||||
$minDim,
|
||||
(int) config(
|
||||
'recommendations.embedding.max_dim',
|
||||
4096
|
||||
)
|
||||
);
|
||||
|
||||
if (
|
||||
$count < $minDim
|
||||
|| $count > $maxDim
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (
|
||||
(int) $embedding->dim > 0
|
||||
&& $count !== (int) $embedding->dim
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $vector;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, float> $vector
|
||||
* @return array<int, float>
|
||||
*/
|
||||
private function normalize(array $vector): array
|
||||
@@ -162,12 +314,13 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
}
|
||||
|
||||
$norm = sqrt($sumSquares);
|
||||
|
||||
return array_map(static fn (float $value): float => $value / $norm, $vector);
|
||||
}
|
||||
|
||||
private function lockKey(int $artworkId, string $model, string $version): string
|
||||
{
|
||||
return 'artwork-embedding:lock:' . $artworkId . ':' . $model . ':' . $version;
|
||||
return 'artwork-embedding:lock:'.$artworkId.':'.$model.':'.$version;
|
||||
}
|
||||
|
||||
private function acquireLock(string $key): bool
|
||||
@@ -177,6 +330,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
if ($didSet) {
|
||||
Redis::expire($key, 1800);
|
||||
}
|
||||
|
||||
return (bool) $didSet;
|
||||
} catch (\Throwable) {
|
||||
return true;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user