25. Full-text search. Laravel Scout. PGroonga. Postgres extensions. Eloquent filters

Відео версія
(YouTube - для зворотнього зв'язку)

Повнотекстовий пошук — це величезна тема. Повний розбір алгоритмів, готових рішень та їх оптимізації неможливо описати навіть у рамках серії статей.
У цій статті я розглядаю та пояснюю свій вибір для конкретної ситуації. Я маю право помилятися і змінити цей вибір у майбутньому.

TL;DR

  • Стандартні не-SQL пошукові двигуни швидкі й ефективні. Але пам'ятайте, що ви втратите SQL-контроль.
  • І для повного позитивного досвіду вам доведеться будувати дві архітектури: реляційну й документну, а також думати про їхню синхронізацію.
  • Це можливо, але дуже ДОРОГО.
  • Мій вибір для повнотекстового пошуку — PGroonga.
  • Для продакшену рекомендую використовувати hosted DB сервіси. Але будьте готові до того, що вони можуть не підтримувати необхідні розширення.
  • Postgres full-text search гарно працює, але має проблему з установкою словників на hosted сервісах.
  • Eloquent filters.
  • Якщо хочете підказувати планувальнику Postgres — pg_hint_plan.
  • Explain visualizer

PGroonga

Документація.

Використовуйте або готове рішення — Docker image,
або встановіть власноруч — Інструкція.

Будьте обережні при використанні similar search v2 — у випадку не Index Scan, запити падають з критичною помилкою!

Будь-які розширення треба вмикати вручну.

Migration
<?php

use Illuminate\Database\Migrations\Migration;

return new class extends Migration
{
    public function up(): void
    {
        DB::unprepared('CREATE EXTENSION IF NOT EXISTS pgroonga');
    }
};

Мій приклад індексів:

Migration
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class() extends Migration {
    public function up(): void
    {
        Schema::create('bands', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('slug')->unique();
            $table->typeColumn('text[]', 'synonyms')->nullable();
            $table->jsonb('description')->nullable();
        });

        DB::unprepared(
            "
                CREATE INDEX pgroonga_band_name ON bands
                USING pgroonga (
                    name pgroonga_varchar_full_text_search_ops_v2,
                    synonyms pgroonga_text_array_full_text_search_ops_v2
                )
                WITH (normalizers='NormalizerNFKC100')"
        );

        DB::unprepared(
            "
                CREATE INDEX pgroonga_band_description ON bands
                USING pgroonga (
                    description pgroonga_jsonb_full_text_search_ops_v2
                )
                WITH (normalizers='NormalizerNFKC100')"
        );
    }
};

Приклад пошуку

Modules/Band/ModelFilters/BandFilter.php
 public function search(string $search): void
    {
        $this->where(function (Builder $query) use ($search) {
            $query
                ->whereRaw(
                    "bands.name &@~
                (?, ARRAY[3], 'pgroonga_band_name')::pgroonga_full_text_search_condition",
                    [$search]
                )
                ->orWhereRaw("bands.synonyms &@~
                (?, array_fill(2, array[100]), 'pgroonga_band_name')::pgroonga_full_text_search_condition", [$search])
                ->orWhereRaw('description &@~ ?', [$search]);
        });
    }