laravel-hermes maintained by andydefer
Description
Hermes - Intelligent text similarity, completion, suggestion and search service for Laravel, powered by n-grams and metaphone algorithms.
Author
Last update
2026/07/26 00:55
(dev-main)
License
Downloads
60
Tags
search - laravel - levenshtein - similarity - suggestion - completion - metaphone - hermes - n-gram - fuzzy-search - text-analysis
Laravel Hermes
Table des matières
- Installation
- Concepts fondamentaux
- Completion
- Suggestion
- Search
- Requêtes multiples (AND)
- Les clusters
- Les contextes
- Contextes multiples
- Repositories
- Collections
- Exemple complet : API de recherche
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