Aller au contenu

Gérer les bases via l'API

Vous pouvez tout piloter par API : créer des bases, y ajouter des documents et des extraits (« chunks »), et faire de la recherche directe (sans modèle). La même clé sk- que pour les appels au modèle authentifie ces requêtes.

Fenêtre de terminal
export AUTH="Authorization: Bearer $MIRACA_API_KEY"
export BASE="https://api.miraca.fr/v1/kb"
MéthodeRouteEffet
GET/v1/kbLister vos bases
POST/v1/kbCréer une base
GET/v1/kb/{slug}Détail d’une base
PATCH/v1/kb/{slug}Modifier (nom, slug, intention, facettes, type, découpage par défaut ; pas le modèle d’embedding)
DELETE/v1/kb/{slug}Supprimer (+ documents + chunks)
GET/v1/kb/{slug}/exportExporter la base entière (bundle .kb.json.gz)
POST/v1/kb/importImporter un bundle dans une base neuve
Fenêtre de terminal
# Créer une base, avec un schéma de facettes (structure des métadonnées)
curl "$BASE" -H "$AUTH" -H "Content-Type: application/json" -d '{
"name": "FAQ Support",
"intent": "FAQ : une question/réponse par extrait.",
"facet_schema": { "facets": [
{ "key": "theme", "values": ["facturation", "securite", "compte"] },
{ "key": "montant", "type": "number", "description": "Montant TTC en euros, sans symbole." },
{ "key": "resolu", "type": "boolean", "default": "false" }
]}
}'
# → { "slug": "faq-support", "name": "FAQ Support", "facet_schema": {…}, … }

Le slug renvoyé est l’identifiant à utiliser partout ensuite (y compris dans knowledge_base lors des appels RAG).

Chaque entrée de facet_schema.facets contraint ce que l’IA doit extraire pour chaque extrait lors du découpage piloté par l’IA :

ChampTypeRôle
keystringRequis. Nom de la facette (ex. theme).
typestringForme de la valeur : text (défaut), number, boolean, date (ISO AAAA-MM-JJ), personne, email, url (cf. ci-dessous). Force l’IA à renvoyer un nombre/booléen plutôt qu’un texte approximatif.
descriptionstring(optionnel) Intention : décrit en clair ce que l’IA doit mettre dans cette facette.
valuesstring[](optionnel, type text uniquement) Valeurs autorisées → l’IA est contrainte à cet enum.
multipleboolean(optionnel, types text, personne, email, url) true = plusieurs valeurs par document (ex. plusieurs thématiques, les participants d’une réunion). La valeur est alors stockée en liste, même d’un seul élément.
defaultstring(optionnel) Valeur de repli si l’IA ne sait pas trancher (coercée selon le type).

Une facette sans type est traitée comme text (rétrocompatible). Les valeurs sont ensuite filtrables à la recherche (knowledge_base_facets).

Un schéma incohérent est refusé (400) : une clé déclarée deux fois, ou une valeur par défaut absente des valeurs autorisées ou impossible à convertir dans le type.

Trois types donnent une forme à une valeur texte : personne, email et url. Pour tout ce qui filtre, groupe, agrège ou liste les valeurs, ils se comportent exactement comme du texte : la valeur stockée est une chaîne, cherchée avec les mêmes opérateurs (eq, neq, in, contains…). Ils ne diffèrent qu’à l’écriture (validation et forme canonique) et à l’affichage au portail.

{ "facets": [
{ "key": "participants", "type": "personne", "multiple": true },
{ "key": "contact", "type": "email" },
{ "key": "visio", "type": "url" }
]}
TypeAccepté à l’écritureForme stockée
personneCamille Ferrand <Camille@Asso.org>, "Ferrand, Camille" <c@asso.org>, camille@asso.org, mailto:camille@asso.org, ou le nom seulCamille Ferrand <camille@asso.org> : le nom, puis l’adresse entre chevrons ; ou l’adresse seule ; ou le nom seul
emailune adresse, éventuellement entre chevrons ou préfixée de mailto:l’adresse, en minuscules
urlun lien http ou https ; sans schéma, https:// est ajoutéle lien complet (https://meet.google.com/abc-defg-hij)
  • Validation : une valeur illisible dans son type est refusée (400) quand vous la posez (facet_overrides, ajout ou modification de document), avec un message qui dit ce qui est attendu. Une adresse invalide entre chevrons (Paul <pas-une-adresse>) n’est pas une personne. Un lien javascript: ou file: n’est jamais accepté. Une valeur produite par l’IA lors du découpage qui ne se lit pas est écartée (le document, lui, est gardé).

  • Forme canonique : l’adresse passe en minuscules, les espaces du nom sont aplatis, les guillemets retirés. Une même personne est donc toujours la même valeur, ce qui fait fonctionner l’égalité, le groupement et le comptage. Les filtres d’égalité (eq, neq, in, not_in) mettent leur valeur dans la même forme avant de comparer.

  • Chercher une personne par son adresse : la valeur stockée contient le nom, donc une égalité sur l’adresse seule ne la trouve pas. Utilisez contains :

    { "filters": [{ "key": "participants", "op": "contains", "value": "camille@asso.org" }] }
  • Découverte (/facets) : ces facettes rendent leur vrai type (personne, email, url) avec la même forme que le texte (values, distinct_count, truncated). Un client qui ne connaît pas ces types peut les traiter comme du texte.

  • Pas d’énumération : values est réservé au type text. La valeur par défaut (default), elle, doit se lire dans le type, sinon le schéma est refusé.

{ "key": "themes", "values": ["climat", "emploi", "sante"], "multiple": true }
  • Écriture : facet_overrides accepte une liste ({"themes": ["climat", "emploi"]}) ou une valeur seule, rangée en liste d’un élément. Chaque élément doit appartenir à values s’il est déclaré. Une liste sur une facette à valeur unique est refusée (400) : déclarez multiple: true.
  • Filtres : l’égalité (facets, eq, in, contains) trouve les documents qui portent au moins la valeur ; neq et not_in ceux qui n’en portent aucune. Une liste passée dans facets se lit « porte toutes ces valeurs ».
  • Découverte (/facets) : chaque valeur est comptée à part, au nombre de documents.
  • mode: "query" + group_by : un document compte dans le groupe de chacune de ses valeurs. La somme des groupes dépasse donc le total, et la réponse le signale par multi_valued_groups.
  • État courant par entité (group_by: {facet, latest}) : refusé sur une facette multiple, qui ne désigne pas une entité.

Modifier facet_schema (PATCH /v1/kb/{slug}) relit les valeurs déjà posées sur les documents pour chaque facette dont la contrainte change : type, valeurs autorisées, valeur unique ou multiple. L’intention et la valeur par défaut se modifient librement.

  • Ce qui se convertit sans perte est converti automatiquement : "12" devient 12 quand la facette passe en number, une valeur devient une liste d’un élément quand elle passe en multiple, une liste d’un élément redevient une valeur quand elle repasse en valeur unique.
  • Ce qui ne passe plus bloque la modification : réponse 409, rien n’est modifié, et conflicts liste chaque valeur en cause avec le nombre de documents qui la portent.
  • Retirer une facette du schéma ne bloque jamais : ses valeurs deviennent une facette libre, toujours filtrable.
{
"error": { "message": "Ce changement de schéma laisserait des documents avec une valeur que la base n'accepte plus : …" },
"conflicts": [
{ "key": "statut", "value": "en cours", "documents": 12, "reason": "valeur \"en cours\" non autorisée. Valeurs acceptées : \"a_faire\", \"fait\"." }
]
}

Renvoyez alors la même modification avec facet_value_migrations : pour chaque valeur, où la mettre (to), ou null pour la retirer. C’est aussi la façon de renommer une valeur.

