First commit
This commit is contained in:
466
app/Services/Qdrant/QdrantService.php
Normal file
466
app/Services/Qdrant/QdrantService.php
Normal file
@@ -0,0 +1,466 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qdrant;
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* QdrantService
|
||||
*
|
||||
* Wrapper untuk Qdrant REST API.
|
||||
* Menguruskan: buat collection, upsert, cari, update, dan delete point.
|
||||
*
|
||||
* Reka bentuk: Satu collection 'knowledge_base' untuk semua jenis knowledge.
|
||||
* Gunakan payload filtering untuk bezakan kategori, jenis, status.
|
||||
*/
|
||||
class QdrantService
|
||||
{
|
||||
private string $baseUrl;
|
||||
private ?string $apiKey;
|
||||
private string $collection;
|
||||
private array $timeouts;
|
||||
private int $batchSize;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user