QdrantService.php

Code Hygiene Score: 85

Keine Issues gefunden.

Dependencies 2

Klassen 1

Funktionen 7

Verwendet von 7

Versionen 13

Code

<?php

declare(strict_types=1);

namespace Infrastructure\AI;

// @responsibility: Qdrant-Vektor-DB für Similarity-Search

use Infrastructure\Config\CredentialService;
use RuntimeException;

final class QdrantService
{
    /**
     * Default timeout for HTTP requests in seconds.
     */
    private const int DEFAULT_TIMEOUT = 30;

    /**
     * Health check timeout in seconds.
     */
    private const int HEALTH_CHECK_TIMEOUT = 5;

    private readonly string $host;

    /** @param string|null $host Qdrant API host (uses env QDRANT_HOST if null) */
    public function __construct(?string $host = null)
    {
        $this->host = $host ?? CredentialService::getQdrantHost();
    }

    /**
     * Searches for similar vectors in a Qdrant collection.
     *
     * @param array<int, float> $vector     Query embedding vector
     * @param string            $collection Collection to search (default: documents)
     * @param int               $limit      Max results (default: 5)
     * @return array<int, array{id: int|string, score: float, payload: array<string, mixed>}>
     * @throws RuntimeException If API request fails
     */
    public function search(array $vector, string $collection = 'documents', int $limit = 5): array
    {
        $url = sprintf('%s/collections/%s/points/search', $this->host, urlencode($collection));

        $payload = [
            'vector' => array_values($vector),
            'limit' => $limit,
            'with_payload' => true,
        ];

        $response = $this->makeRequest($url, $payload, self::DEFAULT_TIMEOUT);

        if (!isset($response['result']) || !is_array($response['result'])) {
            throw new RuntimeException('Invalid search response from Qdrant API');
        }

        return array_map(
            static function (mixed $item): array {
                if (!is_array($item)) {
                    throw new RuntimeException('Invalid search result item format');
                }

                return [
                    'id' => $item['id'] ?? throw new RuntimeException('Missing id in search result'),
                    'score' => (float) ($item['score'] ?? throw new RuntimeException('Missing score in search result')),
                    'payload' => is_array($item['payload'] ?? null) ? $item['payload'] : [],
                ];
            },
            $response['result']
        );
    }

    /** Checks if a collection exists in Qdrant. */
    public function collectionExists(string $collection): bool
    {
        $url = sprintf('%s/collections/%s', $this->host, urlencode($collection));

        try {
            $ch = curl_init($url);

            if ($ch === false) {
                return false;
            }

            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT => self::HEALTH_CHECK_TIMEOUT,
                CURLOPT_CONNECTTIMEOUT => self::HEALTH_CHECK_TIMEOUT,
                CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
                CURLOPT_CUSTOMREQUEST => 'GET',
            ]);

            $result = curl_exec($ch);
            $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

            curl_close($ch);

            return $result !== false && $httpCode === 200;
        } catch (\Throwable) {
            return false;
        }
    }

    /** Lists all available collections. Returns empty array on error. */
    public function listCollections(): array
    {
        $url = $this->host . '/collections';

        try {
            $ch = curl_init($url);

            if ($ch === false) {
                return [];
            }

            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT => self::HEALTH_CHECK_TIMEOUT,
                CURLOPT_CONNECTTIMEOUT => self::HEALTH_CHECK_TIMEOUT,
                CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
                CURLOPT_CUSTOMREQUEST => 'GET',
            ]);

            $result = curl_exec($ch);
            $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

            curl_close($ch);

            if ($result === false || $httpCode !== 200) {
                return [];
            }

            $decoded = json_decode((string) $result, true);

            if (!is_array($decoded) || !isset($decoded['result']['collections'])) {
                return [];
            }

            $collections = [];
            foreach ($decoded['result']['collections'] as $collection) {
                if (is_array($collection) && isset($collection['name'])) {
                    $collections[] = (string) $collection['name'];
                }
            }

            sort($collections);

            return $collections;
        } catch (\Throwable) {
            return [];
        }
    }

    /** Checks if Qdrant API is available (health check). */
    public function isAvailable(): bool
    {
        $url = $this->host . '/';

        try {
            $ch = curl_init($url);

            if ($ch === false) {
                return false;
            }

            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT => self::HEALTH_CHECK_TIMEOUT,
                CURLOPT_CONNECTTIMEOUT => self::HEALTH_CHECK_TIMEOUT,
                CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
            ]);

            $result = curl_exec($ch);
            $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

            curl_close($ch);

            return $result !== false && $httpCode === 200;
        } catch (\Throwable) {
            return false;
        }
    }

    /**
     * Retrieves collection metadata (vector size, point count, etc).
     * @return array<string, mixed>|null Null if collection doesn't exist
     * @throws RuntimeException If API request fails (excluding 404)
     */
    public function getCollectionInfo(string $collection): ?array
    {
        $url = sprintf('%s/collections/%s', $this->host, urlencode($collection));

        try {
            $ch = curl_init($url);

            if ($ch === false) {
                throw new RuntimeException('Failed to initialize cURL');
            }

            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT => self::DEFAULT_TIMEOUT,
                CURLOPT_CONNECTTIMEOUT => 10,
                CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
                CURLOPT_CUSTOMREQUEST => 'GET',
            ]);

            $result = curl_exec($ch);
            $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            $curlError = curl_error($ch);

            curl_close($ch);

            if ($result === false) {
                throw new RuntimeException(
                    sprintf('cURL request failed: %s', $curlError !== '' ? $curlError : 'Unknown error')
                );
            }

            if ($httpCode === 404) {
                return null;
            }

            if ($httpCode !== 200) {
                throw new RuntimeException(
                    sprintf('Qdrant API returned HTTP %d: %s', $httpCode, $result)
                );
            }

            $decoded = json_decode((string) $result, true);

            if (!is_array($decoded)) {
                throw new RuntimeException('Failed to decode JSON response from Qdrant API');
            }

            return is_array($decoded['result'] ?? null) ? $decoded['result'] : null;
        } catch (RuntimeException $e) {
            throw $e;
        } catch (\Throwable) {
            return null;
        }
    }

    /** Makes an HTTP POST request to Qdrant API. @throws RuntimeException */
    private function makeRequest(string $url, array $payload, int $timeout): array
    {
        $ch = curl_init($url);

        if ($ch === false) {
            throw new RuntimeException('Failed to initialize cURL');
        }

        $jsonPayload = json_encode($payload);

        if ($jsonPayload === false) {
            curl_close($ch);

            throw new RuntimeException('Failed to encode JSON payload');
        }

        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $jsonPayload,
            CURLOPT_TIMEOUT => $timeout,
            CURLOPT_CONNECTTIMEOUT => 10,
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json',
                'Content-Length: ' . strlen($jsonPayload),
            ],
        ]);

        $result = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $curlError = curl_error($ch);

        curl_close($ch);

        if ($result === false) {
            throw new RuntimeException(
                sprintf('cURL request failed: %s', $curlError !== '' ? $curlError : 'Unknown error')
            );
        }

        if ($httpCode !== 200) {
            throw new RuntimeException(
                sprintf('Qdrant API returned HTTP %d: %s', $httpCode, $result)
            );
        }

        $decoded = json_decode((string) $result, true);

        if (!is_array($decoded)) {
            throw new RuntimeException('Failed to decode JSON response from Qdrant API');
        }

        return $decoded;
    }
}
← Übersicht Graph