Fenêtre de terminal
curl -X PATCH "$BASE/suivi" -H "$AUTH" -H "Content-Type: application/json" -d '{
"facet_schema": { "facets": [ { "key": "statut", "values": ["a_faire", "en_cours", "fait"] } ] },
"facet_value_migrations": [
{ "key": "statut", "from": "en cours", "to": "en_cours" },
{ "key": "statut", "from": "abandonné", "to": null }
]
}'
# → { "slug": "suivi", …, "facet_migration": [ { "key": "statut", "from": "en cours", "to": "en_cours", "documents": 12 }, … ] }

La réécriture porte sur les extraits et sur les facettes mémorisées du document (celles que relit un re-découpage), dans la même transaction que le schéma. Sur une facette multiple, une correspondance s’applique à chaque élément de la liste. facet_migration rend ce qui a été réécrit, conversions automatiques comprises. Cette réécriture ne déclenche pas les tâches qui surveillent la base : aucune fiche n’a bougé, seule l’étiquette d’une valeur a changé.

À la création (POST /v1/kb) ou en modification (PATCH), une base a un role :

ValeurUsage
data (défaut)Base de données classique : des faits que l’IA restitue.
processBase de procédures (playbooks) : chaque document décrit, étape par étape, comment mener une analyse (quelles recherches lancer, quelles facettes filtrer, quoi vérifier).

En mode agentique, quand une base process est sélectionnée dans knowledge_base, les fiches les plus proches de la demande sont retrouvées automatiquement et fournies à l’IA avant qu’elle ne commence : elle ne dépense pas d’étape à les chercher. Elle suit ensuite leurs étapes sur les bases de données, et se replie sur une démarche autonome si aucune fiche ne correspond. La liste complète des fiches lui reste présentée — elle peut en ouvrir une autre. Voir le guide RAG pour un exemple complet.

Fenêtre de terminal
# Créer une base de procédures
curl "$BASE" -H "$AUTH" -H "Content-Type: application/json" -d '{
"name": "Procédures — Fonds Impact",
"role": "process",
"intent": "Playbooks : comment mener nos analyses récurrentes."
}'
# → { "slug": "procedures-fonds-impact", "role": "process", … }

Le role est renvoyé par GET /v1/kb et GET /v1/kb/{slug}. Une base sans role explicite reste data.

Chaque base est vectorisée par un seul modèle d’embedding, choisi à la création : tous les documents ajoutés, et toutes les questions posées à la base, passent par lui. Il se règle par le champ embed_model de POST /v1/kb, avec l’identifiant d’un modèle d’embedding du catalogue qui rend 1024 dimensions, la dimension des bases.

ValeurModèleHébergeurPrix
bge-embed (défaut)BGE-M3OVHcloud, Gravelines0,012 €/M
mistral-embedMistral EmbedMistral, endpoint UE0,132 €/M
qwen-embedQwen3 EmbedScaleway, Paris0,120 €/M

Les trois acceptent jusqu’à 8 192 tokens par texte (mesuré chez chaque fournisseur ; qwen-embed monte à 32 768), très au-dessus du plafond de 6 000 caractères d’un extrait. Le tokenizer de BGE-M3 est plus économe sur le français que celui de Mistral : le même texte de 27 500 caractères compte 5 753 tokens chez l’un, 7 753 chez l’autre, et c’est le compte de tokens qui est facturé. Deux modèles ne classent pas les résultats à l’identique, mais sur nos bases ils se valent : mesuré le 10 septembre 2026 sur cinq bases et 400 recherches par modèle, le bon document remonte en tête dans 67 % des cas pour l’un comme pour l’autre, et dans le top 10 dans 95 à 96 % des cas ; les désaccords sont symétriques (38 questions où seul Mistral trouve, 36 où seul BGE trouve). Le défaut a changé ce jour-là (mistral-embed auparavant) ; les bases créées avant restent sur leur modèle.

La liste vivante est celle de GET /v1/pricing : les modèles de mode: "embedding" dont dimensions vaut 1024. codestral-embed (1536 dimensions) et les modèles de chat sont refusés avec un 400 qui nomme les modèles admissibles ; rien n’est créé.

Fenêtre de terminal
# Créer une base vectorisée par Mistral Embed plutôt que par le défaut
curl "$BASE" -H "$AUTH" -H "Content-Type: application/json" -d '{
"name": "Comptes rendus 2026",
"embed_model": "mistral-embed"
}'
# → { "slug": "comptes-rendus-2026", "embed_model": "mistral-embed", … }

Le modèle est renvoyé par GET /v1/kb et GET /v1/kb/{slug} (embed_model). Au portail, il se choisit dans la modale « Nouvelle base » (section Vectorisation) ; dans Make et Zapier, c’est le champ Embedding model du module Create a Knowledge Base.

Chaque base porte ses propres réglages de découpage par défaut. Toute ingestion qui ne précise rien les suit : vous réglez le découpage une fois sur la base, au lieu de le répéter à chaque ajout de document.

C’est utile parce que toutes les bases ne se découpent pas de la même façon. Une base de fiches atomiques (CRM, factures, contacts) gagne à garder un document par extrait, alors qu’une base de longs rapports gagne à être découpée en paragraphes.

ChampValeursDéfaut
default_chunking"simple" · "ai" · "whole""simple"
default_chunk_sizeEntier 200-6000, ou nullnull (défaut produit : 800)
default_chunk_modelIdentifiant de modèle de chat, ou nullnull (défaut produit : mistral-small)
ModeCe qu’il fait
simple (défaut)Regroupe les paragraphes jusqu’à atteindre une taille cible (chunk_size). Déterministe et gratuit.
aiUn modèle (chunk_model) réémet le document en extraits, guidés par l’intent de la base et son schéma de facettes. Facturé (un appel modèle par document).
wholeUn document = un extrait. Le texte n’est recoupé que s’il dépasse le plafond technique d’embedding (6000 caractères). Gratuit.

Le mode whole existe pour les bases dont chaque document est déjà une unité de sens complète : une fiche CRM, une facture, un playbook. Le découper en morceaux disperse l’information entre plusieurs extraits qui, pris isolément, perdent leur contexte. Avant, il fallait tenter de forcer ce comportement avec une valeur de chunk_size très élevée, ce qui ne fonctionnait pas (chunk_size est borné à 6000). whole est le réglage explicite qui répond à ce besoin.

Pour chaque ingestion, le réglage effectif se résout dans cet ordre :

  1. le paramètre de l’appel (chunking, chunk_size, chunk_model), s’il est fourni ;
  2. sinon, le défaut de la base (default_chunking, default_chunk_size, default_chunk_model) ;
  3. sinon, le défaut produit (simple, 800 caractères, mistral-small).

Un paramètre passé à l’appel prime donc toujours. Régler un défaut de base ne vous enferme pas : vous gardez la main document par document.

Fenêtre de terminal
# Créer une base dont chaque document reste entier (une fiche = un extrait)
curl "$BASE" -H "$AUTH" -H "Content-Type: application/json" -d '{
"name": "CRM",
"intent": "Une fiche entreprise, contact ou opportunité par document.",
"default_chunking": "whole"
}'
# → { "slug": "crm", "default_chunking": "whole", "default_chunk_size": null,
# "default_chunk_model": null, … }
# Changer les défauts d'une base existante (découpage simple, extraits plus longs)
curl -X PATCH "$BASE/rapports" -H "$AUTH" -H "Content-Type: application/json" -d '{
"default_chunking": "simple",
"default_chunk_size": 1500
}'
# Ingestion SANS paramètre de découpage → la base décide
curl "$BASE/crm/documents" -H "$AUTH" -H "Content-Type: application/json" -d '{
"title": "GreenLoop", "text": "…", "external_id": "acc-greenloop"
}'
# → découpé en "whole" (le défaut de la base « crm »)
# Ingestion AVEC paramètre → l'appel prime, pour ce document seulement
curl "$BASE/crm/documents" -H "$AUTH" -H "Content-Type: application/json" -d '{
"title": "Rapport annuel 2025", "text": "…", "chunking": "simple", "chunk_size": 1000
}'

