Files
SkinbaseNova/app/Services/Recommendations/VectorSimilarity/PineconeAdapter.php

150 lines
5.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Recommendations\VectorSimilarity;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Managed vector DB adapter (Pinecone-style REST API).
*
* Spec §9 Option B.
*
* Configuration:
* recommendations.similarity.pinecone.api_key
* recommendations.similarity.pinecone.index_host
* recommendations.similarity.pinecone.index_name
* recommendations.similarity.pinecone.namespace
* recommendations.similarity.pinecone.top_k
*/
final class PineconeAdapter implements VectorAdapterInterface
{
private function apiKey(): string
{
return (string) config('recommendations.similarity.pinecone.api_key', '');
}
private function host(): string
{
return rtrim((string) config('recommendations.similarity.pinecone.index_host', ''), '/');
}
private function namespace(): string
{
return (string) config('recommendations.similarity.pinecone.namespace', '');
}
public function querySimilar(int $artworkId, int $topK = 100): array
{
$configTopK = (int) config('recommendations.similarity.pinecone.top_k', 100);
$effectiveTopK = min($topK, $configTopK);
$vectorId = "artwork:{$artworkId}";
try {
$response = Http::withHeaders([
'Api-Key' => $this->apiKey(),
'Content-Type' => 'application/json',
])->timeout(10)->post("{$this->host()}/query", array_filter([
'id' => $vectorId,
'topK' => $effectiveTopK + 1, // +1 to exclude self
'includeMetadata' => true,
'namespace' => $this->namespace() ?: null,
'filter' => [
'is_active' => ['$eq' => true],
],
]));
if (! $response->successful()) {
Log::warning("[PineconeAdapter] Query failed: HTTP {$response->status()}");
return [];
}
$matches = $response->json('matches', []);
$results = [];
foreach ($matches as $match) {
$matchId = $match['id'] ?? '';
// Extract artwork ID from "artwork:123" format
if (! str_starts_with($matchId, 'artwork:')) {
continue;
}
$matchArtworkId = (int) substr($matchId, 8);
if ($matchArtworkId === $artworkId) {
continue; // skip self
}
$results[] = [
'artwork_id' => $matchArtworkId,
'score' => (float) ($match['score'] ?? 0.0),
];
}
return $results;
} catch (\Throwable $e) {
Log::warning("[PineconeAdapter] Query exception: {$e->getMessage()}");
return [];
}
}
public function upsertEmbedding(int $artworkId, array $embedding, array $metadata = []): void
{
$vectorId = "artwork:{$artworkId}";
// Spec §9B: metadata should include category_id, content_type, author_id, is_active, nsfw
$pineconeMetadata = array_merge([
'is_active' => true,
'category_id' => $metadata['category_id'] ?? null,
'content_type' => $metadata['content_type'] ?? null,
'author_id' => $metadata['author_id'] ?? null,
'nsfw' => $metadata['nsfw'] ?? false,
], array_diff_key($metadata, array_flip([
'category_id', 'content_type', 'author_id', 'nsfw', 'is_active',
])));
// Remove null values (Pinecone doesn't accept nulls in metadata)
$pineconeMetadata = array_filter($pineconeMetadata, fn ($v) => $v !== null);
try {
$response = Http::withHeaders([
'Api-Key' => $this->apiKey(),
'Content-Type' => 'application/json',
])->timeout(10)->post("{$this->host()}/vectors/upsert", array_filter([
'vectors' => [
[
'id' => $vectorId,
'values' => array_map('floatval', $embedding),
'metadata' => $pineconeMetadata,
],
],
'namespace' => $this->namespace() ?: null,
]));
if (! $response->successful()) {
Log::warning("[PineconeAdapter] Upsert failed for artwork {$artworkId}: HTTP {$response->status()}");
}
} catch (\Throwable $e) {
Log::warning("[PineconeAdapter] Upsert exception for artwork {$artworkId}: {$e->getMessage()}");
}
}
public function deleteEmbedding(int $artworkId): void
{
$vectorId = "artwork:{$artworkId}";
try {
Http::withHeaders([
'Api-Key' => $this->apiKey(),
'Content-Type' => 'application/json',
])->timeout(10)->post("{$this->host()}/vectors/delete", array_filter([
'ids' => [$vectorId],
'namespace' => $this->namespace() ?: null,
]));
} catch (\Throwable $e) {
Log::warning("[PineconeAdapter] Delete exception for artwork {$artworkId}: {$e->getMessage()}");
}
}
}