RAG

RAG et function calling sur nos agents : leur donner de la mémoire externe et des mains

ShareXLinkedIn

RAG et function calling sur nos agents : leur donner de la mémoire externe et des mains

Un agent persona dans PulseLABS, jusqu'à récemment, ne savait que ce qu'on lui avait écrit dans son system prompt et ce qu'il avait accumulé dans sa mémoire via les tags <MEMORY>. Pour tout le reste — vos docs produit, votre politique de remboursement, le statut d'une commande en base — il fallait soit tout recopier dans le prompt à la main, soit accepter qu'il invente.

On a fermé ce trou avec deux features qui se ressemblent dans la forme (un objet à enregistrer, un lien vers un agent, un flag à activer dans le chat) mais qui répondent à deux besoins différents :

  • Knowledge Base : l'agent peut lire — retrouver les passages pertinents dans des documents que vous lui avez confiés.
  • Tools : l'agent peut agir — appeler un de vos endpoints HTTP pour récupérer une donnée live ou déclencher quelque chose.

Les deux sont opt-in, par appel, et n'ajoutent rien au system prompt de base si on ne les active pas.


Knowledge Base : du RAG sans vector store externe

Pas de Pinecone, pas de Weaviate. On a deux tables Postgres et pgvector... non, pas même ça — on stocke les embeddings comme Float[] brut dans Prisma et on calcule la similarité cosinus en mémoire, côté Node.

model KnowledgeSource {
  id          String           @id @default(cuid())
  workspaceId String
  name        String
  chunks      KnowledgeChunk[]
  agents      AgentPersona[]   @relation("AgentKnowledgeSources")
}

model KnowledgeChunk {
  id        String   @id @default(cuid())
  sourceId  String
  content   String
  embedding Float[]  // vecteur 768 dimensions
}

À l'ingestion, le texte brut est découpé en chunks de 1200 caractères avec 150 caractères de chevauchement — assez pour ne pas couper une idée en deux au milieu d'une phrase :

const CHUNK_SIZE = 1200
const CHUNK_OVERLAP = 150

Chaque chunk est embeddé une fois, à l'ingestion. Pour l'embedding, on réutilise la même logique multi-provider que pour le reste de l'IA : text-embedding-3-small d'OpenAI (tronqué à 768 dimensions) si une clé OpenAI est configurée, sinon nomic-embed-text d'Ollama en local — qui produit nativement du 768. Le code de retrieval ne sait pas, et n'a pas besoin de savoir, lequel des deux a généré le vecteur qu'il compare.

À la requête, on embed le message de l'utilisateur, on calcule la similarité cosinus contre tous les chunks des sources liées à l'agent, on garde le top-K :

return chunks
  .map((c) => ({ ...c, similarity: cosineSimilarity(queryEmbedding, c.embedding) }))
  .sort((a, b) => b.similarity - a.similarity)
  .slice(0, topK)

Pas d'index ANN, pas de HNSW. Pour des workspaces avec quelques milliers de chunks, un scan linéaire en mémoire est largement suffisant et évite une dépendance de plus. On ajoutera un vrai index vectoriel le jour où un client aura assez de documents pour que ça devienne un problème — pas avant.

Les chunks retrouvés sont injectés dans le system prompt comme un bloc de contexte priority: 'high', labellisé par leur source d'origine. Et la réponse du chat renvoie un tableau knowledgeUsed avec le score de similarité de chaque chunk utilisé — pour qu'on puisse afficher une citation côté client, pas juste faire confiance à l'agent.

"knowledgeUsed": [
  { "sourceId": "kns_xxx", "sourceName": "Pricing FAQ", "similarity": 0.91 }
]

Tools : function calling, mais l'exécution reste chez vous

Le function calling côté LLM n'a rien d'original — c'est le format OpenAI standard, repris par à peu près tous les providers maintenant. Ce qui nous intéressait, c'était de ne jamais exécuter le code de l'utilisateur nous-mêmes.

Un tool, chez nous, c'est une définition + une URL :

model AgentTool {
  name        String   // nom de fonction exposé au modèle
  description String   // ce que le modèle lit pour décider d'appeler ou pas
  parameters  Json     // JSON Schema des arguments
  url         String   // votre endpoint HTTPS
  secret      String   // clé HMAC, comme nos webhooks
}

Quand le modèle décide d'appeler get_order_status({ orderId: "4471" }), PulseLABS ne fait jamais tourner cette fonction — il POST le payload chez vous, signé HMAC-SHA256 exactement comme nos webhooks, et attend votre réponse JSON pendant 15 secondes max :

POST https://yourapp.com/webhooks/tools/order-status
X-PulseLabs-Signature: sha256=<hmac>

{ "tool": "get_order_status", "arguments": { "orderId": "4471" } }

Votre réponse devient le résultat du tool call, renvoyé au modèle, qui peut alors formuler sa réponse finale — ou décider d'appeler un deuxième tool. On boucle jusqu'à 3 rounds (MAX_TOOL_ROUNDS) avant de forcer une réponse finale, pour permettre des chaînes courtes (vérifier le stock → puis créer la commande) sans laisser un agent partir en boucle infinie sur un budget de tokens qu'on paie.

La raison du choix HMAC plutôt qu'un token statique dans un header : c'est exactement le même modèle de confiance que nos webhooks sortants, donc zéro nouveau concept à documenter pour quelqu'un qui les utilise déjà.


Pourquoi pas une seule abstraction "context provider" ?

On aurait pu fusionner Knowledge et Tools dans un seul concept générique — "des sources de contexte externes". On ne l'a pas fait, pour une raison simple : leurs garanties de sécurité sont opposées.

Le RAG est passif — on lit des données qu'on a nous-mêmes stockées et embeddées, le risque est contenu chez nous. Les tools sont actifs — on envoie une requête signée vers un serveur tiers que le client contrôle, avec tout ce que ça implique (timeout, disponibilité, contenu de la réponse non garanti). Mélanger les deux dans une seule API aurait rendu invisible la différence entre "lire un document" et "exécuter un side-effect potentiel" — exactement le genre de distinction qu'on veut garder explicite pour quelqu'un qui lit le code six mois plus tard.


Ce qu'on n'a pas fait

Pas de re-ranking après le retrieval. Le top-K par cosinus brut peut remonter des chunks redondants. Un re-ranker (cross-encoder) améliorerait la précision, mais ajoute une latence et un coût qu'on n'a pas voulu payer avant d'avoir des retours d'usage réels sur la qualité actuelle.

Pas de tool calling parallèle. Le modèle ne peut demander qu'un appel à la fois par round actuellement, même si le format OpenAI permet plusieurs tool_calls simultanés dans une réponse. On boucle dessus séquentiellement — plus simple à tracer, plus simple à débugger en cas d'échec partiel.

Pas de versioning des knowledge sources. Si vous ré-ingérez un document mis à jour, l'ancien reste tel quel sauf si vous le supprimez explicitement. Pas de diff, pas d'historique. Pour un usage interne aux agents, on n'en a pas encore eu besoin.


Les deux features sont documentées sur le Dev Portal pour ceux qui veulent les brancher via l'API publique. Côté produit, c'est la même chose qui se passe sous le capot quand vous liez un document ou un endpoint à un agent depuis l'interface — pas de chemin caché entre l'UI et l'API.

ShareXLinkedIn