暂存
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\Embedding;
|
||||
|
||||
use app\service\LLM\LLMRequestException;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\RequestException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class BigModelEmbeddingClient
|
||||
{
|
||||
private Client $client;
|
||||
private array $config;
|
||||
|
||||
public function __construct(?array $config = null)
|
||||
{
|
||||
$this->config = $config ?? config('LLMapi.embedding', []);
|
||||
$baseUrl = rtrim((string) ($this->config['base_url'] ?? ''), '/');
|
||||
|
||||
$this->client = new Client([
|
||||
'base_uri' => $baseUrl . '/',
|
||||
'timeout' => (int) ($this->config['timeout'] ?? 60),
|
||||
'connect_timeout' => (int) ($this->config['connect_timeout'] ?? 10),
|
||||
]);
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return trim((string) ($this->config['api_key'] ?? '')) !== ''
|
||||
&& trim((string) ($this->config['base_url'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
public function embed(array $texts, array $options = []): array
|
||||
{
|
||||
if (!$this->isConfigured()) {
|
||||
throw new RuntimeException('BigModel embedding API is not configured.');
|
||||
}
|
||||
|
||||
$texts = array_values($texts);
|
||||
if ($texts === []) {
|
||||
return [
|
||||
'model' => (string) ($options['model'] ?? $this->config['model'] ?? 'embedding-3'),
|
||||
'data' => [],
|
||||
'usage' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$batchSize = (int) ($this->config['batch_size'] ?? 64);
|
||||
if (count($texts) > $batchSize) {
|
||||
throw new RuntimeException("Embedding batch exceeds configured batch size {$batchSize}.");
|
||||
}
|
||||
|
||||
$body = [
|
||||
'model' => $options['model'] ?? $this->config['model'] ?? 'embedding-3',
|
||||
'input' => $texts,
|
||||
];
|
||||
|
||||
$dimensions = $options['dimensions'] ?? $this->config['dimensions'] ?? null;
|
||||
if ($dimensions !== null) {
|
||||
$dimensions = (int) $dimensions;
|
||||
if (!in_array($dimensions, [256, 512, 1024, 2048], true)) {
|
||||
throw new RuntimeException('Embedding dimensions must be one of 256, 512, 1024, or 2048.');
|
||||
}
|
||||
$body['dimensions'] = $dimensions;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->client->post('embeddings', [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $this->config['api_key'],
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => $body,
|
||||
]);
|
||||
} catch (RequestException $exception) {
|
||||
throw $this->requestException($exception);
|
||||
} catch (Throwable $exception) {
|
||||
throw new RuntimeException('BigModel embedding request failed: ' . $exception->getMessage(), 0, $exception);
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response->getBody(), true);
|
||||
if (!is_array($payload) || !isset($payload['data']) || !is_array($payload['data'])) {
|
||||
throw new RuntimeException('BigModel embedding response is invalid.');
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function requestException(RequestException $exception): LLMRequestException
|
||||
{
|
||||
$statusCode = $exception->getResponse()?->getStatusCode();
|
||||
$body = $exception->getResponse() ? (string) $exception->getResponse()->getBody() : '';
|
||||
$payload = json_decode($body, true);
|
||||
$providerCode = null;
|
||||
$providerMessage = null;
|
||||
|
||||
if (is_array($payload)) {
|
||||
$providerCode = isset($payload['error']['code']) ? (string) $payload['error']['code'] : null;
|
||||
$providerMessage = isset($payload['error']['message']) ? (string) $payload['error']['message'] : null;
|
||||
|
||||
if ($providerCode === null && isset($payload['code'])) {
|
||||
$providerCode = (string) $payload['code'];
|
||||
}
|
||||
if ($providerMessage === null && isset($payload['message'])) {
|
||||
$providerMessage = (string) $payload['message'];
|
||||
}
|
||||
}
|
||||
|
||||
$message = 'BigModel embedding request failed';
|
||||
if ($statusCode !== null) {
|
||||
$message .= " with HTTP {$statusCode}";
|
||||
}
|
||||
if ($providerCode !== null) {
|
||||
$message .= " and provider code {$providerCode}";
|
||||
}
|
||||
if ($providerMessage !== null) {
|
||||
$message .= ": {$providerMessage}";
|
||||
} else {
|
||||
$message .= ': ' . $exception->getMessage();
|
||||
}
|
||||
|
||||
return new LLMRequestException($message, $statusCode, $providerCode, is_array($payload) ? $payload : null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\Embedding;
|
||||
|
||||
use app\service\LLM\LLMRequestException;
|
||||
use app\service\LLM\LLMRetryQueue;
|
||||
use Throwable;
|
||||
|
||||
class ChunkEmbeddingHandler
|
||||
{
|
||||
private BigModelEmbeddingClient $client;
|
||||
private ChunkEmbeddingRepository $chunks;
|
||||
private LLMRetryQueue $retryQueue;
|
||||
|
||||
public function __construct(
|
||||
?BigModelEmbeddingClient $client = null,
|
||||
?ChunkEmbeddingRepository $chunks = null,
|
||||
?LLMRetryQueue $retryQueue = null
|
||||
) {
|
||||
$this->client = $client ?? new BigModelEmbeddingClient();
|
||||
$this->chunks = $chunks ?? new ChunkEmbeddingRepository();
|
||||
$this->retryQueue = $retryQueue ?? new LLMRetryQueue();
|
||||
}
|
||||
|
||||
public function handle(array $task): void
|
||||
{
|
||||
if (($task['target_type'] ?? null) !== 'archive') {
|
||||
return;
|
||||
}
|
||||
|
||||
$archiveUid = trim((string) ($task['target_uid'] ?? ''));
|
||||
if ($archiveUid === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$batchSize = (int) config('LLMapi.embedding.batch_size', 32);
|
||||
$chunks = $this->chunks->findQueuedChunks($archiveUid, $batchSize);
|
||||
if ($chunks === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$chunkUids = array_column($chunks, 'chunk_uid');
|
||||
$this->chunks->markProcessing($chunkUids);
|
||||
|
||||
try {
|
||||
$payload = $this->retryQueue->run(
|
||||
fn (): array => $this->client->embed(array_column($chunks, 'text'), [
|
||||
'model' => config('LLMapi.embedding.model', 'embedding-3'),
|
||||
'dimensions' => config('LLMapi.embedding.dimensions', 2048),
|
||||
]),
|
||||
config('LLMapi.embedding.retry', [])
|
||||
);
|
||||
|
||||
$this->persistEmbeddings($chunks, $payload);
|
||||
} catch (Throwable $exception) {
|
||||
$this->chunks->markFailed($chunkUids, $exception->getMessage(), $this->isRetryable($exception));
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function persistEmbeddings(array $chunks, array $payload): void
|
||||
{
|
||||
$model = (string) ($payload['model'] ?? config('LLMapi.embedding.model', 'embedding-3'));
|
||||
$usage = is_array($payload['usage'] ?? null) ? $payload['usage'] : [];
|
||||
$results = [];
|
||||
|
||||
foreach ($payload['data'] ?? [] as $item) {
|
||||
if (!is_array($item) || !isset($item['index'], $item['embedding']) || !is_array($item['embedding'])) {
|
||||
continue;
|
||||
}
|
||||
$results[(int) $item['index']] = $item['embedding'];
|
||||
}
|
||||
|
||||
foreach ($chunks as $index => $chunk) {
|
||||
if (!isset($results[$index])) {
|
||||
$this->chunks->markFailed([$chunk['chunk_uid']], 'Embedding response missing index ' . $index, true);
|
||||
continue;
|
||||
}
|
||||
|
||||
$embedding = $results[$index];
|
||||
$this->chunks->markEmbedded($chunk['chunk_uid'], [
|
||||
'provider' => 'bigmodel',
|
||||
'model' => $model,
|
||||
'dimensions' => count($embedding),
|
||||
'embedding' => $embedding,
|
||||
'usage' => $usage,
|
||||
'embedded_at' => date(DATE_ATOM),
|
||||
], $model);
|
||||
}
|
||||
}
|
||||
|
||||
private function isRetryable(Throwable $exception): bool
|
||||
{
|
||||
if (!$exception instanceof LLMRequestException) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$statusCode = $exception->statusCode();
|
||||
if ($statusCode === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $statusCode === 429 || $statusCode >= 500;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\Embedding;
|
||||
|
||||
use support\Db;
|
||||
|
||||
class ChunkEmbeddingRepository
|
||||
{
|
||||
public function queuePendingArchiveTasks(int $limit): array
|
||||
{
|
||||
$statuses = [EmbeddingStatus::PENDING, EmbeddingStatus::QUEUED, EmbeddingStatus::FAILED_RETRYABLE];
|
||||
$archiveUids = Db::table('chunks')
|
||||
->whereIn('embedding_status', $statuses)
|
||||
->select('archive_uid')
|
||||
->groupBy('archive_uid')
|
||||
->orderByRaw('MIN(id)')
|
||||
->limit($limit)
|
||||
->pluck('archive_uid')
|
||||
->all();
|
||||
|
||||
$archiveUids = array_values(array_filter(array_map('strval', $archiveUids)));
|
||||
foreach ($archiveUids as $archiveUid) {
|
||||
Db::table('chunks')
|
||||
->where('archive_uid', $archiveUid)
|
||||
->whereIn('embedding_status', $statuses)
|
||||
->update([
|
||||
'embedding_status' => EmbeddingStatus::QUEUED,
|
||||
'embedding_error' => null,
|
||||
'embedding_updated_at' => Db::raw('CURRENT_TIMESTAMP'),
|
||||
]);
|
||||
}
|
||||
|
||||
return $archiveUids;
|
||||
}
|
||||
|
||||
public function findQueuedChunks(string $archiveUid, int $limit): array
|
||||
{
|
||||
$chunks = Db::table('chunks')
|
||||
->where('archive_uid', $archiveUid)
|
||||
->whereIn('embedding_status', [EmbeddingStatus::QUEUED, EmbeddingStatus::PROCESSING])
|
||||
->orderBy('chunk_index')
|
||||
->limit($limit)
|
||||
->get(['chunk_uid', 'archive_uid', 'chunk_index', 'text'])
|
||||
->all();
|
||||
|
||||
return array_map(static fn (object $chunk): array => [
|
||||
'chunk_uid' => (string) $chunk->chunk_uid,
|
||||
'archive_uid' => (string) $chunk->archive_uid,
|
||||
'chunk_index' => (int) $chunk->chunk_index,
|
||||
'text' => (string) $chunk->text,
|
||||
], $chunks);
|
||||
}
|
||||
|
||||
public function markProcessing(array $chunkUids): void
|
||||
{
|
||||
$this->updateStatus($chunkUids, EmbeddingStatus::PROCESSING);
|
||||
}
|
||||
|
||||
public function markEmbedded(string $chunkUid, array $embeddingRef, string $model): void
|
||||
{
|
||||
Db::table('chunks')->where('chunk_uid', $chunkUid)->update([
|
||||
'embedding_status' => EmbeddingStatus::EMBEDDED,
|
||||
'embedding_ref' => json_encode($embeddingRef, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'embedding_model' => $model,
|
||||
'embedding_error' => null,
|
||||
'embedding_updated_at' => Db::raw('CURRENT_TIMESTAMP'),
|
||||
'search_index_status' => 0,
|
||||
'search_index_error' => null,
|
||||
'search_index_updated_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function markFailed(array $chunkUids, string $error, bool $retryable): void
|
||||
{
|
||||
if ($chunkUids === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
Db::table('chunks')->whereIn('chunk_uid', $chunkUids)->update([
|
||||
'embedding_status' => $retryable ? EmbeddingStatus::FAILED_RETRYABLE : EmbeddingStatus::FAILED_TERMINAL,
|
||||
'embedding_error' => mb_substr($error, 0, 4000),
|
||||
'embedding_updated_at' => Db::raw('CURRENT_TIMESTAMP'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function updateStatus(array $chunkUids, int $status): void
|
||||
{
|
||||
if ($chunkUids === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
Db::table('chunks')->whereIn('chunk_uid', $chunkUids)->update([
|
||||
'embedding_status' => $status,
|
||||
'embedding_updated_at' => Db::raw('CURRENT_TIMESTAMP'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\Embedding;
|
||||
|
||||
class EmbeddingStatus
|
||||
{
|
||||
public const PENDING = 0;
|
||||
public const QUEUED = 1;
|
||||
public const PROCESSING = 2;
|
||||
public const EMBEDDED = 3;
|
||||
public const FAILED_RETRYABLE = 4;
|
||||
public const FAILED_TERMINAL = 5;
|
||||
}
|
||||
Reference in New Issue
Block a user