Ces trois champs sont acceptés à la création (POST /v1/kb) comme en modification (PATCH /v1/kb/{slug}), et renvoyés par GET /v1/kb et GET /v1/kb/{slug}. Les bases créées avant cette fonctionnalité se comportent comme si elles portaient les défauts produit.

Les défauts de la base s’appliquent à toutes les surfaces d’ingestion : ajout de document par API ou depuis le portail, re-découpage d’un document ou de la base entière, outils d’écriture MCP, et synchronisation d’un connecteur.

MéthodeRouteEffet
GET/v1/kb/{slug}/documentsLister les documents
POST/v1/kb/{slug}/documentsAjouter un document — texte (JSON) ou fichier (multipart)
GET/v1/kb/{slug}/documents/{id}Détail
PATCH/v1/kb/{slug}/documents/{id}Modifier (filename, date, external_id, source_url)
GET/v1/kb/{slug}/documents/{id}/contentLire le texte source (déchiffré) + état des chunks
PUT/v1/kb/{slug}/documents/{id}/contentRemplacer le texte source (sans re-découper)
POST/v1/kb/{slug}/documents/{id}/rechunkRe-découper un document depuis sa source (régénère les chunks)
POST/v1/kb/{slug}/rechunkRe-découper toute la base (job de fond, asynchrone)
GET/v1/kb/{slug}/rechunkÉtat du dernier re-découpage de la base
DELETE/v1/kb/{slug}/documents/{id}Supprimer (+ ses chunks)

{id} accepte l’UUID renvoyé ou votre external_id (voir plus bas).

Fenêtre de terminal
# Ajouter du texte : il est découpé, vectorisé puis chiffré
curl "$BASE/faq-support/documents" -H "$AUTH" -H "Content-Type: application/json" -d '{
"title": "Crédits",
"text": "Les crédits se rechargent dans le portail, par carte via Stripe.",
"date": "2024-03-01",
"external_id": "doc-credits",
"source_url": "https://docs.miraca.fr/credits"
}'
# → { "id": "…", "external_id": "doc-credits", "filename": "Crédits",
# "doc_date": "2024-03-01T00:00:00.000Z", "source_url": "https://docs.miraca.fr/credits",
# "status": "ready", "chunk_count": 1 }

Plutôt que de retenir l’UUID interne, attachez votre propre external_id (unique par base) à un document. Vous pouvez alors l’adresser directement (GET/PATCH/DELETE /v1/kb/{slug}/documents/{external_id}), et ré-ingérer le même document de façon idempotente :

Fenêtre de terminal
# Même external_id → le document (et ses chunks) est REMPLACÉ (upsert)
curl "$BASE/faq-support/documents" -H "$AUTH" -H "Content-Type: application/json" -d '{
"text": "Texte mis à jour…", "external_id": "doc-credits"
}'
# Puis : récupérer / modifier / supprimer par cet identifiant
curl "$BASE/faq-support/documents/doc-credits" -H "$AUTH"

Le date est la date du contenu (≠ created_at, la date d’insertion en base). Stockée comme doc_date ; les chunks du document en héritent (chunk_date) sauf date explicite. Format YYYY-MM-DD, ou un instant ISO 8601 complet (2026-03-15T09:30:00+01:00) : la fraction de seconde s’écrit avec un point (09:30:00.5, jamais une virgule) et le décalage va de -15:59 à +15:59. Exploitable à la recherche via date_range (voir plus bas) : un filtre par plage de dates, là où les facettes ne permettent qu’une égalité.

Modifier la date d’un document (PATCH …/documents/{id} avec date) la reporte sur ses chunks qui portaient l’ancienne ; un chunk daté à part garde la sienne. Un re-découpage redonne à tous les chunks la date du document.

Attachez à un document un lien vers la ressource d’origine (page web, fichier dans un drive, ticket…) via source_url. C’est purement informatif : stocké en clair, jamais vectorisé, et remonté tel quel à la recherche directe et dans les sources RAG — pour que votre process puisse retrouver la ressource d’origine d’un extrait. Disponible à l’ajout (texte ou fichier) et modifiable par PATCH (chaîne vide ou null pour le retirer). ⚠️ Seul un lien http(s) est accepté : un schéma différent (javascript:, data:…) est silencieusement remplacé par null avant stockage, garde posée après un constat d’audit (XSS stockée exploitable au clic sur un document cité).

Chaque document conserve son texte source canonique (chiffré, comme les chunks). Les chunks en sont une projection régénérable : vous pouvez relire la source, la modifier, puis re-découper pour régénérer les chunks et leurs embeddings.

Fenêtre de terminal
# Lire le texte source (déchiffré) + l'état des chunks
curl "$BASE/faq-support/documents/doc-credits/content" -H "$AUTH"
# → { "id": "…", "filename": "Crédits", "external_id": null, "source_url": null, "doc_date": null, "has_source": true, "source_format": "markdown",
# "source_origin": "original", "content": "Les crédits se rechargent…",
# "chunk_count": 1, "chunks_stale": false }
# Remplacer le texte source (n'écrit PAS de chunks → ils deviennent « périmés »)
curl -X PUT "$BASE/faq-support/documents/doc-credits/content" -H "$AUTH" -H "Content-Type: application/json" -d '{
"content": "Les crédits se rechargent dans le portail (carte via Stripe) ou par virement.",
"format": "markdown"
}'
# → { …, "chunks_stale": true } ← la source a changé, re-découpez pour régénérer
# Variante « tout-en-un » : remplacer la source ET re-découper dans le même appel
curl -X PUT "$BASE/faq-support/documents/doc-credits/content" -H "$AUTH" -H "Content-Type: application/json" -d '{
"content": "Les crédits se rechargent dans le portail (carte via Stripe) ou par virement.",
"rechunk": "simple"
}'
# → { …, "chunks_stale": false } ← source écrite + chunks régénérés en une requête
# Re-découper depuis la source : régénère les chunks (simple = gratuit ; ai = facturé)
# `chunk_size` (200-6000, défaut 800) règle la taille cible des extraits du découpage simple.
curl -X POST "$BASE/faq-support/documents/doc-credits/rechunk" -H "$AUTH" -H "Content-Type: application/json" -d '{
"chunking": "simple",
"chunk_size": 1200
}'
# → le document (ApiDocument), "chunks_stale": false
  • source_origin : original (source fidèle, captée à l’ingestion ou éditée) ou reconstructed (source recollée depuis les chunks pour un document antérieur — pour un document découpé en IA, ce n’est pas l’original).
  • chunks_stale : true quand la source a été modifiée depuis le dernier découpage — exposé aussi sur le détail du document (GET …/documents/{id}). Re-découpez pour le remettre à jour.
  • POST …/rechunk accepte chunking ("simple", "ai" + chunk_model, ou "whole"), chunk_size (200-6000, défaut 800, taille cible des extraits du découpage simple), facet_overrides (remplace ceux mémorisés à l’ingestion), bypass_conformite. Il remplace TOUS les chunks du document, y compris ceux ajoutés ou édités à la main.
  • chunking_notice : présent dans la réponse d’un ajout, d’un POST …/rechunk ou d’un PUT …/content avec rechunk, en mode ai, quand des passages rendus par le modèle ont été écartés, ou quand son plan a été remplacé par le découpage simple (voir les trois modes de découpage). Re-découper un document en mode ai est aussi la façon de corriger un document découpé avant cette vérification : ses extraits sont régénérés depuis le texte source.
  • PUT …/content accepte une option rechunk ("simple", "ai", "whole", ou true pour suivre le défaut de la base ; + chunk_model, chunk_size, facet_overrides, bypass_conformite) pour écrire la source ET régénérer les chunks en un seul appel (pratique pour l’automatisation). Sans elle, les chunks restent périmés jusqu’au POST …/rechunk.
  • chunking et chunk_size sont aussi acceptés à l’ingestion (POST …/documents, JSON ou champ multipart) pour fixer le découpage du nouveau document.
  • Sans paramètre de découpage, ces trois appels suivent le découpage par défaut de la base. Un paramètre fourni prime toujours.

