Notes

Recherche via MCP : ce que votre agent IA trouve

Sous le capot : recherche plein texte Postgres, restreinte par la visibilité, immédiatement cohérente, capable de suivre les relations entre contacts.

Quand vous branchez votre IA sur Historis via MCP et demandez « quels clients étaient intéressés par les manteaux d'hiver ? » ou « qu'ai-je dit à Marie le mois dernier ? », quelque chose doit transformer ça en réponse en une seule étape. Ce quelque chose, c'est la recherche.

C'est aussi là qu'un CRM MCP conçu pour cet usage et un espace de travail généraliste divergent discrètement. Si le terme est nouveau, commencez par ce qu'est un CRM MCP. Voici comment ça marche vraiment dans Historis, et pourquoi chaque choix compte quand l'appelant est un agent plutôt qu'un humain qui clique.

Plein texte, pas seulement les titres

La recherche qu'un agent obtient d'un outil généraliste est souvent orientée titre et présentée comme best-effort, sans garantie de tout retourner. La recherche d'Historis est une recherche plein texte Postgres sur le contenu réel de chaque événement et de chaque contact.

Chaque événement porte un search_vector, un tsvector généré et stocké, calculé directement à partir du texte libre de l'événement lui-même, à la fois le contenu et la note de résolution saisie à la clôture :

search_vector tsvector
  generated always as (
    to_tsvector('fr_unaccent',
      coalesce(content, '') || ' ' || coalesce(resolution, ''))
  ) stored

Il est soutenu par un index GIN. Les requêtes parsent l'entrée brute avec websearch_to_tsquery, qui ne lève jamais d'erreur sur une ponctuation ou un opérateur inattendu, et reprennent la même config insensible aux accents que celle du vecteur :

query.textSearch('search_vector', userQuery, {
  type: 'websearch',
  config: 'fr_unaccent',
})

Cette insensibilité aux accents vient d'une configuration de recherche fr_unaccent sur mesure (unaccent + french_stem de Postgres), et elle est partagée par les deux surfaces : une recherche sur francois trouve François, et crepiere trouve crêpière, que vous cherchiez des événements ou des contacts. Pour les contacts, le vecteur va encore plus loin et couvre tous les champs descriptifs : nom, notes, e-mail, téléphone, adresse complète.

Une note est-elle trouvable dès l'instant où vous l'écrivez ?

Oui, et structurellement. Comme search_vector est une colonne GENERATED ALWAYS STORED, Postgres la recalcule et la persiste dans la même transaction que chaque insertion et mise à jour. Pas d'indexeur en arrière-plan, pas de tâche de rafraîchissement, pas de fenêtre de cohérence à terme.

Une note que votre IA enregistre à 10:00:00 apparaît dans la recherche à 10:00:01. Un outil généraliste dont la recherche s'appuie sur un index asynchrone peut manquer un enregistrement créé il y a quelques secondes. C'est le genre de trou qui pousse un agent à créer un doublon parce qu'il n'a pas trouvé l'original. Historis ne peut structurellement pas avoir ce trou.

Qui a le droit de voir quoi, et quand est-ce décidé ?

Avant même d'évaluer une seule ligne, le serveur résout les enregistrements que l'appelant a le droit de voir, et restreint la requête exactement à cet ensemble :

const visible = await getVisibleEventIds(userId, organizationId)
query.in('id', visible)

