150 lines
5.3 KiB
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()}");
|
|
}
|
|
}
|
|
}
|