POST /v1/kb/{slug}/rechunk re-découpe tous les documents de la base depuis leur texte source. Comme l’opération peut porter sur des centaines de documents (et, en ai, appeler un modèle par document), elle s’exécute en job de fond : la requête enfile le travail et répond 202 avec un job_id et l’état initial. Suivez l’avancée avec GET /v1/kb/{slug}/rechunk.

Fenêtre de terminal
# Lancer le re-découpage de toute la base (asynchrone)
curl -X POST "$BASE/faq-support/rechunk" -H "$AUTH" -H "Content-Type: application/json" -d '{
"chunking": "simple",
"chunk_size": 1000
}'
# → 202 { "job_id": "…", "status": { "state": "running", "chunking": "simple", … } }
# Suivre l'avancée (polling)
curl "$BASE/faq-support/rechunk" -H "$AUTH"
# → { "status": { "state": "done", "total": 42, "rechunked": 40, "skipped": 2, "errors": 0, … } }
  • Body : chunking ("simple", "ai" + chunk_model, ou "whole"), chunk_size (mode simple). Sans chunking, la base re-découpe selon son réglage par défaut.
  • Statut : quand des documents ont échoué (errors > 0), first_error donne le motif du premier échec (par exemple un modèle de découpage retiré du catalogue de l’organisation). Les documents en échec gardent leur découpage précédent.
  • Un modèle nommé (default_chunk_model d’une base, chunk_model ou facet_model d’un appel) doit être disponible pour votre organisation (Gestion → Modèles) : sinon la requête est refusée en 400, avant tout appel au modèle.
  • Les documents sans texte source stocké ne sont pas re-découpables → comptés en skipped.
  • Un seul re-découpage à la fois par base : relancer pendant qu’il tourne renvoie 409.
  • Métré : en ai, chaque document appelle le modèle ; l’embedding des chunks est facturé (simple comme ai) sur la dernière clé API créée sur le compte (les clés internes de Miraca — assistant, accès MCP d’un membre, copilote — ne sont jamais retenues).

Le découpage ai renseigne les facettes, mais il réémet le document mot pour mot, donc il plafonne (cf. l’encadré plus haut). Or ce sont deux besoins différents : découper, c’est décider où couper ; qualifier, c’est lire un document et dire de quoi il parle. Le second ne demande aucune réécriture — sa sortie tient en quelques centaines de tokens, quelle que soit la taille de l’entrée.

qualify_facets: true déclenche une passe courte et séparée : un modèle lit le document (par échantillon tête / milieu / fin s’il est long) et n’en renvoie que les valeurs de facettes, appliquées ensuite à tous les extraits. Combinez-la avec chunking: "simple" pour obtenir des métadonnées qualifiées à n’importe quelle taille de document.

Fenêtre de terminal
curl "$BASE/comptes-rendus/documents" -H "$AUTH" -H "Content-Type: application/json" -d '{
"title": "Comité budgétaire 12 mars",
"text": "…un transcript de 120 000 caractères…",
"chunking": "simple",
"qualify_facets": true
}'
ChampEffet
qualify_facetstrue = lance la passe de qualification.
facets_fromTexte court à lire à la place du document (un résumé, un chapeau). Quand vous en disposez, c’est plus fiable qu’un échantillon : il est déjà dense.
facet_modelModèle de la passe (facturé sur votre clé). Défaut : le modèle de découpage par défaut.
  • Les facet_overrides que vous fournissez priment toujours sur les valeurs déduites, et les facettes ainsi fixées ne sont même pas soumises au modèle.
  • Le modèle omet une facette qu’il ne peut pas trancher plutôt que d’inventer une valeur : une facette fausse filtrerait des recherches, une facette absente non.
  • Les valeurs déduites sont mémorisées sur le document, donc réappliquées à un re-découpage simple ultérieur.
  • qualify_facets avec chunking: "ai" est refusé : le découpage IA renseigne déjà les facettes, extrait par extrait.
  • Sur une base sans schéma de facettes, l’appel est refusé : il n’y aurait rien à renseigner.

Disponible aussi dans la synchronisation d’un connecteur (sync.qualify_facets), pour qualifier une source dont les enregistrements sont trop longs pour le découpage IA.

Passez facet_overrides pour imposer des valeurs de facettes sur tous les chunks du document, sans laisser l’IA les deviner :

Fenêtre de terminal
# Découpage simple : catégorie fixée, l'IA n'intervient pas
curl "$BASE/faq-support/documents" -H "$AUTH" -H "Content-Type: application/json" -d '{
"title": "CR réunion 15 jan.",
"text": "…",
"external_id": "cr-2025-01-15",
"facet_overrides": { "categorie": "compte-rendu", "client": "Acme" }
}'
# Découpage IA : l'IA ne remplit que les facettes non couvertes (ex. "date_reunion")
curl "$BASE/faq-support/documents" -H "$AUTH" -H "Content-Type: application/json" -d '{
"title": "CR réunion 15 jan.",
"text": "…",
"chunking": "ai",
"facet_overrides": { "categorie": "compte-rendu" }
}'
ComportementDétail
Mode simpleLes overrides sont appliqués à chaque chunk, les facettes restantes restent {}.
Mode aiL’IA ne voit que les facettes non couvertes (tokens économisés) et sa sortie est fusionnée ; les overrides écrasent toujours ce qu’elle aurait produit.
PrioritéLes overrides gagnent sur les valeurs IA et sur les default du schéma.
FormatObjet JSON ou chaîne JSON (pour les clients no-code comme Make). Une facette multiple reçoit une liste.
ValidationUne valeur hors des values déclarées, ou une liste sur une facette à valeur unique, est refusée en 400 avec le détail.

Le même endpoint accepte un fichier en multipart/form-data (champ file). Le texte est extrait côté serveur puis découpé, vectorisé et chiffré comme un document texte. Le binaire n’est pas conservé.

Fenêtre de terminal
# Importer un PDF avec facettes forcées
curl "$BASE/faq-support/documents" -H "$AUTH" \
-F "file=@guide.pdf" \
-F "title=Guide d'installation" \
-F "source_url=https://drive.exemple.org/guide.pdf" \
-F 'facet_overrides={"categorie":"guide"}' \
-F "bypass_conformite=false"
# → { "id": "…", "filename": "Guide d'installation", "source_type": "file", "status": "ready", "chunk_count": 12 }

Formats : PDF, DOCX, TXT, MD, RTF, CSV, TSV, HTML (max 15 Mo). Le DOCX et le HTML sont convertis en Markdown (titres, listes et tableaux préservés), ce qui améliore le découpage (notamment le découpage IA) et l’extraction de facettes ; le PDF est extrait en texte à plat. Un .doc (Word legacy) ou un PDF scanné (image, sans couche texte) est refusé (415/422) — convertissez-le d’abord. Le champ title est optionnel (par défaut : le nom du fichier).

MéthodeRouteEffet
GET…/documents/{id}/chunksLister les extraits (déchiffrés, avec date et created_at)
POST…/documents/{id}/chunksAjouter un extrait à la main (content, facets?, date?)
PATCH…/chunks/{chunkId}Modifier (content, facets et/ou date)
DELETE…/chunks/{chunkId}Supprimer

