First commit

This commit is contained in:
Saufi
2026-05-18 08:56:23 +08:00
commit fd3d3a4d2b
147 changed files with 22099 additions and 0 deletions

View 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}"),
};
}
}