andydefer/laravel-indexer 问题修复 & 功能扩展

解决BUG、新增功能、兼容多环境部署,快速响应你的开发需求

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

andydefer/laravel-indexer

Composer 安装命令:

composer require andydefer/laravel-indexer

包简介

A powerful and flexible indexing system for Laravel with Eloquent support, n-gram and metaphone tokenization, and advanced search capabilities.

README 文档

README

Un système d'indexation puissant et flexible pour Laravel avec support d'Eloquent, tokenisation par n-grammes et métaphones, et capacités de recherche avancées.

Version PHP Version Laravel Licence

Table des matières

  1. Introduction
  2. Fonctionnalités
  3. Installation
  4. Configuration
  5. Structure des tables
  6. Concepts fondamentaux
  7. Guide de démarrage
  8. Indexation des entités
  9. Recherche
  10. Suppression et nettoyage
  11. Architecture détaillée
  12. Performances
  13. Bonnes pratiques
  14. Dépannage
  15. Tests
  16. License

Introduction

Laravel Indexer est un package d'indexation full-text conçu pour Laravel qui transforme vos modèles Eloquent en documents recherchables. Il génère des tokens à partir de vos données (n-grammes lexicaux et métaphones phonétiques) et les stocke dans une base de données SQL, permettant des recherches ultra-rapides en O(k)k est le nombre de résultats.

Contrairement aux solutions Elasticsearch ou Algolia qui nécessitent des services externes, Laravel Indexer fonctionne directement avec votre base de données existante, sans infrastructure supplémentaire.

Comment ça fonctionne ?

  1. Indexation : Vos entités sont transformées en documents avec des tokens (n-grammes et métaphones)
  2. Stockage : Les documents et tokens sont persistés dans des tables SQL avec des index optimisés
  3. Recherche : Les requêtes sont transformées en tokens et exécutées via des requêtes SQL optimisées

Fonctionnalités