{id} du document = UUID ou external_id. Le date d’un extrait est optionnel : s’il est absent, l’extrait hérite de la date du document.

Fenêtre de terminal
# Ajouter un extrait précis, avec ses facettes et sa date métier
curl "$BASE/faq-support/documents/doc-credits/chunks" -H "$AUTH" -H "Content-Type: application/json" -d '{
"content": "Q : Quels paiements ? R : Carte bancaire et virement SEPA.",
"facets": { "theme": "facturation" },
"date": "2024-03-01"
}'

Modifier le content d’un chunk le re-vectorise (facturé comme un embedding).

POST /v1/search cherche dans toutes vos bases à la fois (au lieu d’une seule), sans IA, et renvoie les documents correspondants. Pour aussi chercher dans une source externe (Airtable, Notion, Pennylane…), synchronisez le connecteur dans une base (cf. Connecteurs API) : ses enregistrements deviennent des documents et sont couverts par cette même recherche.

Deux régimes, via mode :

  • relevance (défaut si query) : classement par pertinence sémantique. L’embedding de la question est facturé. Pagination superficielle (top_k), pas de total.
  • browse (défaut sans query) : exploration — facettes + tri + recherche par titre (le texte query filtre alors le titre du document), avec pagination profonde (limit/offset) et total exact (nombre de documents, pas d’extraits). Aucun embedding facturé. C’est le régime « Algolia interne » : lister, filtrer, trier, paginer un jeu de documents.
Fenêtre de terminal
# Pertinence : classement sémantique
curl "https://api.miraca.fr/v1/search" -H "$AUTH" -H "Content-Type: application/json" -d '{
"query": "facture impayée Greenloop",
"knowledge_base": ["factures", "crm"]
}'
:::caution[Un filtre mal formé est REFUSÉ, pas ignoré]
`filters` attend une **liste** de `{ key, op, value }` ; `facets` attend un **objet**
`{ "cle": "valeur" }` (égalité simple). Intervertir les deux — ou employer un opérateur
incompatible avec le type de la facette — renvoie une **erreur `400`** qui nomme le problème.
C'est délibéré : auparavant le filtre était écarté et la recherche répondait `200` avec
**tous** les documents. Une réponse complète à une question filtrée est le pire résultat
possible — elle est plausible, et rien ne signale qu'elle répond à autre chose.
Les facettes **non déclarées au schéma** restent filtrables : elles sont acceptées à
l'écriture, donc elles doivent l'être à la lecture. En revanche, une clé qu'**aucun**
enregistrement de la base ne porte est refusée elle aussi (`400`, avec la liste des facettes
réelles) : un filtre sur une facette qui n'existe nulle part ne peut rien attraper, et le
`total: 0` qu'il rendrait se lit comme « aucune facture » alors qu'il ne dit rien.
:::
# Exploration : facettes + tri + pagination profonde
curl "https://api.miraca.fr/v1/search" -H "$AUTH" -H "Content-Type: application/json" -d '{
"mode": "browse",
"knowledge_base": ["factures"],
"filters": [{ "key": "statut", "op": "in", "value": ["impayée"] }],
"sort": { "by": "date", "dir": "desc" },
"limit": 25,
"offset": 50
}'
{
"query": "",
"mode": "browse",
"searched": ["factures"],
"total": 312,
"offset": 50,
"page_size": 25,
"has_more": true,
"results": [ { "knowledge_base": "factures", "document": "F-2023-6051 — GreenLoop", "external_id": "F-2023-6051", "date": "2023-04-11", "source_url": null, "matched_chunks": 3, "snippet": "…", "score": null } ],
"sources": [ { "slug": "factures", "hits": 25 } ]
}
  • knowledge_base (slug ou tableau) : bases à interroger ; absent = toutes celles du compte.
  • filters : mêmes filtres de facettes typés que la recherche par base (eq/neq/gt/gte/lt/lte/in/contains), en liste ou en chaîne JSON d’une liste (clients no-code). Toute autre forme (un objet, un texte) renvoie une erreur 400, dans les deux régimes.
  • date_range : { "from"?, "to"? }, bornes en AAAA-MM-JJ. Appliqué dans les deux régimes ; une borne dans un autre format renvoie une erreur 400.
  • skipped (réponse, mode relevance) : les bases écartées de cette recherche, chacune avec sa raison, par exemple { "slug": "crm", "reason": "ne déclare pas la facette « statut »" }. Une base qui ne déclare pas une facette filtrée est écartée, de même qu’une base où le filtre ne peut pas s’appliquer ou qui n’a pas répondu. Le champ est absent quand aucune base n’a été écartée. Une liste results vide accompagnée d’un skipped ne veut donc pas dire « rien ne correspond ». Un filtre refusé par toutes les bases interrogées renvoie une erreur 400, avec le motif.
  • sort (mode browse) : { "by": "date" | "alpha", "dir": "asc" | "desc" } (alpha = titre). Ignoré en pertinence (classé par score).
  • top_k (mode relevance, défaut 10, max 50) ; limit (mode browse, défaut 25, max 100) + offset (mode browse, pagination profonde).
  • total : nombre exact de documents (mode browse) ; has_more indique s’il reste des pages.
  • Compteurs au grain document : les facettes (/v1/search/facets ci-dessous) comptent des documents, pas des extraits internes.
  • Facettes cross-bases : POST /v1/search/facets (facettes filtrables, partagées d’abord) et POST /v1/search/facet-values ({ "key": "...", "query": "..." } → valeurs contenant le texte, sans tenir compte de la casse ni de la typographie).

Note (chiffrement) : le corps des documents est chiffré au repos → pas de recherche plein-texte par mots-clés sur le contenu. L’exploration recherche par titre/external_id (en clair), et cette recherche est tolérante aux fautes (« recrutment » retrouve « recrutement »). Pour chercher dans le contenu, utilisez le mode pertinence (sémantique), lui aussi tolérant aux fautes (embeddings).

Interroger une base sans passer par un modèle : utile pour intégrer la recherche dans vos propres workflows. Renvoie les extraits les plus proches, déchiffrés, avec leur score.

Fenêtre de terminal
curl "$BASE/faq-support/search" -H "$AUTH" -H "Content-Type: application/json" -d '{
"query": "comment payer",
"facets": { "theme": "facturation" },
"top_k": 5
}'
{
"group_by": "chunk",
"data": [
{
"n": 1,
"knowledge_base": "faq-support",
"document": "Crédits",
"document_id": "a1b2c3d4-…",
"external_id": "doc-credits",
"source_url": "https://docs.miraca.fr/credits",
"score": 0.87,
"facets": { "theme": "facturation" },
"content": "Q : Quels paiements ? R : Carte bancaire et virement SEPA.",
"relation": "semantic"
}
]
}

La réponse contient toujours group_by (granularité utilisée) et data.

Avec une query, les extraits sont retrouvés par proximité sémantique, puis les 20 premiers sont reclassés en lisant la question et les extraits ensemble, ce qui écarte mieux un extrait qui partage des mots avec la question sans y répondre. Leur score (de 0 à 1) est celui de ce reclassement. Au-delà de ces 20 premiers, les extraits suivent dans l’ordre de proximité, avec "score": null : les deux mesures ne sont pas sur la même échelle, et un tri par score les mélangerait. Le rang n donne l’ordre de toute la liste, et le reclassement porte toujours sur les mêmes 20 extraits, quelle que soit la page demandée (offset). Si le reclassement est indisponible, tous les extraits portent leur score de proximité (cosinus, de 0 à 1).

