ENbext.dev
DocumentationFonctionnalités

Traduction automatique depuis l’anglais. Consulter l’original.

Sur cette page

Rechercher un client

La fonctionnalité** du client de recherche **répond à une question à chaque appel : , quels documents de cet index nommé correspondent à cette requête ? Un SearchClientPlugin est un moteur de recherche : donnez-lui un nom d'index et une requête, il renvoie des résultats classés. Le trait ne dit rien sur la façon dont le classement a été calculé, où se trouve l'index inversé, ou même si est un index inversé : il peut provenir d'un moteur dédié comme Meilisearch, Elasticsearch ou Typesense, ou de to_tsvector exécuté sur une simple table Postgres. Tous se trouvent derrière la même forme, de sorte que les sites d'appels ne se branchent jamais sur le backend.

Cette page couvre le trait, la forme délibérément petite de SearchQuery, comment les charges utiles de chaîne JSON maintiennent l'ABI plate, comment le SearchError typé classe les modes de défaillance et comment les deux plugins de référence – @bext/search-meili et @bext/search-pg – correspondent au trait.

Le trait#

Chaque plugin Search Client implémente un trait, SearchClientPlugin, défini dans bext-plugin-api::search :

rust
pub trait SearchClientPlugin: Send + Sync {
    fn name(&self) -> &str;

    fn search(&self, index: &str, query: &SearchQuery) -> Result<SearchResults, SearchError>;

    fn index(&self, index: &str, docs: Vec<Document>) -> Result<(), SearchError>;

    fn delete(&self, index: &str, ids: Vec<String>) -> Result<(), SearchError>;

    fn is_healthy(&self) -> bool { true }
}

Trois méthodes : lire, écrire, supprimer. Chaque méthode prend un nom index explicite afin qu'un seul backend puisse héberger de nombreuses collections logiques ; cela correspond aux "index" de Meili, aux "index" d'Elastic, aux "collections" de Typesense et à la convention pg-FTS de "un index == une table". Le trait est synchronisé pour correspondre au reste de bext-plugin-api, de sorte que la forme se déplace proprement dans les bacs à sable WASM, QuickJS et nsjail. Les backends qui parlent asynchrone natif (le SDK Meilisearch, le client Elasticsearch Rust) utilisent soit leur frère bloquant, soit possèdent un petit runtime tokio et appellent block_on en interne - le même modèle que le récupérateur JWKS de @bext/auth-jwt et les appels du fournisseur de @bext/flags-openfeature utilisent. Le transport asynchrone réside à l’intérieur du plugin ; la forme face à l'hôte reste synchronisée.

delete est idempotent — la suppression d'un identifiant qui n'existe pas n'est pas une erreur, correspondant à Redis DEL, S3 DeleteObject et à toutes les autres suppressions idempotentes dans l'API du plugin. Cela permet à l'appelant d'exécuter "supprimer puis insérer" pour réamorcer un index sans se demander si chaque cible était déjà présente.

SearchQuery – délibérément minimal#

rust
pub struct SearchQuery {
    pub text: String,
    pub filters: Vec<(String, String)>,
    pub limit: u32,
    pub offset: u32,
}

Quatre champs : une chaîne de texte de forme libre, une liste plate de filtres d'égalité d'attributs, une limite et un décalage. Il s'agit de la plus petite forme qui couvre le cas à 80 % — autocomplete, search within category, keyword + facet — sans divulguer la requête DSL d'un fournisseur dans le trait. Chaque backend du plan s'y intègre parfaitement : Meili traduit filters dans son langage de filtre field = "value" AND ..., pg les traduit en WHERE data ->> 'field' = $N, Elastic en un bool.must de clauses term, Typesense dans sa chaîne filter_by.

Développer un DSL de requêtes partagées plus riche – arbres booléens, filtres géographiques, requêtes de plage, agrégations de facettes – est le piège que le document sur l'architecture de l'écosystème dénonce : les formes couplées au fournisseur finissent par ressembler au backend expédié en premier et ne s'adaptent jamais proprement au suivant. Au lieu de cela, le trait laisse deux portes de secours pour des besoins plus riches :

  • Un backend qui souhaite une requête JSON brute peut l'accepter dans text et documenter la forme — le trait n'analyse pas text.
  • Un backend peut exposer sa propre API plus riche derrière SearchClientPlugin au moment de la construction, puis se limiter au trait lorsqu'il est appelé à partir du code de répartition des capacités.

limit = 0 signifie « utiliser la valeur par défaut du backend », ce qui est la même convention que @bext/session-redis utilise pour les TTL et que le SDK Meilisearch utilise lorsqu'aucune limite n'est fournie. Les appelants qui souhaitent un plafond doivent définir une valeur explicite ; les appelants qui veulent "tous" doivent parcourir le décalage au lieu de demander au backend de renvoyer des résultats illimités.

Les charges utiles des documents et des hits sont des chaînes JSON#

