andydefer/laravel-hermes

Hermes - Intelligent text similarity, completion, suggestion and search service for Laravel, powered by n-grams and metaphone algorithms.

Maintainers

Package info

github.com/andydefer/laravel-hermes

pkg:composer/andydefer/laravel-hermes

Transparency log

Statistics

Installs: 60

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.14 2026-07-25 13:01 UTC

README

Table des matières

Installation

composer require andydefer/laravel-hermes

Migrations

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

Configuration

php artisan vendor:publish --tag=hermes-config
// config/hermes.php
return [
    'similarity' => [
        'gram_min_size' => 2,
        'gram_max_size' => 4,
        'vector_dimension' => 128,
        'textual_weight' => 0.6,
        'phonetic_weight' => 0.4,
        'letter_bonus' => 0.05,
        'bigram_bonus' => 0.03,
        'min_word_length' => 2,
        'max_words' => 50,
        'max_pairs' => 2500,
        'timeout_seconds' => 0.5,
        'levenshtein' => [
            'metaphone_threshold' => 3,
            'metaphone_bonus' => 0.175,
            'lexical_threshold' => 3,
            'lexical_bonus_medium' => 0.225,
            'lexical_bonus_high' => 0.275,
            'max_bonus' => 0.45,
        ],
    ],
];

Concepts fondamentaux

Laravel Hermes est un package de recherche intelligente qui s'appuie sur Laravel Indexer pour offrir trois services :

Service Description
COMPLETION Complète un bout de mot avec des mots existants
SUGGESTION Corrige les fautes de frappe
SEARCH Recherche textuelle avec résultats détaillés

Architecture

Laravel Hermes
    ├── HermesService (orchestrateur)
    │   ├── complete()
    │   ├── suggest()
    │   └── search()
    ├── HermesRepository (accès aux tokens)
    ├── SimilarityCalculatorService (calcul de similarité)
    └── Records & Collections (DTOs typés)

Prérequis

Ce package nécessite Laravel Indexer pour l'indexation des données.

// Votre modèle doit implémenter Indexable
use AndyDefer\LaravelIndexer\Contracts\Indexable;

class User extends Model implements Indexable
{
    public function shouldBeIndexed(): bool
    {
        return $this->is_active;
    }

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

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

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

Completion

Objectif : L'utilisateur tape un bout de mot, on propose des mots complets triés par similarité.

Exemple

Base contient : "john", "johanna", "johnson", "johny", "joshua"
User tape : "joh"
→ Résultat trié par similarité :
   1. "joh" → "john" (similarité 1.0)
   2. "joh" → "johanna" (similarité 0.83)
   3. "joh" → "johnson" (similarité 0.80)

Utilisation

use AndyDefer\LaravelHermes\Contracts\Services\HermesInterface;
use AndyDefer\LaravelHermes\Records\CompletionRequestRecord;
use AndyDefer\LaravelIndexer\ValueObjects\SearchQueryVO;

class UserController
{
    public function __construct(
        private HermesInterface $hermes
    ) {}