query est facultative. Sans requête, la base bascule en mode listing : pas de recherche sémantique, pas de score ("score": null), aucun coût d’embedding — uniquement le filtrage (facets, filters, date_range) et le tri. Idéal pour « tous les documents tels que … » et pour compter / lister de façon exacte (le listing est déterministe, là où la recherche sémantique est approximative et plafonnée).

Chaque résultat porte aussi l’external_id du document (votre identifiant client, s’il est renseigné) à côté de l’UUID interne.

Quand vous connaissez la référence métier d’un document (n° de facture, id CRM, id de note… c’est-à-dire votre external_id), ne partez pas en recherche sémantique approximative : passez external_id pour restreindre la recherche aux documents correspondants (une chaîne, ou un tableau). Combinable avec les autres paramètres.

Fenêtre de terminal
# Retrouver un document précis par sa référence, et lire ses extraits
curl "$BASE/factures/search" -H "$AUTH" -H "Content-Type: application/json" -d '{
"external_id": "F-2025-089"
}'
# Plusieurs références d'un coup
curl "$BASE/factures/search" -H "$AUTH" -H "Content-Type: application/json" -d '{
"external_id": ["F-2025-089", "F-2025-090"], "group_by": "document"
}'

Pour lire le contenu entier d’un document dont vous avez l’external_id, vous pouvez aussi appeler directement GET /v1/kb/{slug}/documents/{external_id} (l’UUID n’est pas nécessaire).

Pour retrouver un document par son nom/titre (et non par le sens de son contenu), passez title_contains : recherche sous-chaîne sur le titre (filename) et l’external_id, tolérante aux fautes (« recrutment » retrouve « recrutement ») et, pour le titre, à la casse et à la typographie : un titre importé depuis macOS, dont les accents sont enregistrés sous une autre forme, se retrouve en le tapant normalement. Se combine aux filtres, au tri, à la pagination et au total analytique. Le contenu (chiffré) reste couvert par query (sémantique).

Fenêtre de terminal
# Documents dont le titre ressemble à « recrutement », comptés au grain document
curl "$BASE/rh/search" -H "$AUTH" -H "Content-Type: application/json" -d '{
"title_contains": "recrutment", "group_by": "document"
}'

top_k est plafonné à 50. En régime analytique (sans query), le défaut est d’ailleurs 50 (on veut généralement tout l’ensemble, pas un top-k). Pour parcourir un ensemble plus grand de façon exhaustive, utilisez offset (mode listing surtout) : offset: 0, puis 50, 100… jusqu’à recevoir moins de top_k résultats.

La réponse porte truncated: true quand la page ne contient pas tout l’ensemble filtré (total > nombre de lignes renvoyées) : ne concluez jamais « tous les X » sur une page tronquée — paginez (ou lisez total pour un simple comptage). Pour lister les valeurs distinctes d’une facette sous condition (toutes les entités concernées), GET …/facets avec le filtre en scope les renvoie toutes d’un coup, sans pagination.

Granularité : extraits, documents ou dates (group_by)

Section intitulée « Granularité : extraits, documents ou dates (group_by) »
  • "chunk" (défaut) : un résultat par extrait.
  • "document" : les extraits d’un même document sont regroupés (le document n’apparaît qu’une fois) et classés par score moyen des extraits trouvés. Chaque résultat porte document_id, matched_chunks (nombre d’extraits trouvés) et chunks (les extraits). C’est la granularité pour COMPTER ou LISTER des enregistrements (réunions, factures, dossiers… — un enregistrement = un document). En régime analytique (sans query), la réponse porte total = le compte exact de documents correspondant aux filtres, calculé côté serveur indépendamment de top_k/offset : pour « combien », lisez total (inutile de paginer). data reste plafonné à top_k (la page de résultats).
  • "date" : un résultat par date métier distincte (chunk_date), pour les questions par jour / chronologiques (combien de jours d’activité, occurrences par date). ⚠️ Plusieurs documents peuvent tomber le même jour : data.length = nombre de dates, ce qui sous-compte les enregistrements. La réponse expose donc, au niveau racine, distinct_documents (= nombre d’enregistrements, ≥ nombre de dates) et, par jour, documents (documents distincts ce jour-là), plus total_dates (nombre de jours, analytique). Pour « combien de rendez-vous avec X en 2025 », utilisez group_by: "document" et lisez total (ou distinct_documents en mode date), jamais le nombre de dates.
Fenêtre de terminal
curl "$BASE/faq-support/search" -H "$AUTH" -H "Content-Type: application/json" -d '{
"query": "comment payer",
"group_by": "document",
"top_k": 5
}'
{
"group_by": "document",
"data": [
{
"n": 1,
"knowledge_base": "faq-support",
"document": "Crédits",
"document_id": "a1b2c3d4-…",
"score": 0.86,
"matched_chunks": 2,
"chunks": [ { "n": 1, "content": "…", "score": 0.87, "facets": {} } ]
}
]
}

En group_by: "date", la réponse porte distinct_documents (le nombre d’enregistrements) à côté des dates — ici 12 jours mais 13 réunions, car deux ont eu lieu le même jour :

{
"group_by": "date",
"total_dates": 12,
"distinct_documents": 13,
"data": [
{ "n": 1, "date": "2025-05-21", "documents": 2, "matched_chunks": 8, "facets": { … }, "chunks": [ … ] }
]
}

État courant par entité (group_by: { facet, latest } + having)

Section intitulée « État courant par entité (group_by: { facet, latest } + having) »

Pour les questions du type « combien de X sont actuellement Y » ou « le dernier état de chaque X » (X = une entité : startup, client, fournisseur… ; Y = un état : en alerte, en retard, churné…), passez un group_by objet : la base réduit à un résultat par entité, ramené à son enregistrement le plus récent. La condition d’état courant se pose avec having (≠ filters, voir l’encart) :

Fenêtre de terminal
curl "$BASE/suivi-participations/search" -H "$AUTH" -H "Content-Type: application/json" -d '{
"group_by": { "facet": "startup", "latest": true },
"having": { "key": "alerte", "op": "eq", "value": true }
}'
{
"group_by": "entity",
"entity_field": "startup",
"total": 11,
"data": [
{ "entity": "AgriBoost", "date": "2025-06-02", "facets": { "startup": "AgriBoost", "statut": "…" } }
]
}
  • total = nombre d’entités distinctes dont le dernier enregistrement satisfait having — exact, indépendant de top_k.
  • aggregate se combine : aggregate: "mrr_keur" donne alors la valeur d’état courant agrégée (ex. le MRR moyen actuel du portefeuille = moyenne du dernier MRR de chaque startup).

En plus de facets (égalité simple), filters permet des comparaisons sur les valeurs de facettes, validées contre le type déclaré de chaque facette (cf. facet_schema) :

Fenêtre de terminal
curl "$BASE/factures/search" -H "$AUTH" -H "Content-Type: application/json" -d '{
"group_by": "document",
"filters": [
{ "key": "montant", "op": "gte", "value": 1000 },
{ "key": "echeance", "op": "lt", "value": "2026-06-15" },
{ "key": "categorie", "op": "in", "value": ["A", "B"] }
],
"sort": { "by": "date", "dir": "desc" }
}'

Opérateurs : eq, neq, gt, gte, lt, lte, in, contains. Les opérateurs disponibles dépendent du type de la facette — nombre/date : comparaisons ; texte : eq/neq/in/ contains ; booléen : eq/neq/in. Un filtre incohérent (ex. > sur un booléen) ou une valeur qui ne se lit pas dans le type de la facette est refusé (erreur 400 qui nomme le problème), jamais ignoré. Les dates sont au format ISO AAAA-MM-JJ, et doivent exister au calendrier.