rust
pub struct Document {
    pub id: String,
    pub fields_json: String,
}

pub struct SearchHit {
    pub id: String,
    pub score: f32,
    pub source_json: String,
}

Document::fields_json et SearchHit::source_json sont des chaînes JSON simples, et non des serde_json::Value. Cela correspond à la capacité de session transportant des données de session, à la capacité de cycle de vie transportant des charges utiles d'événements et à la capacité d'indicateur de fonctionnalité transportant des valeurs d'indicateur structurées. La raison est la même à chaque fois : l'ABI du bac à sable WASM / QuickJS / nsjail est plus plat lorsqu'il ne doit transporter que des octets, et le code orienté vers l'hôte paie un serde_json::from_str au bord au lieu de pousser une valeur entièrement saisie à travers la frontière.

id est l'identifiant externe stable - les backends l'utilisent pour dédupliquer lors de la réindexation (vous pouvez donc appeler index deux fois avec le même identifiant et obtenir un upsert, pas un doublon) et comme cible pour delete. score est un numéro de pertinence défini par le backend, comparable au sein d'un seul ensemble de résultats, mais et non entre backends ou entre requêtes. Les appelants classés parmi plusieurs fournisseurs doivent effectuer leur propre reclassement sur le JSON source.

Les erreurs sont classées en quatre catégories récupérables#

rust
pub enum SearchError {
    IndexNotFound(String),
    AccessDenied(String),
    BadQuery(String),
    Backend(String),
}

Une énumération de type plat, pas Result<_, String>, car la classification est importante au niveau de la couche de répartition. Les quatre variantes correspondent aux quatre choses qu'un appelant pourrait vouloir faire différemment :

  • IndexNotFound — l'index lui-même est manquant. Ce n'est pas la même chose que "index vide", qui est Ok(SearchResults { hits: vec![], .. }). Les sites qui amorcent les index lors de la première utilisation détectent cette variante et exécutent le flux de création d'index.
  • AccessDenied — l'authentification ou l'autorisation a échoué. Mauvaise clé API, mauvais rôle, mauvais réseau. Distinct de Backend afin que le répartiteur puisse faire remonter les problèmes d'informations d'identification sans appeler les flocons de transport.
  • BadQuery — la requête elle-même était mal formée. Erreur de syntaxe, forme de filtre non prise en charge, décalage hors plage. Erreur de l'appelant, pas une erreur du backend ; récupérable en corrigeant l’entrée.
  • Backend — tout le reste : panne de réseau, 5xx, panique du pilote, délai d'attente. Non classé davantage car l'appelant ne peut récupérer d'aucun d'entre eux, sauf en réessayant ou en alertant.

Chaque variante comporte une chaîne de message destinée à l'opérateur : faites correspondre la variante pour le flux de contrôle, inspectez la chaîne pour les journaux. Il s'agit du même modèle d'énumération plate utilisé par SessionError, MailerError, StorageError et WebhookError.

Plugins de référence#

@ bext /search-meili#

Vit à crates/bext-impls/bext-search-meili. Encapsule meilisearch-sdk derrière la synchronisation SearchClientPlugin en possédant un environnement d'exécution Tokio multi-thread à 1 travailleur et block_on en exécutant les appels asynchrones du SDK. Le traducteur de filtre de l'adaptateur transforme Vec<(field, value)> en chaîne DSL de filtre de Meili — category = "shoes" AND color = "red" — citant chaque valeur afin que les entrées brutes de l'utilisateur ne puissent pas être divisées en nouvelles clauses. Les guillemets simples et les barres obliques inverses à l'intérieur des valeurs sont échappés ; les noms de champs sont transmis tels quels car la convention de Meili est que les attributs filtrables sont déclarés au moment de la configuration de l'index par l'opérateur, et non par l'appelant, il n'y a donc pas d'identifiant fourni par l'utilisateur à nettoyer.

Les écritures du document analysent le fields_json de chaque appelant en tant qu'objet JSON, injectent le Document::id sous la clé id (l'identifiant explicite l'emporte si l'appelant en a déjà mis un dans fields_json ) et transmettent l'objet entier au SDK via add_documents avec "id" comme clé primaire. Les lectures mappent chaque réponse Meili à SearchHit { id, score, source_json } en extrayant id du résultat JSON et en resérialisant l'objet de résultat complet en tant que source. Les erreurs proviennent de la variante MeiliError::Meilisearch(m) du SDK et sont classées par m.error_code : IndexNotFound, InvalidApiKey | MissingAuthorizationHeader → AccessDenied, InvalidSearchQ | InvalidSearchFilter | InvalidSearchLimit | InvalidSearchOffset → BadQuery, tout le reste → Backend.

Le backend Meili est le bon choix lorsque le site souhaite déjà une tolérance aux fautes de frappe, une recherche à facettes et une mise en évidence des clics ; ce n'est pas le bon choix lorsque vous souhaitez éviter d'exécuter un service supplémentaire aux côtés de Postgres.