    public function autocomplete(Request $request)
    {
        $request = CompletionRequestRecord::from([
            'query' => new SearchQueryVO($request->get('q') . '=name,email'),
            'limit' => 10,
        ]);

        $results = $this->hermes->complete($request);

        return response()->json([
            'suggestions' => $results->getOriginalTexts()
        ]);
    }
}

Avec filtres de contexte

use AndyDefer\LaravelHermes\Collections\ContextFilterVOCollection;
use AndyDefer\LaravelHermes\ValueObjects\ContextFilterVO;

$contexts = new ContextFilterVOCollection();
$contexts->add(new ContextFilterVO('App.Models.User'));

$request = CompletionRequestRecord::from([
    'query' => 'joh=name',
    'limit' => 10,
    'contexts' => $contexts,
]);

$results = $this->hermes->complete($request);

Suggestion

Objectif : L'utilisateur a fait une faute de frappe, on propose les mots les plus proches.

Exemple

Base contient : "developer", "development", "deploy", "devops"
User tape : "devloper" (faute)
→ Résultat trié par similarité :
   1. "devloper" → "developer" (similarité 0.92)
   2. "devloper" → "development" (similarité 0.78)
   3. "devloper" → "deploy" (similarité 0.65)

Utilisation

use AndyDefer\LaravelHermes\Records\SuggestionRequestRecord;

$request = SuggestionRequestRecord::from([
    'query' => 'devloper=skills,bio',
    'limit' => 5,
    'min_similarity' => 0.3,
]);

$results = $this->hermes->suggest($request);

foreach ($results as $result) {
    echo $result->original_text . ' (' . round($result->similarity, 2) . ")\n";
}
// developer (0.92)
// development (0.78)
// deploy (0.65)

Avec seuil de similarité

// Seulement les suggestions très proches (> 70%)
$request = SuggestionRequestRecord::from([
    'query' => 'devloper=skills',
    'min_similarity' => 0.7,
    'limit' => 5,
]);

$results = $this->hermes->suggest($request);
// Résultat : seulement "developer" (0.92)

Search

Objectif : L'utilisateur cherche, on retourne les documents complets avec le détail des matchs.

Utilisation

use AndyDefer\LaravelHermes\Records\SearchRequestRecord;

$request = SearchRequestRecord::from([
    'query' => 'john=name,email|developer=description',
    'limit' => 20,
    'min_similarity' => 0.3,
]);

$results = $this->hermes->search($request);

foreach ($results as $result) {
    echo "Document: " . $result->fingerprint . "\n";
    echo "Score global: " . round($result->similarity, 2) . "\n";
    
    foreach ($result->matches as $match) {
        echo "  - " . $match->field . ": " . $match->original_text . "\n";
        echo "    Score: " . round($match->similarity, 2) . "\n";
    }
}

Structure du résultat

// SearchResultRecord
[
    'document_id' => 'uuid',
    'fingerprint' => 'App.Models.User|123',
    'data' => StrictAssociative::from([
        'name' => 'John Doe',
        'email' => 'john@example.com',
        'description' => 'Software Developer'
    ]),
    'matches' => [
        ['field' => 'name', 'original_text' => 'John', 'similarity' => 1.0],
        ['field' => 'email', 'original_text' => 'john@example.com', 'similarity' => 0.85]
    ],
    'similarity' => 0.95
]

Requêtes multiples (AND)

Format

La requête supporte plusieurs termes séparés par | (pipe) :

terme1=champ1,champ2|terme2=champ3|terme3=champ1,champ4

Logique

