Compléter la saisie dans une barre de recherche
Proposer des suggestions dès les premiers caractères frappés dans une barre de recherche.
Pas besoin d’IA
GratuitInstantanéVos données ne sortent pas
Le verdict
N0 répond, N1 ne fait que remettre en ordre ce que N0 a trouvé, et les deux niveaux échouent sur la même faute de frappe : le test de N1 le démontre. Prenez N0, et ajoutez N1 le jour où votre journal de clics est assez fourni pour montrer que le terme choisi n’est plus celui du haut de la liste — assez fourni, et pas avant : un seul clic y commande le classement de tous les préfixes qu’il partage. Le réordonnancement ne fait que lire des compteurs en mémoire, sans appel, mais vous prenez en charge un journal de comportement à conserver, et un compteur qui renforce sa propre sortie.
Arbre de préfixes, trié par fréquence de recherche
Pas besoin d’IA
Règle et algorithme classiqueN0GratuitInstantané
Python
"""
Suggérer à mesure que l'utilisateur tape : un arbre de préfixes, ordonné par
la fréquence à laquelle un terme est cherché.
Niveau N0. Sans dépendance, et l'index entier est un emboîtement de
dictionnaires qui tient dans la mémoire du processus qui sert la barre de
recherche.
Deux décisions portent l'approche.
D'abord, l'arbre est indexé sur une orthographe normalisée, accents repliés
et casse abandonnée, tandis que chaque feuille conserve l'orthographe
d'origine. Qui tape « ec » trouve « écharpe », et le lit tout de même
correctement écrit dans la liste déroulante.
Ensuite, l'ordre est un tri ordinaire sur le compte d'usage, fait une fois
par nœud et conservé là. La barre vide atteint l'index entier, une première
lettre une bonne part de celui-ci, et chaque utilisateur demande les deux :
les trier de nouveau à chaque frappe, c'est là que partirait le temps. Le
prix se paie en mémoire, au plus une référence par terme pour chaque nœud
qu'une frappe a atteint.
"""
import unicodedata
# Marks the terms that end at a node. It is not a string, so no character can
# collide with it.
END = object()
# Where a node keeps its ranked terms once a keystroke has asked for them.
RANKED = object()
# Categories M* are the accents NFKD detaches; Cf holds the invisible characters.
DROPPED = {"Mn", "Mc", "Me", "Cf"}
# NFKD has already turned non-breaking spaces into plain ones; these are the
# other spaces. The final sigma is folded by hand: JavaScript keeps it.
FOLD = str.maketrans("\t\n\v\f\rς", " σ")
def normalise(text: str) -> str:
"""
Fold case and strip accents, so that "ec" reaches "écharpe".
Invisible characters (zero-width space, byte order mark) are dropped, and
runs of spaces, non-breaking ones included, become one space with none at
either end: what gets pasted into a search bar carries all of these.
"""
# Upper then lower case folds "ß" into "ss", the same way in both languages.
# "ẞ" is the capital of "ß" and upper-casing leaves it alone; written as
# "ß" first, the pair folds to "ss" on both sides.
folded = unicodedata.normalize("NFKD", text.replace("\u1e9e", "ß")).upper().lower().translate(FOLD)
kept = "".join(c for c in folded if unicodedata.category(c) not in DROPPED)
return " ".join(word for word in kept.split(" ") if word)
def build(entries) -> dict:
"""
Build the tree from pairs of (term, how often it was searched).
Terms sharing a normalised spelling are kept side by side at the same
leaf, rather than one silently replacing the other.
"""
root: dict = {}
for term, count in entries:
key = normalise(term)
node = root
for char in key:
node = node.setdefault(char, {})
node.setdefault(END, []).append((count, key, term))
return root
def _descend(root: dict, prefix: str) -> dict:
"""The node holding everything that starts with `prefix`, empty if none does."""
node = root
for char in prefix:
node = node.get(char, {})
return node
def _collect(node: dict):
"""
Every (count, key, term) stored under a node, in no particular order.
A stack rather than recursion: one term of a hundred thousand characters in
the search log would otherwise overflow the call stack.
"""
stack = [node]
while stack:
for key, value in stack.pop().items():
if key is END:
yield from value
elif key is not RANKED:
stack.append(value)
def suggest(root: dict, prefix: str, limit: int = 5) -> list[str]:
"""
The most searched terms starting with `prefix`, most searched first.
An empty prefix returns the most searched terms overall, which is what an
empty search bar should offer. An unknown prefix returns nothing: the tree
answers about the characters it was given, not about the ones it guesses.
"""
node = _descend(root, normalise(prefix))
if RANKED not in node:
# Sorted once per node, then read back. Equal counts fall back to the
# normalised spelling, so "écharpe" comes before "zèbre".
node[RANKED] = sorted(_collect(node), key=lambda entry: (-entry[0], entry[1], entry[2]))
return [term for _, _, term in node[RANKED][:limit]]JavaScript
/**
* Suggérer à mesure que l'utilisateur tape : un arbre de préfixes, ordonné par
* la fréquence à laquelle un terme est cherché.
*
* Niveau N0. Sans dépendance, et l'index entier est un emboîtement de
* dictionnaires qui tient dans la mémoire du processus qui sert la barre de
* recherche.
*
* Deux décisions portent l'approche.
*
* D'abord, l'arbre est indexé sur une orthographe normalisée, accents repliés
* et casse abandonnée, tandis que chaque feuille conserve l'orthographe
* d'origine. Qui tape « ec » trouve « écharpe », et le lit tout de même
* correctement écrit dans la liste déroulante.
*
* Ensuite, l'ordre est un tri ordinaire sur le compte d'usage, fait une fois
* par nœud et conservé là. La barre vide atteint l'index entier, une première
* lettre une bonne part de celui-ci, et chaque utilisateur demande les deux :
* les trier de nouveau à chaque frappe, c'est là que partirait le temps. Le
* prix se paie en mémoire, au plus une référence par terme pour chaque nœud
* qu'une frappe a atteint.
*/
// Marks the terms that end at a node. A character can never collide with it.
const END = Symbol('term');
// Where a node keeps its ranked terms once a keystroke has asked for them.
const RANKED = Symbol('ranked');
/**
* Fold case and strip accents, so that "ec" reaches "écharpe".
*
* Invisible characters (zero-width space, byte order mark) are dropped, and
* runs of spaces, non-breaking ones included, become one space with none at
* either end: what gets pasted into a search bar carries all of these.
*/
export function normalise(text) {
// Upper then lower case folds "ß" into "ss", the same way in both languages,
// and the final sigma is folded by hand. \p{M} holds the accents NFKD
// detaches, \p{Cf} the invisible characters; NFKD has already turned
// non-breaking spaces into plain ones.
// "ẞ" is the capital of "ß" and upper-casing leaves it alone; written as
// "ß" first, the pair folds to "ss" on both sides.
const folded = text.replace(/\u1e9e/g, 'ß').normalize('NFKD').toUpperCase().toLowerCase().replace(/ς/g, 'σ');
const kept = folded.replace(/[\p{M}\p{Cf}]/gu, '').replace(/[\t\n\v\f\r]/g, ' ');
return kept.split(' ').filter(Boolean).join(' ');
}
/**
* Build the tree from pairs of [term, how often it was searched].
*
* Terms sharing a normalised spelling are kept side by side at the same leaf,
* rather than one silently replacing the other.
*/
export function build(entries) {
const root = new Map();
for (const [term, count] of entries) {
const key = normalise(term);
let node = root;
for (const char of key) node = node.get(char) ?? node.set(char, new Map()).get(char);
if (!node.has(END)) node.set(END, []);
node.get(END).push([count, key, term]);
}
return root;
}
/**
* Every [count, key, term] stored under a node, in no particular order.
*
* A stack rather than recursion: one term of a hundred thousand characters in
* the search log would otherwise overflow the call stack.
*/
function collect(root) {
const found = [];
const stack = [root];
while (stack.length > 0) {
for (const [key, value] of stack.pop()) {
if (key === END) for (const entry of value) found.push(entry);
else if (key !== RANKED) stack.push(value);
}
}
return found;
}
const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
/**
* The most searched terms starting with `prefix`, most searched first.
*
* An empty prefix returns the most searched terms overall, which is what an
* empty search bar should offer. An unknown prefix returns nothing: the tree
* answers about the characters it was given, not about the ones it guesses.
*/
export function suggest(root, prefix, limit = 5) {
// Walk down to the node holding everything that starts with the prefix; an
// unknown prefix walks into an empty node, which holds nothing to suggest.
let node = root;
for (const char of normalise(prefix)) node = node.get(char) ?? new Map();
if (!node.has(RANKED)) {
// Sorted once per node, then read back. Equal counts fall back to the
// normalised spelling, so "écharpe" comes before "zèbre".
const found = collect(node);
found.sort((a, b) => b[0] - a[0] || compare(a[1], b[1]) || compare(a[2], b[2]));
node.set(RANKED, found);
}
return node.get(RANKED).slice(0, limit).map(([, , term]) => term);
}Essayer
interactifLe code ci-dessus tourne dans votre navigateur. Écrivez dans le champ : le résultat se refait sous vos yeux.
Le nombre de recherches est ce que l’arbre trie : c’est lui qui décide de l’ordre, et rien d’autre.
Ou partez d’un exemple
La saisie libre et les autres exemples ont besoin de JavaScript. L’exemple affiché ci-dessous, lui, a été calculé à la construction du site.
Ce qu’il renvoie
| Suggestion | Recherches sur quinze jours |
|---|---|
| crème fraîche | 900 |
| crème de marrons | 260 |
2 suggestions sur les 7 termes du journal.
Là, ça casseVoir le niveau au-dessus
Ce que ça implique
- Vos données ne sortent pas
- Résultat toujours identique
- Se teste unitairement, cas par cas
- Aucun fournisseur dans la boucle
- Empreinte négligeable
Ce que cela ajoute à votre périmètre réglementaire
- Aucun périmètre spécifique ajouté : l’index tient dans le processus qui sert la barre de recherche, et la frappe ne le quitte pas
Point de rupture
La faute de frappe, à n’importe quelle position : l’arbre ne descend que les caractères qu’on lui donne, et la liste se vide à partir de la touche fautive. « echarpe » remonte « écharpe en laine », « rcharpe » et « echarpz » ne remontent rien, et la bonne orthographe est pourtant dans l’index — la faute sur le premier caractère est le pire cas, parce que rien n’a jamais été montré. Second manque, plus fréquent sur un catalogue à termes composés : la saisie du deuxième mot. « laine » ne remonte pas « écharpe en laine », faute d’indexer aussi les suffixes de mot.
Quand passer au niveau suivant
Votre journal de clics montre que le terme choisi n’est pas celui du haut de la liste : la fréquence de recherche classe mal ce que les gens finissent par prendre.
Les quatre options
- RecommandéN0Arbre de préfixes, trié par fréquence de rechercheGratuit · Instantané
- N1Réordonnancement par comptage des clics passésnégligeable · Instantané
- N2Niveau sans objet ici
- N3Niveau sans objet ici
Les autres options
N1Réordonnancement par comptage des clics passésnégligeable · Instantané
Réordonnancement par comptage des clics passés
Modèle classique légerN1négligeableInstantané
Python
"""
Réordonner les suggestions avec ce sur quoi les gens ont réellement cliqué.
Niveau N1. L'arbre de préfixes de N0 classe les candidats par la fréquence à
laquelle un terme est cherché. Ce compte dit ce que les gens ont cherché, pas
ce qu'ils ont pris une fois la liste ouverte. Les clics disent la seconde
chose, et ils sont déjà dans les journaux.
Le modèle est un comptage, pas un gradient : pour chaque préfixe qui a été
frappé un jour, combien de fois chaque suggestion a été choisie. Il
s'entraîne en une passe sur le journal et se relit par des lectures de
dictionnaire, au plus une par longueur de préfixe pour chaque candidat, sur
une saisie dont la longueur comptée est plafonnée.
Les candidats arrivent en argument : ce niveau réordonne une liste, il ne la
retrouve pas.
"""
import unicodedata
# Categories M* are the accents NFKD detaches; Cf holds the invisible characters.
DROPPED = {"Mn", "Mc", "Me", "Cf"}
# NFKD has already turned non-breaking spaces into plain ones; these are the
# other spaces. The final sigma is folded by hand: JavaScript keeps it.
FOLD = str.maketrans("\t\n\v\f\rς", " σ")
# Only the first characters of a query are counted. A pasted paragraph would
# otherwise store one key per prefix, each a copy of the prefix: the memory
# grows with the square of its length, and so does the lookup.
MAX_TYPED = 64
def normalise(text: str) -> str:
"""Same folding as the prefix tree, so both rungs agree on what was typed."""
# Upper then lower case folds "ß" into "ss", the same way in both languages.
# "ẞ" is the capital of "ß" and upper-casing leaves it alone; written as
# "ß" first, the pair folds to "ss" on both sides.
folded = unicodedata.normalize("NFKD", text.replace("\u1e9e", "ß")).upper().lower().translate(FOLD)
kept = "".join(c for c in folded if unicodedata.category(c) not in DROPPED)
return " ".join(word for word in kept.split(" ") if word)
def learn(clicks) -> dict:
"""
Count clicks from pairs of (what was typed, which suggestion was clicked).
One click teaches something about every prefix of what was typed, from the
first letter on: whoever chose "chaussettes de sport" after typing "chau"
also tells us what to show at "c" and at "cha". The empty prefix is left
out on purpose: counted, one click made under any query at all would move
its term to the top of every other query.
Nothing here decays and nothing is windowed: a term that was clicked a lot
two years ago keeps its lead for good.
"""
model: dict = {}
for typed, term in clicks:
typed = normalise(typed)[:MAX_TYPED]
for length in range(1, len(typed) + 1):
key = (typed[:length], term)
model[key] = model.get(key, 0) + 1
return model
def _evidence(model: dict, prefix: str, term: str) -> int:
"""
Clicks recorded for the longest prefix of the query that saw this term.
Backing off matters: a rare prefix has too few clicks of its own, but it
shares its first letters with past queries that do. The longer
the matching prefix, the more specific the evidence, hence the weight.
"""
for length in range(len(prefix), 0, -1):
clicked = model.get((prefix[:length], term), 0)
if clicked:
return clicked * (length + 1)
return 0
def rerank(model: dict, prefix: str, candidates, limit: int = 5) -> list[str]:
"""
Sort candidates by past clicks, keeping their incoming order as tie-break.
Candidates arrive ordered by search frequency, as the previous rung left
them. A term nobody ever clicked keeps that order.
What that does not make it is safe on a thin log. Clicks are counted, not
weighed: one click recorded under "c" puts its term at the top of every
query that starts with a "c", ahead of terms drawn from thousands of
searches. And the clicks come from the list this very ranking produced —
the top suggestions get clicked because they are at the top, and the count
then hardens the order it was meant to correct.
"""
prefix = normalise(prefix)[:MAX_TYPED]
ranked = sorted(
enumerate(candidates),
key=lambda pair: (-_evidence(model, prefix, pair[1]), pair[0]),
)
return [term for _, term in ranked[:limit]]JavaScript
/**
* Réordonner les suggestions avec ce sur quoi les gens ont réellement cliqué.
*
* Niveau N1. L'arbre de préfixes de N0 classe les candidats par la fréquence à
* laquelle un terme est cherché. Ce compte dit ce que les gens ont cherché, pas
* ce qu'ils ont pris une fois la liste ouverte. Les clics disent la seconde
* chose, et ils sont déjà dans les journaux.
*
* Le modèle est un comptage, pas un gradient : pour chaque préfixe qui a été
* frappé un jour, combien de fois chaque suggestion a été choisie. Il
* s'entraîne en une passe sur le journal et se relit par des lectures de
* dictionnaire, au plus une par longueur de préfixe pour chaque candidat, sur
* une saisie dont la longueur comptée est plafonnée.
*
* Les candidats arrivent en argument : ce niveau réordonne une liste, il ne la
* retrouve pas.
*/
// Only the first characters of a query are counted. A pasted paragraph would
// otherwise store one key per prefix, each a copy of the prefix: the memory
// grows with the square of its length, and so does the lookup.
export const MAX_TYPED = 64;
/** Same folding as the prefix tree, so both rungs agree on what was typed. */
export function normalise(text) {
// Upper then lower case folds "ß" into "ss", the same way in both languages,
// and the final sigma is folded by hand. \p{M} holds the accents NFKD
// detaches, \p{Cf} the invisible characters; NFKD has already turned
// non-breaking spaces into plain ones.
// "ẞ" is the capital of "ß" and upper-casing leaves it alone; written as
// "ß" first, the pair folds to "ss" on both sides.
const folded = text.replace(/\u1e9e/g, 'ß').normalize('NFKD').toUpperCase().toLowerCase().replace(/ς/g, 'σ');
const kept = folded.replace(/[\p{M}\p{Cf}]/gu, '').replace(/[\t\n\v\f\r]/g, ' ');
return kept.split(' ').filter(Boolean).join(' ');
}
// A map key has to be a single value. A tab never occurs inside a normalised
// prefix, since normalise turns it into a space, so the first tab splits the key.
const key = (prefix, term) => `${prefix}\t${term}`;
/**
* Count clicks from pairs of [what was typed, which suggestion was clicked].
*
* One click teaches something about every prefix of what was typed, from the
* first letter on: whoever chose "chaussettes de sport" after typing "chau"
* also tells us what to show at "c" and at "cha". The empty prefix is left out
* on purpose: counted, one click made under any query at all would move its
* term to the top of every other query.
*
* Nothing here decays and nothing is windowed: a term that was clicked a lot two
* years ago keeps its lead for good.
*/
export function learn(clicks) {
const model = new Map();
for (const [typed, term] of clicks) {
const prefix = normalise(typed).slice(0, MAX_TYPED);
for (let length = 1; length <= prefix.length; length += 1) {
const at = key(prefix.slice(0, length), term);
model.set(at, (model.get(at) ?? 0) + 1);
}
}
return model;
}
/**
* Clicks recorded for the longest prefix of the query that saw this term.
*
* Backing off matters: a rare prefix has too few clicks of its own, but it
* shares its first letters with past queries that do. The longer
* the matching prefix, the more specific the evidence, hence the weight.
*/
function evidence(model, prefix, term) {
for (let length = prefix.length; length >= 1; length -= 1) {
const clicked = model.get(key(prefix.slice(0, length), term)) ?? 0;
if (clicked) return clicked * (length + 1);
}
return 0;
}
/**
* Sort candidates by past clicks, keeping their incoming order as tie-break.
*
* Candidates arrive ordered by search frequency, as the previous rung left
* them. A term nobody ever clicked keeps that order.
*
* What that does not make it is safe on a thin log. Clicks are counted, not
* weighed: one click recorded under "c" puts its term at the top of every query
* that starts with a "c", ahead of terms drawn from thousands of searches. And
* the clicks come from the list this very ranking produced — the top
* suggestions get clicked because they are at the top, and the count then
* hardens the order it was meant to correct.
*/
export function rerank(model, prefix, candidates, limit = 5) {
const typed = normalise(prefix).slice(0, MAX_TYPED);
const scored = candidates.map((term, rank) => [evidence(model, typed, term), rank, term]);
scored.sort((a, b) => b[0] - a[0] || a[1] - b[1]);
return scored.slice(0, limit).map(([, , term]) => term);
}Ce que ça implique
- Vos données restent chez vous
- Résultat toujours identique
- Se teste unitairement, cas par cas
- Aucun fournisseur dans la boucle
- Empreinte faible
Ce que cela ajoute à votre périmètre réglementaire
- Traitement de données d’usage sur votre infrastructure : le journal conserve ce que les utilisateurs ont frappé, et la suggestion qu’ils ont choisie ensuite
- Une requête de recherche contient ce que l’utilisateur y met : rien dans l’approche ne la filtre avant de la compter
Point de rupture
Les clics sont comptés, pas pesés. Un seul clic enregistré sous « c » met son terme en tête de toutes les requêtes qui commencent par un « c », devant des termes tirés de milliers de recherches : sur un journal mince, un clic de robot ou un clic de recette renverse le classement. Et les clics viennent de la liste que ce classement a produite — les suggestions du haut reçoivent les clics parce qu’elles sont en haut, et le compteur durcit l’ordre qu’il devait corriger. Témoin : un clic fait sous une saisie sans rapport, « zzz », ne déplace rien.
Quand passer au niveau suivant
Il n’y a pas de niveau au-dessus. Ce qui manque encore, la tolérance à la faute de frappe, se corrige dans la récupération et non dans le classement : une distance d’édition bornée sur le préfixe, ce que les moteurs d’index offrent déjà — l’option `fuzzy` du suggesteur `completion` d’Elasticsearch, par exemple.
N2Niveau sans objet ici
Niveau sans objet ici
Petit modèle spécialisé auto-hébergéN2
Un modèle auto-hébergé demande un service permanent à exploiter et un appel à chaque touche, pour proposer des termes qui appartiennent déjà à un ensemble fermé : l’arbre de préfixes les sort de la mémoire du processus.
N3Niveau sans objet ici
Niveau sans objet ici
API de LLM généralisteN3
Un appel réseau facturé à chaque pause de frappe, pour proposer des termes que l’index contient déjà et qui forment un ensemble fermé. Rien de ce qu’un modèle apporterait ne se trouve dans cet ensemble.
Pour aller plus loin
- Introduction to Information Retrieval, chapitre 3 — Dictionnaires et récupération tolérante aux fautes
- Unicode Standard Annex #15 — Normalization Forms, la décomposition de compatibilité (NFKD) employée par les deux niveaux
- W3C ARIA Authoring Practices — le motif combobox, pour la liste déroulante de suggestions
- Elasticsearch — Suggesters, la même approche par préfixe à l’échelle d’un index
Métadonnées
Information générale, tenue à jour au mieux. Ce n’est pas un avis juridique. Faites valider votre situation par un professionnel.