Nombre écrit en chaîne. Une valeur numérique peut être passée en nombre JSON (1250) ou en chaîne. En chaîne, la convention est française : la virgule est décimale ("1,250" vaut 1,25, jamais mille deux cent cinquante) et une espace sépare les milliers ("1 250" vaut 1250, que l’espace soit ordinaire ou insécable). Une chaîne vide est refusée : elle n’est plus lue comme zéro. Pour éviter toute ambiguïté, préférez un nombre JSON.

Nom de la facette. La clé d’un filtre (key), d’un facets, d’un group_by, d’un aggregate ou d’un having est rapprochée de celles du schéma : "statut" désigne la facette déclarée Statut. Si deux facettes du schéma ne diffèrent que par la casse ou la typographie, la demande est refusée et les nomme : il faut alors écrire la clé exacte.

Pour les facettes texte, l’égalité (eq/neq/in), contains, le filtre rapide facets (une valeur ou une liste) et la recherche de valeurs de facette comparent deux textes sans tenir compte :

  • de la casse : {"type_rdv": "Gestion de crise"} correspond à la valeur stockée gestion de crise ;
  • de la forme des accents (composée ou décomposée, comme dans les noms de fichiers de macOS) ;
  • des espaces : insécables ou ordinaires, doublées, en début ou en fin ;
  • de la forme des apostrophes, des guillemets et des tirets (droits ou typographiques) ;
  • des caractères invisibles (espace de largeur nulle, trait d’union conditionnel).

Ce n’est pas une recherche approximative : une lettre, un chiffre ou un accent de différence donnent deux valeurs distinctes (été n’est pas ete, Cœur n’est pas Coeur). Les facettes nombre, booléen et date restent en comparaison exacte.

À l’écriture par l’API (documents, extraits, synchronisation d’un connecteur, découpage par IA), une valeur de facette texte est rangée dans une forme stable : accents composés, espaces repliées, bords coupés. La casse, les apostrophes et les guillemets sont conservés tels que fournis. Un texte de plusieurs lignes garde ses retours à la ligne.

Sur une facette à plusieurs valeurs, eq, in et contains trouvent les documents qui portent au moins une valeur correspondante ; neq ceux qui n’en portent aucune.

Un fragment contains sans caractère visible (des espaces, un caractère invisible) ne trouve rien, et son négatif not_contains (mode query) ne retire aucun document. Il en va de même pour starts_with.

Pour sommer / moyenner une facette numérique (« montant moyen des factures », « total facturé », « MRR cumulé »), passez aggregate = le nom d’une facette numérique, ou une liste de noms. La réponse porte un champ aggregates : un objet par champ avec count, sum, avg, min, max, calculés exactement côté serveur et par enregistrement (un document = une valeur ; une facette dénormalisée sur plusieurs extraits n’est comptée qu’une fois), indépendamment de top_k/pagination. Régime analytique uniquement (sans query).

Fenêtre de terminal
curl "$BASE/factures/search" -H "$AUTH" -H "Content-Type: application/json" -d '{
"facets": { "sens": "client", "type_piece": "facture" },
"aggregate": "montant_ttc_eur"
}'
{
"group_by": "chunk",
"total": 122,
"aggregates": [
{ "field": "montant_ttc_eur", "count": 122, "sum": 435155.91, "avg": 3566.85, "min": 484.13, "max": 12240 }
],
"data": []
}

count = nombre d’enregistrements ayant une valeur numérique (le dénominateur de avg). Un nom non numérique ou inconnu du schéma est refusé (erreur 400 qui liste les facettes numériques de la base) : auparavant il était ignoré et son agrégat disparaissait de la réponse sans explication. N’additionnez jamais les montants à la main : lisez aggregates.

aggregate rend une somme globale ; group_by: {facet} rend l’état courant par entité. Aucun des deux ne répond à « les trois clients les plus facturés en 2024 » : il faut une somme par groupe, triée. mode: "query" donne l’algèbre complète en un appel, filtrer → grouper → agréger → having → trier → borner :

{
"mode": "query",
"facets": { "exercice": "2024" },
"group_by": "structure",
"aggregate": "montant_ht",
"sort": "montant_ht:sum",
"limit": 3
}
{
"group_by": "group",
"group_fields": ["structure"],
"total": 40,
"data": [
{ "group": { "structure": "Passerelle Emploi" }, "count": 7,
"aggregates": [{ "field": "montant_ht", "count": 7, "sum": 179558.38, "avg": 25651.20, "min": 3210.5, "max": 44100 }] }
]
}
  • group_by : une facette, une liste (["structure","exercice"] = deux axes), et/ou une tranche de date : { "date": "month" } (day, month, quarter, year), combinable avec des facettes : { "facets": ["secteur"], "date": "quarter" }.
  • aggregate : facette(s) numérique(s) ; chaque groupe rend {count, sum, avg, min, max}, exacts, calculés en base sur tous ses enregistrements. Sans aggregate, un count par groupe.
  • having : filtre sur les agrégats — { "metric": "montant_ht:sum", "op": "gte", "value": 10000 } (métriques : count ou <facette>:sum|avg|min|max).
  • sort : "count", "group" (par clé), ou "<facette>:sum|avg|min|max" ; {by, dir}. Défaut : première somme décroissante.
  • limit / offset : pagination des groupes ; total = nombre exact de groupes.
  • filters accepte, en plus des opérateurs ci-dessus : not_in, not_contains, starts_with, regex (motif borné à 200 caractères), between ([a, b], bornes incluses), exists / missing (sans valeur), before / after (dates, synonymes de lt / gt).
  • text : filtre sur le contenu des extraits. Le texte est chiffré au repos, donc ce filtre s’applique après déchiffrement à l’ensemble déjà réduit par les facettes — une chaîne (contient), ou { "contains", "not_contains", "regex" }. Borné à 2 000 extraits ; text_truncated: true au-delà.
  • query (sémantique) se combine : les agrégats portent alors sur les extraits retrouvés, et la réponse le dit (aggregates_scope: "retrieved"). Sans query, sur toute la base.
  • Sans group_by : group_by: "rows", avec total, aggregates globaux et une page de lignes réduites à leurs facettes (jamais leur contenu).
  • Grouper sur une facette à plusieurs valeurs place chaque document dans le groupe de chacune de ses valeurs : multi_valued_groups le signale, et la somme des groupes n’est plus le total.

Une demande mal formée est refusée avec la raison (facette de groupement inconnue, facette de filtre qu’aucun enregistrement ne porte, opérateur incompatible avec le type, facette non numérique agrégée) et jamais écartée en silence : un groupement perdu rendrait un total global lu comme un total par groupe, un filtre sur une facette inexistante un zéro lu comme une valeur.

Ce que cette algèbre ne fait pas, et qui relève de votre code (ou de l’exécution de code côté agent) : joindre deux bases, comparer deux périodes, croiser avec un connecteur.

{ "by": …, "dir": "asc" | "desc" } — by vaut :

  • "relevance" (défaut avec query) : par score décroissant. Exige une requête.
  • "date" : par date métier (chunk_date), valeurs absentes en dernier.
  • "created" : par date d’insertion en base (created_at) — l’ordre d’ajout, indépendant de la date métier.
  • "alpha" : par nom de document.

Sans query, relevance retombe automatiquement sur date desc.

Deux options avancées, identiques à celles du RAG :

  • date_range { from?, to? } : ne retient que les extraits dont la date métier (chunk_date) tombe dans la plage (les extraits sans date sont alors écartés). Chaque borne s’écrit AAAA-MM-JJ (ou un instant ISO 8601 complet). Une borne dans un autre format (01/09/2026, septembre 2026), une liste ou d’autres noms de bornes renvoient une erreur 400 : la plage n’est jamais ignorée, ni interprétée au hasard.
  • expand (requête uniquement) : en plus des hits sémantiques, ramène les extraits reliés par facettes (relation métier). Champs : seeds (graines, défaut 3), facets (clés de match ; omis = toutes), match ("all"/"any"), depth (niveaux, défaut 1), max_chunks (plafond, défaut 30, max 200).