  • Plusieurs termes = ET (intersection)
  • Plusieurs champs = OU (union)

Exemples de requêtes multiples

Deux termes (AND)

// "john" dans name ET "developer" dans description

$request = SearchRequestRecord::from([
    'query' => 'john=name|developer=description',
    'limit' => 20,
]);

$results = $this->hermes->search($request);
// Résultat : documents qui contiennent "john" DANS name ET "developer" DANS description

Terme avec plusieurs champs (OR)

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

$results = $this->hermes->search($request);
// Résultat : documents qui contiennent "john" DANS name OU email

Deux termes avec plusieurs champs

// "john" dans name OU email ET "developer" dans description OU bio
$query = new SearchQueryVO('john=name,email|developer=description,bio');

$results = $this->hermes->search($request);
// Résultat : (john dans name/email) ET (developer dans description/bio)

Trois termes

// "john" dans name ET "developer" dans skills ET "laravel" dans framework
$query = new SearchQueryVO('john=name|developer=skills|laravel=framework');

$results = $this->hermes->search($request);
// Résultat : documents qui remplissent les TROIS conditions

Completion avec requêtes multiples

// Completion pour "john" dans name ET "jane" dans email
$request = CompletionRequestRecord::from([
    'query' => new SearchQueryVO('john=name|jane=email'),
    'limit' => 10,
]);

$results = $this->hermes->complete($request);
// Résultat : mots qui correspondent aux DEUX conditions

Suggestion avec requêtes multiples

// Suggestion pour "devloper" dans skills ET "musik" dans categories
$request = SuggestionRequestRecord::from([
    'query' => new SearchQueryVO('devloper=skills|musik=categories'),
    'limit' => 5,
    'min_similarity' => 0.3,
]);

$results = $this->hermes->suggest($request);
// Résultat : suggestions qui correspondent aux DEUX conditions

Les clusters

Le cluster est un filtre contextuel pour les recherches multi-tenant.

Créer un cluster

use AndyDefer\LaravelIndexer\ValueObjects\ClusterVO;

// Simple
$cluster = new ClusterVO('tenant:company_abc');

// Multiple
$cluster = new ClusterVO('tenant:company_abc|env:production|region:europe');

// Valeurs multiples
$cluster = new ClusterVO('tenant:company_abc,company_xyz|category:electronics,music');

Lire un cluster

$cluster = new ClusterVO('tenant:company_abc,company_xyz|env:production');

$cluster->get('tenant');     // ['company_abc', 'company_xyz']
$cluster->get('env');        // ['production']
$cluster->has('tenant');     // true
$cluster->has('unknown');    // false
$cluster->contains('tenant', 'company_abc');  // true
$cluster->all();
// ['tenant' => ['company_abc', 'company_xyz'], 'env' => ['production']]

Manipuler un cluster

$cluster = new ClusterVO('tenant:company_abc');

// Ajouter
$new = $cluster->with('env', 'production');
$new = $cluster->withMany('category', ['electronics', 'music']);

// Supprimer
$new = $cluster->without('tenant', 'company_abc');
$new = $cluster->without('env');

// Chaînage
$new = $cluster
    ->with('env', 'production')
    ->with('region', 'europe');

Utiliser un cluster dans une recherche

$request = SearchRequestRecord::from([
    'query' => new SearchQueryVO('john=name'),
    'cluster' => new ClusterVO('tenant:company_abc'),
]);

$results = $this->hermes->search($request);
// Résultat : uniquement les documents du tenant company_abc

Les contextes

Le contexte est un filtre combiné (namespace + cluster) pour les recherches.

Créer un contexte

use AndyDefer\LaravelHermes\ValueObjects\ContextFilterVO;

// Uniquement namespace
$context = new ContextFilterVO('App.Models.User');

// Uniquement cluster (string)
$context = new ContextFilterVO(null, 'tenant:company_abc');

// Uniquement cluster (ClusterVO)
$cluster = new ClusterVO('tenant:company_abc|env:production');
$context = new ContextFilterVO(null, $cluster);

// Les deux (ET)
$context = new ContextFilterVO('App.Models.User', 'tenant:company_abc');

Utiliser un contexte

$contexts = new ContextFilterVOCollection();
$contexts->add(new ContextFilterVO('App.Models.User', 'tenant:company_abc'));

$request = SearchRequestRecord::from([
    'query' => new SearchQueryVO('john=name'),
    'contexts' => $contexts,
]);

// Résultat : Users ET tenant company_abc

Contextes multiples

Logique OR entre les contextes

// Recherche dans Users OU dans le tenant company_abc
$contexts = new ContextFilterVOCollection();
$contexts->add(new ContextFilterVO('App.Models.User'));
$contexts->add(new ContextFilterVO(null, 'tenant:company_abc'));

$request = SearchRequestRecord::from([
    'query' => new SearchQueryVO('john=name'),
    'contexts' => $contexts,
]);

// Résultat : (Users) OU (documents du tenant company_abc)

Logique AND à l'intérieur d'un contexte

// Recherche dans Users ET tenant company_abc
$contexts = new ContextFilterVOCollection();
$contexts->add(new ContextFilterVO('App.Models.User', 'tenant:company_abc'));

$request = SearchRequestRecord::from([
    'query' => new SearchQueryVO('john=name'),
    'contexts' => $contexts,
]);

// Résultat : Users ET tenant company_abc

Combinaison complexe de contextes

// (User ET company_abc) OU (Product ET company_xyz)
$contexts = new ContextFilterVOCollection();
$contexts->add(new ContextFilterVO('App.Models.User', 'tenant:company_abc'));
$contexts->add(new ContextFilterVO('App.Models.Product', 'tenant:company_xyz'));

$request = SearchRequestRecord::from([
    'query' => new SearchQueryVO('john=name'),
    'contexts' => $contexts,
]);

// Résultat : Users de company_abc OU Products de company_xyz

Contextes multiples avec requêtes multiples

// (User ET company_abc) OU (Product ET company_xyz)
// ET "john" dans name OU "developer" dans description
$contexts = new ContextFilterVOCollection();
$contexts->add(new ContextFilterVO('App.Models.User', 'tenant:company_abc'));
$contexts->add(new ContextFilterVO('App.Models.Product', 'tenant:company_xyz'));

$request = SearchRequestRecord::from([
    'query' => new SearchQueryVO('john=name,email|developer=description'),
    'contexts' => $contexts,
    'limit' => 50,
]);

$results = $this->hermes->search($request);
// Résultat : (User ET company_abc) OU (Product ET company_xyz)
// ET (john dans name/email) ET (developer dans description)

Repositories

HermesRepository

use AndyDefer\LaravelHermes\Repositories\HermesRepository;
use AndyDefer\LaravelHermes\Collections\ContextFilterVOCollection;
use AndyDefer\LaravelHermes\ValueObjects\ContextFilterVO;

$repository = app(HermesRepository::class);

// Trouver des tokens par n-grammes
$ngrams = ['joh', 'ohn', 'john'];
$tokens = $repository->findTokensByNgrams($ngrams, limit: 10);

// Avec filtres
$contexts = new ContextFilterVOCollection();
$contexts->add(new ContextFilterVO('App.Models.User'));

$tokens = $repository->findTokensByNgrams(
    $ngrams,
    contexts: $contexts,
    limit: 10
);

// Groupés par document
$grouped = $repository->getTokensGroupedByDocument($ngrams);

// Compter les tokens
$count = $repository->countTokensByNgrams($ngrams);

Collections

CompletionResultRecordCollection

$results = $this->hermes->complete($request);

// Extraction
$tokens = $results->getTokens();
$originalTexts = $results->getOriginalTexts();
$ids = $results->getIds();
$documentIds = $results->getDocumentIds();

// Filtrage
$byField = $results->filterByField('name');
$bySimilarity = $results->filterByMinSimilarity(0.5);

SuggestionResultRecordCollection

$results = $this->hermes->suggest($request);

// Extraction
$tokens = $results->getTokens();
$originalTexts = $results->getOriginalTexts();
$ids = $results->getIds();
$documentIds = $results->getDocumentIds();

// Filtrage
$byField = $results->filterByField('name');
$bySimilarity = $results->filterByMinSimilarity(0.5);

SearchResultRecordCollection

$results = $this->hermes->search($request);

// Extraction
$documentIds = $results->getDocumentIds();
$fingerprints = $results->getFingerprints();
$data = $results->getData();
$matches = $results->getMatches();

// Filtrage
$bySimilarity = $results->filterByMinSimilarity(0.5);
$byField = $results->filterByField('name');

ContextFilterVOCollection

$contexts = new ContextFilterVOCollection();

// Ajout
$contexts->add(new ContextFilterVO('App.Models.User'));

// Extraction
$namespaces = $contexts->getNamespaces();
$clusters = $contexts->getClusters();

// Filtrage
$byNamespace = $contexts->filterByNamespace('App.Models.User');
$byCluster = $contexts->filterByCluster('tenant:company_abc');

Exemple complet : API de recherche

Contrôleur

<?php

namespace App\Http\Controllers;

use AndyDefer\LaravelHermes\Contracts\Services\HermesInterface;
use AndyDefer\LaravelHermes\Records\CompletionRequestRecord;
use AndyDefer\LaravelHermes\Records\SearchRequestRecord;
use AndyDefer\LaravelHermes\Records\SuggestionRequestRecord;
use AndyDefer\LaravelHermes\Collections\ContextFilterVOCollection;
use AndyDefer\LaravelHermes\ValueObjects\ContextFilterVO;
use AndyDefer\LaravelIndexer\ValueObjects\SearchQueryVO;
use Illuminate\Http\Request;

class SearchController extends Controller
{
    public function __construct(
        private HermesInterface $hermes
    ) {}

