baseUrl = config('qdrant.base_url'); $this->apiKey = config('qdrant.api_key'); $this->collection = config('qdrant.collection'); $this->timeouts = config('qdrant.timeout'); $this->batchSize = config('qdrant.batch_size'); } // ========================================================================= // COLLECTION MANAGEMENT // ========================================================================= /** * Buat collection jika belum wujud. * Selamat dipanggil berulang kali (idempotent). * * @throws RuntimeException */ public function ensureCollectionExists(): void { try { $response = $this->request('GET', "/collections/{$this->collection}"); if ($response->status() === 200) { return; // Collection sudah wujud } } catch (RequestException $e) { if ($e->response->status() !== 404) { throw new RuntimeException( 'Gagal semak collection Qdrant: ' . $e->response->body() ); } } // Collection tidak wujud — buat baru $this->createCollection(); } /** * Buat collection baru dengan konfigurasi dari config/qdrant.php */ public function createCollection(): void { $vectorSize = config('qdrant.vector.size'); $vectorDistance = config('qdrant.vector.distance'); $response = $this->request('PUT', "/collections/{$this->collection}", [ 'vectors' => [ 'size' => $vectorSize, 'distance' => $vectorDistance, ], ]); if (!$response->ok()) { throw new RuntimeException( "Gagal buat collection Qdrant: " . $response->body() ); } Log::info("Qdrant collection '{$this->collection}' berjaya dibuat.", [ 'vector_size' => $vectorSize, 'vector_distance' => $vectorDistance, ]); } // ========================================================================= // POINT OPERATIONS // ========================================================================= /** * Upsert satu point ke Qdrant. * Jika point dengan ID yang sama sudah wujud, ia akan digantikan. * * @param string $pointId UUID point * @param float[] $vector Embedding vector * @param array $payload Metadata point * @throws RuntimeException */ public function upsertPoint(string $pointId, array $vector, array $payload): void { $this->upsertPoints([ [ 'id' => $pointId, 'vector' => $vector, 'payload' => $payload, ], ]); } /** * Upsert banyak point sekaligus (lebih efisien). * * @param array[] $points Array of {id, vector, payload} * @throws RuntimeException */ public function upsertPoints(array $points): void { if (empty($points)) { return; } // Hantar dalam batch untuk elak request terlalu besar foreach (array_chunk($points, $this->batchSize) as $batch) { try { $response = $this->request( 'PUT', "/collections/{$this->collection}/points", ['points' => $batch] ); if (!$response->ok()) { throw new RuntimeException( "Qdrant upsert gagal: " . $response->body() ); } } catch (ConnectionException $e) { throw new RuntimeException( 'Tidak dapat sambung ke Qdrant semasa upsert.', 0, $e ); } } } /** * Cari point yang paling serupa dengan vector yang diberikan. * * @param float[] $vector Query vector * @param int $limit Bilangan hasil * @param array $filter Payload filter (optional) * @param float $scoreThreshold Min score (optional) * @return array[] Array of {id, score, payload} * @throws RuntimeException */ public function searchSimilar( array $vector, int $limit = 5, array $filter = [], float $scoreThreshold = 0.0 ): array { $body = [ 'vector' => $vector, 'limit' => $limit, 'with_payload' => true, 'with_vector' => false, ]; if ($scoreThreshold > 0.0) { $body['score_threshold'] = $scoreThreshold; } if (!empty($filter)) { $body['filter'] = $filter; } try { $response = $this->request( 'POST', "/collections/{$this->collection}/points/search", $body ); $response->throw(); return $response->json('result', []); } catch (ConnectionException $e) { throw new RuntimeException( 'Tidak dapat sambung ke Qdrant semasa carian.', 0, $e ); } catch (RequestException $e) { Log::error('Qdrant search gagal', [ 'status' => $e->response->status(), 'body' => $e->response->body(), ]); throw new RuntimeException( 'Carian dalam Qdrant gagal.', 0, $e ); } } /** * Kemaskini payload point yang sedia ada. * Berguna untuk set is_active=false tanpa delete point. * * @param string $pointId * @param array $payload Hanya field yang hendak dikemaskini * @throws RuntimeException */ public function updatePayload(string $pointId, array $payload): void { try { $response = $this->request( 'POST', "/collections/{$this->collection}/points/payload", [ 'payload' => $payload, 'points' => [$pointId], ] ); if (!$response->ok()) { throw new RuntimeException( "Qdrant payload update gagal untuk point {$pointId}: " . $response->body() ); } } catch (ConnectionException $e) { throw new RuntimeException( 'Tidak dapat sambung ke Qdrant semasa update payload.', 0, $e ); } } /** * Kemaskini payload untuk banyak point sekaligus. * Berguna untuk deactivate semua chunk sesuatu dokumen. * * @param string[] $pointIds * @param array $payload */ public function updatePayloadBatch(array $pointIds, array $payload): void { if (empty($pointIds)) { return; } foreach (array_chunk($pointIds, $this->batchSize) as $batch) { $this->request( 'POST', "/collections/{$this->collection}/points/payload", [ 'payload' => $payload, 'points' => $batch, ] ); } } /** * Padam point dari Qdrant. * Gunakan ini hanya untuk hard delete yang benar-benar diperlukan. * Untuk soft delete, gunakan updatePayload({is_active: false}). * * @param string|string[] $pointIds */ public function deletePoints(array|string $pointIds): void { $ids = is_array($pointIds) ? $pointIds : [$pointIds]; if (empty($ids)) { return; } foreach (array_chunk($ids, $this->batchSize) as $batch) { try { $this->request( 'POST', "/collections/{$this->collection}/points/delete", ['points' => $batch] ); } catch (ConnectionException $e) { Log::error('Qdrant delete gagal', ['error' => $e->getMessage()]); throw new RuntimeException( 'Tidak dapat sambung ke Qdrant semasa delete.', 0, $e ); } } } /** * Scroll — dapatkan semua point yang memenuhi filter. * Berguna untuk audit atau bulk operations. * * @param array $filter * @param int $limit * @param ?string $offset Point ID untuk paginasi * @return array{points: array[], next_page_offset: ?string} */ public function scroll(array $filter = [], int $limit = 100, ?string $offset = null): array { $body = [ 'limit' => $limit, 'with_payload' => true, 'with_vector' => false, ]; if (!empty($filter)) { $body['filter'] = $filter; } if ($offset !== null) { $body['offset'] = $offset; } try { $response = $this->request( 'POST', "/collections/{$this->collection}/points/scroll", $body ); $response->throw(); return [ 'points' => $response->json('result.points', []), 'next_page_offset' => $response->json('result.next_page_offset'), ]; } catch (ConnectionException $e) { throw new RuntimeException( 'Tidak dapat sambung ke Qdrant semasa scroll.', 0, $e ); } } /** * Semak kesihatan Qdrant. * * @return array{online: bool, collection_exists: bool, points_count: int|null, error: ?string} */ public function healthCheck(): array { $result = [ 'online' => false, 'collection_exists' => false, 'points_count' => null, 'error' => null, ]; try { $response = Http::timeout($this->timeouts['connect']) ->when($this->apiKey, fn($h) => $h->withToken($this->apiKey)) ->get("{$this->baseUrl}/healthz"); if (!$response->ok()) { $result['error'] = 'Qdrant tidak responsif'; return $result; } $result['online'] = true; // Semak collection $collResponse = $this->request('GET', "/collections/{$this->collection}"); if ($collResponse->ok()) { $result['collection_exists'] = true; $result['points_count'] = $collResponse->json( 'result.points_count' ); } } catch (ConnectionException $e) { $result['error'] = 'Tidak dapat sambung ke Qdrant: ' . $e->getMessage(); } catch (\Exception $e) { $result['error'] = $e->getMessage(); } return $result; } // ========================================================================= // FILTER BUILDERS // ========================================================================= /** * Bina filter Qdrant untuk carian berdasarkan kategori dan jenis. * * Gunakan: QdrantService::buildFilter(category_id: 1, is_active: true) */ public function buildFilter( ?int $categoryId = null, ?bool $isActive = true, ?string $sourceType = null, ?string $knowledgeType = null, ): array { $must = []; // Sentiasa tapis yang aktif sahaja (default) if ($isActive !== null) { $must[] = [ 'key' => 'is_active', 'match' => ['value' => $isActive], ]; } if ($categoryId !== null) { $must[] = [ 'key' => 'category_id', 'match' => ['value' => $categoryId], ]; } if ($sourceType !== null) { $must[] = [ 'key' => 'source_type', 'match' => ['value' => $sourceType], ]; } if ($knowledgeType !== null) { $must[] = [ 'key' => 'knowledge_type', 'match' => ['value' => $knowledgeType], ]; } if (empty($must)) { return []; } return ['must' => $must]; } // ========================================================================= // PRIVATE HELPERS // ========================================================================= private function request(string $method, string $path, array $body = []) { $http = Http::timeout($this->timeouts['request']) ->when($this->apiKey, fn($h) => $h->withHeaders(['api-key' => $this->apiKey])); return match (strtoupper($method)) { 'GET' => $http->get("{$this->baseUrl}{$path}"), 'POST' => $http->post("{$this->baseUrl}{$path}", $body), 'PUT' => $http->put("{$this->baseUrl}{$path}", $body), 'DELETE' => $http->delete("{$this->baseUrl}{$path}", $body), default => throw new \InvalidArgumentException("Method tidak disokong: {$method}"), }; } }