Fenêtre de terminal
curl "$BASE/crm/search" -H "$AUTH" -H "Content-Type: application/json" -d '{
"query": "point sur le dossier",
"date_range": { "from": "2024-01-01" },
"expand": { "seeds": 3, "facets": ["client"], "depth": 1 }
}'

Les extraits ramenés par relation portent "relation": "facet" (et leur level) ; les hits sémantiques portent "relation": "semantic". ⚠️ L’expansion ajoute des extraits (donc des tokens en aval) : max_chunks borne le total ; match: "any" + depth > 1 font grossir l’arbre vite.

Une base entière s’exporte en bundle autonome (un fichier .kb.json.gz : métadonnées, documents, extraits déchiffrés et vecteurs embarqués), puis se réimporte ailleurs — autre compte, autre environnement, ou simple sauvegarde. Comme les vecteurs voyagent dans le bundle, l’import est instantané et gratuit : aucun ré-embedding, aucun coût.

Les vecteurs du bundle sont ceux du modèle d’embedding, pas ceux rangés dans la base (chaque base les transforme par sa propre clé, cf. Bases de connaissance) : un bundle se réimporte donc dans n’importe quelle base, qui les transforme à son tour.

Fenêtre de terminal
# Exporter → fichier gzip (le corps EST le .gz, ne pas le décompresser en transit)
curl -L "$BASE/faq-support/export" -H "$AUTH" -o faq-support.kb.json.gz
# Sans les vecteurs : bundle bien plus léger, ré-importable tel quel
curl -L "$BASE/faq-support/export?vectors=0" -H "$AUTH" -o faq-support.kb.json.gz
# Importer → crée une base NEUVE (slug auto-dédupliqué, jamais d'écrasement)
curl "$BASE/import" -H "$AUTH" \
-H "Content-Type: application/gzip" --data-binary @faq-support.kb.json.gz
# → { "slug": "faq-support-2", "document_count": 12, "chunk_count": 240, … }

L’export est lui aussi streamé : le bundle n’est jamais construit en entier en mémoire, quelle que soit la taille de la base. La réponse arrive donc en transfert « chunked », sans Content-Length — un client HTTP le gère, mais une barre de progression ne pourra pas afficher de pourcentage.

L’envoi en corps brut (Content-Type: application/gzip ou application/json, --data-binary) est streamé de bout en bout : le bundle n’est jamais chargé en entier en mémoire, ce qui permet d’importer de grosses bases (plusieurs dizaines de milliers de documents) sans risque de saturation. Un envoi multipart/form-data (champ file) reste accepté (pratique depuis un formulaire). Nom et slug de la base créée sont repris du bundle ; on peut les surcharger via ?name= / ?slug= (ou les champs name / slug du formulaire).

PointDétail
CibleL’import crée toujours une base neuve — il ne fusionne ni n’écrase une base existante.
VecteursEmbarqués (base64 float32). Un extrait sans vecteur (bundle fabriqué à la main, ou export en ?vectors=0) est ré-embeddé à l’import, facturé sur la clé qui appelle l’import.
?vectors=0Exporte sans les vecteurs. Ils représentent l’essentiel du poids d’un bundle (sur une base de 16 000 documents : 894 Mo sur 930). À réserver aux sauvegardes et aux transferts : l’import recalcule les vecteurs, ce qui prend du temps et est facturé.
DimensionLe modèle d’embedding et la dimension (1024) doivent correspondre, sinon l’import est rejeté. La base importée reprend le collection.embed_model de l’en-tête du bundle (mistral-embed s’il manque).
TailleBundle plafonné à 200 Mo (compressé). Import streamé (mémoire bornée) et par lots. Atomique : en cas d’erreur, aucune base partielle n’est créée.
ConfidentialitéLe bundle contient votre texte en clair : traitez-le comme une donnée sensible. À l’import, une clé de chiffrement neuve est générée pour la nouvelle base.
Lien sourceComme à l’ajout d’un document (voir « Lien source » plus haut), un source_url qui n’est pas un lien http(s) est remplacé par null à l’import.

Au portail, ces actions sont les boutons « Exporter » (page d’une base) et « Importer » (liste des bases).

Le facet_schema déclare la structure des facettes (et l’enum des valeurs autorisées pour les facettes texte), mais pas les valeurs réellement présentes dans une base. Cette route les expose — pratique pour construire une UI de filtres ou laisser un agent découvrir sur quoi filtrer :

Fenêtre de terminal
curl "$BASE/factures/facets" -H "$AUTH"
{
"facets": [
{ "key": "theme", "type": "text", "declared_values": ["facturation","securite"],
"values": [ {"value":"facturation","count":2}, {"value":"securite","count":1} ],
"distinct_count": 2, "truncated": false },
{ "key": "montant", "type": "number", "min": 50, "max": 4200, "distinct_count": 3 },
{ "key": "echeance", "type": "date", "min": "2024-03-01", "max": "2026-06-15", "distinct_count": 3 }
]
}
  • text / boolean (et personne / email / url, qui sont du texte) : values = valeurs présentes triées par fréquence (count), plafonnées (cap 50) → truncated: true au-delà ; distinct_count = nombre total de valeurs distinctes ; declared_values rappelle l’enum du schéma s’il existe.
  • number / date : min, max et distinct_count (pas la liste complète).
  • Couvre toutes les clés rencontrées, y compris des facettes hors schéma.
  • Une facette à plusieurs valeurs est éclatée : chaque valeur a son propre count.
  • Gratuit (agrégation, pas d’embedding) ; le texte des extraits reste chiffré (seules les facettes, en clair, sont agrégées).

Restreindre à un sous-ensemble (scope) — superlatifs exacts

Section intitulée « Restreindre à un sous-ensemble (scope) — superlatifs exacts »

Ajoutez un scope en query params (JSON encodé) — facets, filters, date_range — pour n’agréger que les extraits correspondants. Les min/max portent alors sur ce filtre : c’est la façon exacte et déterministe de répondre à un superlatif numérique (« le MRR le plus élevé de X », « le montant max de la catégorie A »), sans scanner les extraits.

Fenêtre de terminal
# min/max d'une facette numérique pour une entité donnée
curl "$BASE/factures/facets?facets=%7B%22startup%22%3A%22ReFarm%22%7D" -H "$AUTH"
# → { "documents": 24, "facets": [ { "key": "mrr_keur", "type": "number", "min": 18, "max": 127, … }, … ] }

Avec un scope, la réponse porte documents : le nombre de documents qu’il contient.

  • Un scope qui ne contient aucun document répond { "documents": 0, "facets": [], "note": "…" }. Auparavant la réponse listait toutes les facettes déclarées avec min et max à null, ce qui se lisait comme une absence de donnée alors que c’est la valeur filtrée qui ne correspond à rien.
  • Un scope mal formé (JSON illisible, opérateur inconnu, valeur qui ne se lit pas, date dans un autre format, facette qu’aucun enregistrement ne porte) renvoie une erreur 400. Auparavant il était écarté et la réponse portait sur toute la base.
  • Sans scope, la réponse est inchangée (pas de champ documents).
  • Facturation : les opérations qui vectorisent (ajout de texte/chunk, modification de contenu, recherche) coûtent un embedding. Le reste du CRUD (lister, renommer, supprimer, créer une base, lister les valeurs de facettes) est gratuit.
  • Sécurité : le texte des extraits est chiffré au repos (une clé par base) ; il n’est déchiffré que pour vous répondre.
  • Codes : 401 clé invalide · 404 base/document/chunk introuvable · 400 requête invalide.