    public function autocomplete(Request $request)
    {
        $request = CompletionRequestRecord::from([
            'query' => new SearchQueryVO($request->get('q') . '=name,email,bio'),
            'limit' => 10,
            'contexts' => $this->getContexts($request),
        ]);

        $results = $this->hermes->complete($request);

        return response()->json([
            'suggestions' => $results->getOriginalTexts()
        ]);
    }

    public function search(Request $request)
    {
        // Construction de la query
        $queryParts = [];
        
        if ($request->get('name')) {
            $queryParts[] = $request->get('name') . '=name';
        }
        
        if ($request->get('email')) {
            $queryParts[] = $request->get('email') . '=email';
        }
        
        if ($request->get('bio')) {
            $queryParts[] = $request->get('bio') . '=bio';
        }
        
        $queryString = implode('|', $queryParts);

        $request = SearchRequestRecord::from([
            'query' => new SearchQueryVO($queryString),
            'limit' => $request->get('limit', 20),
            'min_similarity' => $request->get('min_similarity', 0.3),
            'contexts' => $this->getContexts($request),
        ]);

        $results = $this->hermes->search($request);

        return response()->json([
            'results' => $results->map(function ($result) {
                return [
                    'id' => $result->document_id,
                    'data' => $result->data->toArray(),
                    'score' => $result->similarity,
                    'matches' => $result->matches->map(function ($match) {
                        return [
                            'field' => $match->field,
                            'value' => $match->original_text,
                            'similarity' => $match->similarity,
                        ];
                    })->toArray(),
                ];
            })->toArray(),
            'total' => $results->count(),
        ]);
    }