Pour les contacts, le contrôle de visibilité vit à l'intérieur de la fonction search_contacts. Ces primitives de visibilité sont SECURITY DEFINER (elles s'exécutent avec les droits de leur propriétaire, pas ceux de l'appelant) et appelables uniquement par le rôle de service. Elles font confiance à l'identifiant utilisateur et organisation porté par le jeton OAuth vérifié, jamais à un paramètre fourni par le client. Aucun appelant ne peut élargir son propre périmètre, et il n'existe aucun moyen d'accéder à l'enregistrement d'un autre tenant en devinant son identifiant (pas d'IDOR).

Et « vous n'avez pas le droit de le voir » produit exactement la même réponse que « ça n'existe pas ». La recherche ne peut jamais servir d'oracle pour sonder l'existence d'enregistrements cachés à l'appelant.

Pagination keyset, pas OFFSET

Historis pagine les résultats avec un curseur keyset sur (occurred_at, id) plutôt qu'un OFFSET numérique. Le serveur récupère limit + 1 lignes ; la ligne en trop prouve à la fois qu'une page suivante existe et fournit son curseur, en un seul aller-retour. Une recherche keyset coûte autant à la page 100 qu'à la page 1, parce que la base saute directement à la position du curseur dans l'index et ne reparcourt jamais les lignes précédentes. Et elle reste correcte sous écritures concurrentes : une ligne insérée ou supprimée ailleurs ne décale jamais les bornes de page.

Le curseur est un jeton opaque. Avant d'interpoler son timestamp ou son UUID dans une requête, le serveur valide la forme de chaque valeur contre des motifs stricts. Un curseur édité à la main ne peut donc rien glisser dans le filtre.

Un détail passe facilement inaperçu. person_name est entièrement résolu en base. Le serveur trouve les contacts correspondants, puis les événements qui leur sont liés, et restreint la requête à ces identifiants avant de récupérer la page. Ce filtre par nom se compose donc avec la recherche plein texte et avec le curseur keyset, et il vaut pour toutes les pages, y compris celles que l'agent n'a pas encore demandées.

La recherche peut-elle suivre les relations ?

Les contacts forment un graphe, donc search_persons peut filtrer par relation. with_role et without_role répondent à des questions comme « trouve tous les fournisseurs » ou « les clients pas encore tagués VIP ». La correspondance se fait sur le label d'un lien ou sur un tag du contact lié, avec la même configuration plein texte insensible aux accents, le tout à l'intérieur de la fonction qui applique la visibilité. C'est la ligne qu'un tableur ne peut pas franchir, parce que des lignes plates n'ont aucune relation à parcourir.

Pensé pour être lu par un agent MCP

Chaque résultat Historis revient en contenu structuré compact plus une courte ligne de texte avec les identifiants et les dates en clair, pas un mur de JSON imbriqué que le modèle doit traverser. Les contenus longs sont prévisualisés à ~280 caractères ; les personnes liées et les tags voyagent dans le même payload, donc l'agent a rarement besoin d'un second appel pour comprendre une ligne.

Tout ce qu'une IA a écrit est marqué d'un : un résultat ne laisse jamais une note stockée se faire passer pour une instruction, et vous distinguez toujours vos mots de ceux d'un assistant.

Pourquoi tout ça compte

Au total, la recherche via le MCP d'Historis est :

  • Plein texte : elle cherche dans le contenu des fiches, pas seulement dans les titres.
  • Insensible aux accents, donc francois trouve François, sur toutes les surfaces.
  • Restreinte par la visibilité avant toute correspondance ; un enregistrement caché n'est jamais candidat.
  • Immédiatement cohérente : une note est trouvable dans la transaction même qui l'écrit.
  • Paginée en keyset, la page 100 coûtant autant que la page 1, même sous écritures concurrentes.
  • Capable de suivre les relations, filtrant sur le graphe qu'un tableur n'a pas.

Pour votre IA, ça veut dire moins d'allers-retours, des résultats déterministes, et une surface de recherche qui ne peut ni fuiter entre tenants ni inventer de correspondance.

C'est l'idée derrière tout Historis : votre IA fait le travail, mais le système le garde correct, cadré et traçable. La recherche alimente aussi l'étape suivante, car un agent capable de retrouver de manière fiable une interaction passée est un agent capable de mener les relances sans perdre le fil. Les contrats d'outils derrière chaque requête sont dans la documentation MCP.

À lire aussi : comment une surface agent multi-tenant reste étanche, le périmètre de visibilité dans lequel chaque recherche s'exécute.

Questions fréquentes

Comment fonctionne la recherche dans un CRM MCP pour un agent IA ?
L'agent appelle un outil de recherche via MCP et le serveur exécute une requête plein texte Postgres sur le texte même des événements et des contacts. Aucun modèle ne fait la recherche : un tsvector insensible aux accents est comparé à la requête parsée, restreint aux enregistrements que l'appelant a le droit de voir, puis renvoyé en contenu structuré compact que l'agent lit d'un seul coup.
Historis utilise-t-il de l'IA pour sa recherche ?
Non. Historis ne fait tourner aucun modèle côté serveur. La recherche est une recherche plein texte Postgres déterministe sur un tsvector stocké. Votre propre IA, connectée via MCP, décide quoi chercher et interprète les résultats, mais la mise en correspondance elle-même est une recherche classique en base de données, donc les résultats sont reproductibles plutôt que best-effort.
Un agent IA peut-il voir des enregistrements auxquels il n'a pas droit ?
Non. La visibilité est résolue avant qu'une seule ligne ne soit examinée : le serveur restreint la requête exactement aux enregistrements que l'appelant a le droit de voir, via des fonctions SECURITY DEFINER qui font confiance au jeton OAuth vérifié plutôt qu'à un paramètre fourni par le client. « Pas le droit de le voir » renvoie exactement la même réponse que « ça n'existe pas », donc la recherche ne peut jamais servir à sonder des enregistrements cachés.
Une note que l'IA vient d'écrire est-elle immédiatement trouvable ?
Oui. Le vecteur de recherche est une colonne GENERATED ALWAYS STORED, donc Postgres le recalcule dans la même transaction que chaque insertion et mise à jour. Pas d'indexeur en arrière-plan ni de fenêtre de cohérence à terme, donc une note enregistrée à 10:00:00 est trouvable à 10:00:01 et un agent ne créera pas de doublon faute d'avoir trouvé l'original.
Pourquoi un CRM peut-il chercher les relations là où un tableur ne peut pas ?
Dans Historis, les contacts forment un graphe, donc la recherche peut filtrer par relation : trouver tous les fournisseurs, ou les clients pas encore tagués VIP, mis en correspondance avec le label d'un lien ou un tag du contact lié. Un tableur stocke des lignes plates sans relations, donc il ne peut pas répondre à ces questions sans que vous reconstruisiez les jointures à la main à chaque fois.
Comment distinguer dans les résultats les entrées écrites par l'IA des miennes ?
Tout ce qu'une IA a écrit est marqué d'un losange, attribué et journalisé, donc une note stockée ne peut jamais se faire passer pour une instruction et vous distinguez toujours vos mots de ceux d'un assistant. Les écritures IA s'appliquent directement mais restent traçables et réversibles, et les permissions par client connecté sont choisies par organisation à l'écran de consentement, en lecture seule par défaut.

Articles liés