@ bext /recherche-pg#

Vit à crates/bext-impls/bext-search-pg. Pour les sites qui exécutent déjà Postgres et ne souhaitent pas de moteur de recherche dédié. Utilise to_tsvector sur une colonne de texte configurable plus plainto_tsquery sur la chaîne de requête de l'appelant, avec des clauses de filtre facultatives traduites en WHERE data ->> 'field' = $N :

sql
SELECT id, data, ts_rank(to_tsvector('english', body), plainto_tsquery('english', $1)) AS rank
FROM products
WHERE to_tsvector('english', body) @@ plainto_tsquery('english', $1)
  AND data ->> 'category' = $2
ORDER BY rank DESC
LIMIT 20;

Le schéma attendu est composé de trois colonnes : id text PRIMARY KEY, data jsonb NOT NULL (la charge utile JSON renvoyée sous la forme source_json ) et body text NOT NULL (la colonne alimentée par to_tsvector ). Un index gin sur to_tsvector('english', body) maintient la clause WHERE sous-linéaire. Les noms des colonnes de données et de texte sont configurables ; le nom de la colonne id ne l'est pas. Les sites qui souhaitent une recherche multicolonne configurent une colonne générée ( GENERATED ALWAYS AS (title || ' ' || body) STORED ) et pointent le plugin dessus.

La protection contre les injections est stricte. Chaque identifiant fourni par l’appelant — nom d’index, colonnes configurées et champs de filtre — doit respecter une expression régulière n’autorisant que les lettres ASCII, les chiffres et le caractère de soulignement, avec une lettre ou un soulignement en première position. Les valeurs passent toujours par les paramètres $N. Les clés de la table des filtres sont le seul endroit où des identifiants non fiables pourraient atteindre la chaîne SQL ; elles sont donc refusées dès la construction : utiliser 1=1-- comme champ renvoie SearchError::BadQuery, sans exécuter de requête.

Les erreurs sont classées à partir du code Postgres SQLSTATE : 42P01 (table_undéfinie) → IndexNotFound, 42501 (privilège_insuffisant) → AccessDenied, tout le reste dans la classe 42xxx (erreurs de syntaxe ou de sémantique) → BadQuery, tout le reste → Backend. Cela permet à un site qui a oublié de créer sa table d'attraper la répartition sous le nom IndexNotFound et d'exécuter une migration en réponse, au lieu de regarder un 500 générique.

Le backend pg est le bon choix lorsque vos données résident déjà dans Postgres et que vos besoins de recherche s'arrêtent à "filtrer + classement par pertinence". Il ne remplace pas Meili ou Elastic : pas de tolérance de faute de frappe, pas de cartes de synonymes, pas d'index inversé distribué.

Configuration#

Les plugins de recherche sont configurés dans le bloc [search] de bext.config.toml :

toml
[search]
provider = "@bext/search-meili"
host = "http://localhost:7700"
api_key = "$MEILI_MASTER_KEY"

Pour Postgres :

toml
[search]
provider = "@bext/search-pg"
conn_str = "$DATABASE_URL"
text_column = "body"
data_column = "data"
dictionary = "english"

Un seul provider est actif à la fois. Les sites qui souhaitent effectuer une recherche sur plusieurs backends (par exemple, Meili pour la recherche de produits et pg pour les recherches de journaux d'audit) câblent un plugin de répartiteur par-dessus plutôt que d'étendre la forme de configuration, de sorte que la composition soit explicite et exploitable.

Où ça correspond#

Search Client est la fonctionnalité de niveau intermédiaire qui permet à un site d'ajouter une recherche de produits, une recherche de documents ou une recherche de journaux sans choisir de moteur au départ. La forme minimale de la requête est délibérée : développez-la plus tard sur le trait une fois que trois backends ont montré le prochain ajout utile, et non à l'avance en fonction du DSL d'un backend. Les stratégies de classement, l'expansion des synonymes, la correction des fautes de frappe, la réécriture des requêtes et la fédération multi-index vivent toutes au-dessus de cette caractéristique, et non à l'intérieur de celle-ci. Cette répartition est ce qui permet d’ajouter des fonctionnalités peu coûteuses et d’assurer l’interchangeabilité du paysage des fournisseurs.

Conseil

Si vous souhaitez valider la forme des documents avant que ne les indexe, câblez un appel Validateur avant index(). La variante SearchError::BadQuery vous indique que la requête était mal formée (et non que le document l'était), donc l'application du schéma appartient à la limite d'écriture.

Attention

limit = 0 signifie « utiliser la limite par défaut du backend », et non « renvoyer tous les résultats ». Définissez une valeur explicite lorsque vous avez besoin d'une taille de page spécifique ; utilisez offset pour la pagination.

En rapport#

Modifier cette page ↗Besoin d’aide ? ↗
Rechercher dans la documentation

Saisissez un sujet, une commande ou une question.