Core

  • Recherche full-text avec n-grammes (taille configurable : 3-5 par défaut)
  • Recherche phonétique avec métaphones (tolérance aux fautes d'orthographe)
  • Filtrage avancé par champ, cluster, namespace, fingerprint
  • Indexation en masse avec bufferisation pour des performances optimales
  • Recherche multi-critères (AND logique)
  • Architecture Repository pour une séparation claire des responsabilités

Stockage

  • Support de SQLite, MySQL, PostgreSQL
  • Index automatiques sur les colonnes de recherche
  • Bulk insert pour l'indexation massive
  • Transactions pour l'intégrité des données

Développement

  • Injection de dépendances et interfaces pour une intégration facile
  • Framework-agnostique (utilise uniquement Laravel et PHP pur)
  • Type-safe avec PHP 8.1+ (types stricts, readonly properties)
  • Tests unitaires et d'intégration complets
  • Benchmarks pour mesurer les performances

Installation

composer require andydefer/laravel-indexer

Prérequis

Dépendance Version
PHP 8.1 ou supérieur
Laravel 12.x, 13.x, 14.x ou 15.x
andydefer/laravel-repository ^2.9.2
andydefer/laravel-directive ^3.31
andydefer/php-console ^1.2
andydefer/jsonl-cache ^0.3.7
andydefer/laravel-logger ^3.8
andydefer/inverted-index-search ^0.3.0

Publier les migrations

php artisan vendor:publish --tag=indexer-migrations
php artisan migrate

Publier la configuration (Optionnel)

php artisan vendor:publish --tag=indexer-config

Configuration

Le fichier de configuration config/indexer.php :

<?php

return [
    /*
    |--------------------------------------------------------------------------
    | Storage Path
    |--------------------------------------------------------------------------
    |
    | The path where index files will be stored (legacy, kept for compatibility).
    |
    */
    'storage_path' => storage_path('indexer'),

    /*
    |--------------------------------------------------------------------------
    | Token Types
    |--------------------------------------------------------------------------
    |
    | Configuration des tokens générés lors de l'indexation.
    |
    | min_size  : Taille minimale des n-grammes (défaut: 3)
    | max_size  : Taille maximale des n-grammes (défaut: 5)
    | metaphone : Activer/désactiver les métaphones (défaut: true)
    |
    | Note: Plus la plage est large, plus la recherche est précise,
    |       mais plus l'indexation est lente et l'espace de stockage important.
    */
    'token_types' => [
        'ngrams' => [
            'min_size' => 3,
            'max_size' => 5,
        ],
        'metaphone' => true,
    ],

    /*
    |--------------------------------------------------------------------------
    | Default Limit
    |--------------------------------------------------------------------------
    |
    | Limite par défaut pour les résultats de recherche.
    |
    */
    'default_limit' => 100,

    /*
    |--------------------------------------------------------------------------
    | Enable Cache
    |--------------------------------------------------------------------------
    |
    | Mettre en cache les résultats de recherche (défaut: true).
    |
    */
    'enable_cache' => true,

    /*
    |--------------------------------------------------------------------------
    | Cache TTL
    |--------------------------------------------------------------------------
    |
    | Durée de vie du cache en secondes (défaut: 3600).
    |
    */
    'cache_ttl' => 3600,
];

Structure des tables

Table indexed_documents

Stocke les documents indexés avec leurs métadonnées.

CREATE TABLE indexed_documents (
    id CHAR(36) PRIMARY KEY,                    -- UUID du document
    fingerprint VARCHAR(255) UNIQUE NOT NULL,    -- "App.Models.User|123"
    cluster VARCHAR(255) NOT NULL,              -- "model:User|tenant:company_abc"
    data JSON NOT NULL,                          -- Données indexées
    created_at TIMESTAMP NULL,
    updated_at TIMESTAMP NULL,
    
    INDEX idx_fingerprint (fingerprint),
    INDEX idx_cluster (cluster)
);

Table indexed_tokens

Stocke tous les tokens générés pour chaque document.

CREATE TABLE indexed_tokens (
    id CHAR(36) PRIMARY KEY,                    -- UUID du token
    document_id CHAR(36) NOT NULL,               -- Référence au document
    token_type VARCHAR(20) NOT NULL,            -- 'lexical' ou 'metaphone'
    token VARCHAR(255) NOT NULL,                -- La valeur du token
    field VARCHAR(255) NOT NULL,                -- Le champ source
    original_text VARCHAR(255) NOT NULL,        -- Texte original (casse préservée)
    frequency BIGINT UNSIGNED DEFAULT 1,        -- Fréquence d'apparition
    created_at TIMESTAMP NULL,
    updated_at TIMESTAMP NULL,
    
    FOREIGN KEY (document_id) REFERENCES indexed_documents(id) ON DELETE CASCADE,
    INDEX idx_token_field (token, field),
    INDEX idx_token_type_token (token_type, token),
    INDEX idx_token (token),
    INDEX idx_field (field)
);

Concepts fondamentaux

1. IndexableRecord

Le IndexableRecord est un DTO (Data Transfer Object) qui représente un document à indexer.

use AndyDefer\LaravelIndexer\Records\IndexableRecord;
use AndyDefer\LaravelIndexer\ValueObjects\IndexableFingerPrintVO;
use AndyDefer\LaravelIndexer\ValueObjects\ClusterVO;
use AndyDefer\DomainStructures\Utils\StrictAssociative;

$record = new IndexableRecord(
    finger_print: new IndexableFingerPrintVO('App.Models.User|123'),
    cluster: new ClusterVO('model:User|tenant:company_abc|env:production'),
    data: StrictAssociative::from([
        'name' => 'John Doe',
        'email' => 'john@example.com',
        'description' => 'Software Developer'
    ])
);

2. Indexable (Interface)

Les entités que vous souhaitez indexer doivent implémenter l'interface Indexable :

use AndyDefer\LaravelIndexer\Contracts\Indexable;
use AndyDefer\DomainStructures\Utils\StrictAssociative;

class User extends Model implements Indexable
{
    /**
     * Détermine si l'entité doit être indexée.
     */
    public function shouldBeIndexed(): bool
    {
        return $this->is_active;
    }

    /**
     * Retourne les données à indexer.
     */
    public function getIndexableData(): StrictAssociative
    {
        return StrictAssociative::from([
            'name' => $this->name,
            'email' => $this->email,
            'description' => $this->description,
        ]);
    }

    /**
     * Retourne l'ID de l'entité.
     */
    public function getKey(): int
    {
        return $this->id;
    }

    /**
     * Retourne le type de l'entité.
     */
    public function getMorphClass(): string
    {
        return self::class;
    }
}

3. Cluster

Le cluster est un système de tags structurés permettant de filtrer les résultats par catégorie.

use AndyDefer\LaravelIndexer\ValueObjects\ClusterVO;

// Format: "clé:valeur|clé:valeur"
$cluster = new ClusterVO('model:User|tenant:company_abc|env:production');

// Récupération des valeurs (toujours un tableau)
$cluster->get('model');    // ['User']
$cluster->get('tenant');   // ['company_abc']
$cluster->get('env');      // ['production']

// Vérifications
$cluster->has('model');    // true
$cluster->has('unknown');  // false

// Support des valeurs multiples
$cluster = new ClusterVO('category:electronics,music,books');
$cluster->get('category'); // ['electronics', 'music', 'books']

4. Fingerprint

Le fingerprint est un identifiant unique combinant le type d'entité et son ID.

use AndyDefer\LaravelIndexer\ValueObjects\IndexableFingerPrintVO;

$fingerprint = new IndexableFingerPrintVO('App.Models.User|123');

$fingerprint->getId();        // '123'
$fingerprint->getNamespace(); // 'App.Models.User'
$fingerprint->getValue();     // 'App.Models.User|123'

5. GramType

Enum définissant les types de tokens.

use AndyDefer\LaravelIndexer\Enums\GramType;

GramType::LEXICAL;    // N-grammes lexicaux
GramType::METAPHONE;  // Métaphones phonétiques

Guide de démarrage

Étape 1 : Implémenter l'interface Indexable

<?php

namespace App\Models;

use AndyDefer\LaravelIndexer\Contracts\Indexable;
use AndyDefer\DomainStructures\Utils\StrictAssociative;
use Illuminate\Database\Eloquent\Model;

class Product extends Model implements Indexable
{
    protected $fillable = ['id', 'name', 'reference', 'description', 'is_active'];

    public function shouldBeIndexed(): bool
    {
        return $this->is_active;
    }

    public function getIndexableData(): StrictAssociative
    {
        return StrictAssociative::from([
            'name' => $this->name,
            'reference' => $this->reference,
            'description' => $this->description,
        ]);
    }

    public function getKey(): int
    {
        return $this->id;
    }

    public function getMorphClass(): string
    {
        return self::class;
    }
}

Étape 2 : Créer le cluster

use AndyDefer\LaravelIndexer\ValueObjects\ClusterVO;

$cluster = new ClusterVO('model:Product|tenant:my_tenant|env:production');

Étape 3 : Indexer une entité

use AndyDefer\LaravelIndexer\Services\IndexerService;
use AndyDefer\LaravelIndexer\Services\Composants\IndexableRecordFactory;

$product = Product::find(1);
$cluster = new ClusterVO('model:Product|tenant:company_abc|env:production');

$record = IndexableRecordFactory::convert($product, $cluster);

$indexer = app(IndexerService::class);
$indexer->index($record);

Étape 4 : Rechercher

use AndyDefer\LaravelIndexer\Records\SearchQueryRecord;
use AndyDefer\LaravelIndexer\ValueObjects\SearchQueryVO;

$query = new SearchQueryRecord(
    query: new SearchQueryVO('laptop=name,description'),
    limit: 20
);

$results = $indexer->search($query);

foreach ($results as $result) {
    echo $result->item->data['name'] . "\n";
    echo "Matché dans: " . $result->field . "\n";
    echo "Token: " . $result->gram_value . "\n";
    echo "Type: " . $result->gram_type->value . "\n";
}

Indexation des entités

Indexation simple

$record = IndexableRecordFactory::convert($entity, $cluster);
$indexer->index($record);

Indexation en masse

use AndyDefer\LaravelIndexer\Collections\IndexableRecordCollection;

$records = new IndexableRecordCollection();

foreach ($products as $product) {
    $records->add(IndexableRecordFactory::convert($product, $cluster));
}

$indexer->indexMany($records);

Indexation avec données imbriquées

$record = new IndexableRecord(
    finger_print: new IndexableFingerPrintVO('App.Models.User|123'),
    cluster: new ClusterVO('model:User'),
    data: StrictAssociative::from([
        'name' => 'John Doe',
        'profile' => [
            'bio' => 'Software Developer',
            'social' => [
                'twitter' => '@johndoe',
                'github' => 'johndoe'
            ]
        ],
        'tags' => ['php', 'laravel', 'mysql']
    ])
);

$indexer->index($record);
// Les tokens seront générés pour :
// - name
// - profile.bio
// - profile.social.twitter
// - profile.social.github
// - tags (concaténé en 'php; laravel; mysql')

Rafraîchissement (update)

// Met à jour un document existant (delete + index)
$indexer->refresh($record);

// Met à jour plusieurs documents
$indexer->refreshMany($records);

Exemple complet d'indexation en masse

<?php

use AndyDefer\LaravelIndexer\Services\IndexerService;
use AndyDefer\LaravelIndexer\Services\Composants\IndexableRecordFactory;
use AndyDefer\LaravelIndexer\Collections\IndexableRecordCollection;
use AndyDefer\LaravelIndexer\ValueObjects\ClusterVO;

class ProductIndexer
{
    private IndexerService $indexer;
    private ClusterVO $cluster;

    public function __construct()
    {
        $this->indexer = app(IndexerService::class);
        $this->cluster = new ClusterVO('model:Product|tenant:my_tenant|env:production');
    }

    public function indexAll(): void
    {
        $products = Product::where('is_active', true)->get();
        $records = new IndexableRecordCollection();

        foreach ($products as $product) {
            $records->add(IndexableRecordFactory::convert($product, $this->cluster));
        }

        $this->indexer->indexMany($records);
        echo "Indexé " . $records->count() . " produits\n";
    }

    public function reindex(Product $product): void
    {
        $record = IndexableRecordFactory::convert($product, $this->cluster);
        $this->indexer->refresh($record);
    }

    public function delete(Product $product): void
    {
        $fingerPrint = new IndexableFingerPrintVO('App.Models.Product|' . $product->id);
        $this->indexer->delete($fingerPrint);
    }
}

Recherche

Recherche simple

$query = new SearchQueryRecord(
    query: new SearchQueryVO('john=name')
);
$results = $indexer->search($query);

Recherche multi-champs (OR)

// "john" dans "name" OU "description" OU "email"
$query = new SearchQueryRecord(
    query: new SearchQueryVO('john=name,description,email')
);

Recherche multi-n-grams (AND)

// "john" dans "name" ET "developer" dans "description"
$query = new SearchQueryRecord(
    query: new SearchQueryVO('john=name|developer=description')
);

Recherche avec cluster

$query = new SearchQueryRecord(
    query: new SearchQueryVO('john=name'),
    cluster: new ClusterVO('tenant:company_abc|env:production')
);

Recherche avec fingerprint

$query = new SearchQueryRecord(
    query: new SearchQueryVO('john=name'),
    finger_print: new IndexableFingerPrintVO('App.Models.User|123')
);

Recherche avec limite personnalisée

$query = new SearchQueryRecord(
    query: new SearchQueryVO('john=name'),
    limit: 50,
    min_size: 3,
    max_size: 5
);

Recherche phonétique (métaphone)

// "jon" est une faute d'orthographe de "john"
// Le métaphone de "jon" et "john" est identique ("JN")
$query = new SearchQueryRecord(
    query: new SearchQueryVO('jon=name')
);

$results = $indexer->search($query);
// Retourne les documents contenant "john" car "jon" est phonétiquement identique

Exemple complet de recherche

<?php

use AndyDefer\LaravelIndexer\Services\IndexerService;
use AndyDefer\LaravelIndexer\Records\SearchQueryRecord;
use AndyDefer\LaravelIndexer\ValueObjects\SearchQueryVO;
use AndyDefer\LaravelIndexer\ValueObjects\ClusterVO;

class ProductSearch
{
    private IndexerService $indexer;

    public function __construct()
    {
        $this->indexer = app(IndexerService::class);
    }

    public function searchProducts(string $query, string $tenant = null): array
    {
        $searchQuery = new SearchQueryVO($query . '=name,reference,description');

        $record = new SearchQueryRecord(
            query: $searchQuery,
            cluster: $tenant ? new ClusterVO('tenant:' . $tenant) : null,
            limit: 50
        );

        $results = $this->indexer->search($record);

        $products = [];
        foreach ($results as $result) {
            $products[] = [
                'id' => $result->item->finger_print->getId(),
                'name' => $result->item->data['name'],
                'reference' => $result->item->data['reference'],
                'matched_field' => $result->field,
                'matched_term' => $result->gram_value,
                'match_type' => $result->gram_type->value,
            ];
        }

        return $products;
    }

    public function autocomplete(string $prefix): array
    {
        $query = new SearchQueryRecord(
            query: new SearchQueryVO($prefix . '=name'),
            limit: 10,
            min_size: 2,
            max_size: 3
        );

        $results = $this->indexer->search($query);

        return $results->map(fn($r) => $r->item->data['name'])->toArray();
    }
}

Suppression et nettoyage

Suppression d'un document

$fingerPrint = new IndexableFingerPrintVO('App.Models.User|123');
$indexer->delete($fingerPrint);

Suppression en masse

use AndyDefer\LaravelIndexer\Collections\IndexableFingerPrintVOCollection;

$collection = new IndexableFingerPrintVOCollection();
$collection->add(new IndexableFingerPrintVO('App.Models.User|123'));
$collection->add(new IndexableFingerPrintVO('App.Models.User|456'));
$collection->add(new IndexableFingerPrintVO('App.Models.Product|789'));

$indexer->deleteMany($collection);

Suppression par namespace

use AndyDefer\LaravelIndexer\Repositories\IndexedDocumentRepository;

$documentRepo = new IndexedDocumentRepository();
$deleted = $documentRepo->deleteByNamespace('App.Models.User');
echo "Supprimé $deleted documents\n";

Suppression par cluster

use AndyDefer\LaravelIndexer\ValueObjects\ClusterVO;

$cluster = new ClusterVO('tenant:company_xyz');
$documentRepo = new IndexedDocumentRepository();
$deleted = $documentRepo->deleteByCluster($cluster);
echo "Supprimé $deleted documents\n";

Nettoyage complet

$indexer->clear(); // Supprime TOUS les documents et tokens

Bonnes pratiques

1. Indexation en masse

// ❌ À éviter : indexation un par un
foreach ($products as $product) {
    $indexer->index(IndexableRecordFactory::convert($product, $cluster));
}

// ✅ Recommandé : indexation en masse
$records = new IndexableRecordCollection();
foreach ($products as $product) {
    $records->add(IndexableRecordFactory::convert($product, $cluster));
}
$indexer->indexMany($records);

2. Utilisation des clusters

// ❌ À éviter : clusters trop génériques
$cluster = new ClusterVO('type:product');

// ✅ Recommandé : clusters précis et structurés
$cluster = new ClusterVO('model:Product|tenant:company_abc|env:production|category:electronics');

3. Filtrage par champ

// ❌ À éviter : rechercher dans tous les champs (lent)
$query = new SearchQueryVO('john=');

// ✅ Recommandé : spécifier les champs pertinents
$query = new SearchQueryVO('john=name,description');

4. Taille des n-grammes

// ❌ À éviter : min_size trop petit (2) → trop de tokens
'min_size' => 2,

// ❌ À éviter : max_size trop grand (10) → trop de tokens
'max_size' => 10,

// ✅ Recommandé : plage équilibrée
'min_size' => 3,
'max_size' => 5,

5. Nettoyage régulier

// ✅ Recommandé : nettoyer les documents inactifs
$inactiveProducts = Product::where('is_active', false)->get();
$fingerPrints = new IndexableFingerPrintVOCollection();
foreach ($inactiveProducts as $product) {
    $fingerPrints->add(new IndexableFingerPrintVO('App.Models.Product|' . $product->id));
}
$indexer->deleteMany($fingerPrints);

6. Utilisation des transactions

// ✅ Recommandé : regrouper les opérations dans une transaction
DB::transaction(function () use ($products, $cluster, $indexer) {
    $records = new IndexableRecordCollection();
    foreach ($products as $product) {
        $records->add(IndexableRecordFactory::convert($product, $cluster));
    }
    $indexer->indexMany($records);
});

Dépannage

Erreur : Prepared statement contains too many placeholders

Cause : Trop de tokens dans une seule requête INSERT (limite MySQL = 65535).

Solution : Réduire insertChunkSize dans IndexWriter :

private int $insertChunkSize = 500; // Au lieu de 1000

Erreur : Array to string conversion

Cause : Les données contiennent des tableaux non encodés en JSON.

Solution : Utiliser StrictAssociative pour les données :

$data = StrictAssociative::from([
    'name' => 'John Doe',
    'tags' => ['php', 'laravel'], // → sera encodé en JSON
]);

Erreur : Cluster cannot be empty

Cause : Le cluster est vide ou mal formé.

Solution : Vérifier le format du cluster :

// ❌ Mauvais
$cluster = new ClusterVO(''); // Exception

// ✅ Bon
$cluster = new ClusterVO('model:User|tenant:company_abc');

Erreur : Invalid cluster format

Cause : Format incorrect (utilise - au lieu de :).

Solution : Utiliser le format clé:valeur :

// ❌ Mauvais
$cluster = new ClusterVO('model-User');

// ✅ Bon
$cluster = new ClusterVO('model:User');

Recherche lente

Cause : Manque d'index SQL ou requêtes non optimisées.

Solution :

  1. Vérifier les index dans la table indexed_tokens
  2. Réduire min_size et max_size pour moins de tokens
  3. Utiliser des clusters pour filtrer avant la recherche

Tests

Exécuter les tests unitaires

./vendor/bin/phpunit

Exécuter les tests d'intégration

./vendor/bin/phpunit --testsuite Integration

Exécuter les benchmarks

./vendor/bin/phpunit --testsuite Benchmark

Exécuter un test spécifique

./vendor/bin/phpunit --filter test_index_creates_document_and_tokens

License

MIT © Andy Kani

统计信息

  • 总下载量: 1
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 0
  • 点击次数: 1
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: Unknown
  • 更新时间: 2026-07-05

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固