    public function suggest(Request $request)
    {
        $request = SuggestionRequestRecord::from([
            'query' => new SearchQueryVO($request->get('q') . '=name,email,bio'),
            'limit' => 5,
            'min_similarity' => $request->get('min_similarity', 0.3),
            'contexts' => $this->getContexts($request),
        ]);

        $results = $this->hermes->suggest($request);

        return response()->json([
            'suggestions' => $results->map(function ($result) {
                return [
                    'text' => $result->original_text,
                    'field' => $result->field,
                    'similarity' => $result->similarity,
                ];
            })->toArray(),
        ]);
    }

    private function getContexts(Request $request): ContextFilterVOCollection
    {
        $contexts = new ContextFilterVOCollection();

        if ($request->user()) {
            // Filtre par tenant (cluster)
            if ($request->user()->tenant_id) {
                $contexts->add(new ContextFilterVO(
                    null,
                    'tenant:' . $request->user()->tenant_id
                ));
            }

            // Filtre par namespace
            if ($request->get('namespace')) {
                $contexts->add(new ContextFilterVO(
                    $request->get('namespace')
                ));
            }

            // Les deux
            if ($request->user()->tenant_id && $request->get('namespace')) {
                $contexts->add(new ContextFilterVO(
                    $request->get('namespace'),
                    'tenant:' . $request->user()->tenant_id
                ));
            }
        }

        return $contexts;
    }
}

Routes

// routes/api.php
use App\Http\Controllers\SearchController;

Route::prefix('search')->middleware('auth:sanctum')->group(function () {
    Route::get('/autocomplete', [SearchController::class, 'autocomplete']);
    Route::get('/search', [SearchController::class, 'search']);
    Route::get('/suggest', [SearchController::class, 'suggest']);
});

License

MIT © Andy Defer