160 lines
6.4 KiB
PHP
160 lines
6.4 KiB
PHP
<?php
|
|
|
|
use App\Actions\Embeddings\RecordEmbeddingUsageEvent;
|
|
use App\Models\EmbeddingEntry;
|
|
use App\Services\EmbeddingWorkbench;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Livewire\Attributes\Title;
|
|
use Livewire\Component;
|
|
|
|
new #[Title('Embedding search')] class extends Component
|
|
{
|
|
public string $similarityQuery = '';
|
|
|
|
public int $similarityLimit = 10;
|
|
|
|
public string $minimumSimilarity = '0.30';
|
|
|
|
public string $embeddingModel = '';
|
|
|
|
/** @var array<int, array{name: string, dimensions: int, default: bool}> */
|
|
public array $modelOptions = [];
|
|
|
|
/** @var array<int, array<string, mixed>> */
|
|
public array $similarityResults = [];
|
|
|
|
public string $providerSummary = '';
|
|
|
|
public int $storedCount = 0;
|
|
|
|
public bool $databaseReady = true;
|
|
|
|
public function mount(EmbeddingWorkbench $workbench): void
|
|
{
|
|
$this->initializeWorkbench($workbench);
|
|
}
|
|
|
|
public function searchSimilar(EmbeddingWorkbench $workbench, RecordEmbeddingUsageEvent $recordUsageEvent): void
|
|
{
|
|
$this->validate([
|
|
'similarityQuery' => ['required', 'string', 'max:1000'],
|
|
'similarityLimit' => ['required', 'integer', 'min:1', 'max:50'],
|
|
'minimumSimilarity' => ['required', 'numeric', 'min:0', 'max:1'],
|
|
'embeddingModel' => ['required', 'string'],
|
|
]);
|
|
|
|
if (! $this->databaseReady) {
|
|
$this->addError('similarityQuery', __('The embedding table is not ready.'));
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$this->similarityResults = $workbench
|
|
->search($this->similarityQuery, $this->similarityLimit, (float) $this->minimumSimilarity, $this->embeddingModel)
|
|
->map(function (array $result) use ($workbench): array {
|
|
return [
|
|
...$workbench->presentEntry($result['entry']),
|
|
'similarity' => $result['similarity'],
|
|
'distance' => $result['distance'],
|
|
];
|
|
})
|
|
->all();
|
|
|
|
$recordUsageEvent->handle(
|
|
user: auth()->user(),
|
|
workbench: $workbench,
|
|
selectedModel: $this->embeddingModel,
|
|
tool: 'search',
|
|
inputCount: 1,
|
|
resultCount: count($this->similarityResults),
|
|
);
|
|
|
|
$this->refreshSummary($workbench);
|
|
} catch (\Throwable $exception) {
|
|
$this->addError('embeddingModel', $exception->getMessage());
|
|
}
|
|
}
|
|
|
|
protected function initializeWorkbench(EmbeddingWorkbench $workbench): void
|
|
{
|
|
$this->modelOptions = $workbench->modelOptions();
|
|
$this->embeddingModel = $this->embeddingModel ?: $workbench->modelName();
|
|
|
|
$this->refreshSummary($workbench);
|
|
}
|
|
|
|
protected function refreshSummary(EmbeddingWorkbench $workbench): void
|
|
{
|
|
$provider = $workbench->providerName();
|
|
$model = $workbench->modelName($this->embeddingModel);
|
|
$dimensions = $workbench->dimensions($model);
|
|
|
|
$this->providerSummary = "{$provider} / {$model} / {$dimensions}d";
|
|
$this->databaseReady = Schema::hasTable('embedding_entries');
|
|
|
|
if (! $this->databaseReady) {
|
|
$this->storedCount = 0;
|
|
|
|
return;
|
|
}
|
|
|
|
$this->storedCount = EmbeddingEntry::query()
|
|
->where('provider', $provider)
|
|
->where('model', $model)
|
|
->where('embedding_dimensions', $dimensions)
|
|
->count();
|
|
}
|
|
|
|
};
|
|
?>
|
|
|
|
<section class="flex w-full flex-col gap-6">
|
|
@include('pages.embeddings.partials.navigation', ['heading' => __('Similarity search')])
|
|
|
|
<section class="rounded-lg border border-zinc-200 bg-white p-5 dark:border-zinc-700 dark:bg-zinc-900">
|
|
<form wire:submit="searchSimilar" class="flex flex-col gap-4">
|
|
<flux:field>
|
|
<flux:label>{{ __('Query') }}</flux:label>
|
|
<flux:textarea wire:model="similarityQuery" rows="4" placeholder="{{ __('semantic search') }}" />
|
|
<flux:error name="similarityQuery" />
|
|
</flux:field>
|
|
|
|
<div class="grid gap-3 sm:grid-cols-2">
|
|
<flux:input wire:model="similarityLimit" :label="__('Limit')" type="number" min="1" max="50" />
|
|
<flux:input wire:model="minimumSimilarity" :label="__('Minimum similarity')" type="number" min="0" max="1" step="0.05" />
|
|
</div>
|
|
|
|
<div>
|
|
<flux:button type="submit" variant="primary" icon="magnifying-glass" :disabled="! $databaseReady" class="data-loading:opacity-60">
|
|
<span wire:loading.remove wire:target="searchSimilar">{{ __('Search') }}</span>
|
|
<span wire:loading wire:target="searchSimilar">{{ __('Searching') }}</span>
|
|
</flux:button>
|
|
</div>
|
|
|
|
@if ($similarityResults !== [])
|
|
<div class="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-700">
|
|
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
|
|
<thead class="bg-zinc-50 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
|
|
<tr>
|
|
<th class="px-3 py-2 text-start font-medium">{{ __('Phrase') }}</th>
|
|
<th class="px-3 py-2 text-start font-medium">{{ __('Similarity') }}</th>
|
|
<th class="px-3 py-2 text-start font-medium">{{ __('Distance') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
|
|
@foreach ($similarityResults as $result)
|
|
<tr wire:key="similarity-result-{{ $result['id'] }}">
|
|
<td class="px-3 py-3 font-medium text-zinc-900 dark:text-zinc-100">{{ $result['source_text'] }}</td>
|
|
<td class="px-3 py-3 text-emerald-700 dark:text-emerald-300">{{ number_format($result['similarity'], 3) }}</td>
|
|
<td class="px-3 py-3 text-zinc-600 dark:text-zinc-300">{{ number_format($result['distance'], 3) }}</td>
|
|
</tr>
|
|
@endforeach
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
@endif
|
|
</form>
|
|
</section>
|
|
</section>
|