Comment utiliser l’IA pour interroger une base de données clients sans écrire de SQL

Vous avez enfin décroché un poste d’analyste de données dans l’entreprise de vos rêves (youpi !). Impatient de dégainer vos compétences SQL durement acquises, peut-être même d’explorer les nouveaux frameworks de dataframes. Vous arrivez à votre bureau, café fumant en main, prêt à plonger dans ce qui se fait de mieux. Mais les demandes urgentes sont les mêmes que d’habitude : « Hé ! Tu peux me sortir la liste de nos clients à New York qui ont acheté au moins 10 avocats l’année dernière ? » Alors vous ouvrez votre bon vieuxcustomers-filter.sql remplacez « Los Angeles » par « New York » et « Avocado » par « Bagel » dans la requête, puis vous joignez ce fichier CSV sur Slack. Ce processus répétitif, mécanique (et franchement ennuyeux) est épuisant et chronophage. Et si vos collègues non techniques pouvaient interroger la base de données directement, quand ils en ont besoin ?

C’est quoi un SQL Toolkit ?

Le SQL Toolkit permet d’interagir avec votre base de données en langage naturel. Plutôt que d’écrire des requêtes complexes, vous décrivez simplement ce dont vous avez besoin en français courant. Le toolkit traduit votre demande en un jeu de paramètres adapté et renvoie des résultats mis en forme, en interrogeant directement vos données pour éviter les hallucinations.

Fonctionnalités clés :

  • Interrogation en langage naturel
  • Résultats correctement mis en forme
  • Validation des données intégrée

Pourquoi Arcade.dev rend cela possible

Nous utilisons un appel d’outil Arcade pour interagir avec votre base de données SQL. L’appel d’outil (tool-calling) signifie que l’IA peut effectuer des actions concrètes en votre nom, plutôt que de simplement fournir des informations. Au lieu de suggérer des requêtes SQL, l’agent utilise Arcade pour interagir directement avec votre base de données via une authentification adaptée, interroge les données et retourne des résultats fiables. Vous obtenez de vraies données, sans hallucinations IA, tout en gardant un contrôle total sur ce à quoi l’agent peut accéder.

Premiers pas

Dans ce tutoriel, nous allons construire un toolkit Arcade capable d’interfacer avec une base de données en langage naturel pour générer et mettre en forme des résultats. Concrètement, nous fournirons à l’agent LLM une fonction qui sait comment accéder correctement aux données pour éviter les hallucinations. Pour cela, nous allons :

  1. Créer un nouveau toolkit
  2. Écrire une fonction de génération de requêtes pour le LLM
  3. Construire des évaluations pour les appels d’outils
  4. Invoquer notre nouvel outil depuis le Playground

Configuration

Commençons par vérifier que les logiciels requis sont bien installés :

  • Python 3.10+ & pip

On commence par créer un répertoire et un environnement virtuel pour notre toolkit

mkdir arcade-sql-agent
cd arcade-sql-agent
python -m venv .venv
source .venv/bin/activate

On installe ensuite les packages Python nécessaires dans l’environnement actif.

pip install 'arcade-ai[evals]' python-dotenv psycopg2-binary

Nous pouvons maintenant invoquer la commande arcade depuis notre terminal pour créer notre nouveau toolkit. Mais d’abord, connectons-nous à Arcade. Si vous n’avez pas encore de compte, c’est le moment de créer un compte Arcade. Puis ouvrez le terminal et exécutez :

arcade login

Votre navigateur s’ouvrira et, une fois la connexion effectuée, un message dans le terminal affichera votre clé API Arcade.

Créer un nouveau toolkit

Développons maintenant notre toolkit from scratch. On va d’abord utiliser arcade pour générer un modèle de projet toolkit :

arcade new

Vous serez invité à saisir un nom, une description, un nom d’utilisateur GitHub et une adresse e-mail pour le toolkit.

J’ai utilisé ces valeurs :

Name of the new toolkit?: sql_customers
Description of the toolkit?: Query the customer database using natural language
Github owner username?: torresmateo
Author's email?: mateo@arcade.dev

Vous verrez qu’un sql_customers a été créé pour vous et alimenté avec un modèle de projet. Il comprend un squelette organisé pour y ajouter nos fonctions, tests et évaluations, avec des utilitaires pour installer le toolkit localement et le tester.

Voici à quoi ressemble le répertoire :

├── LICENSE
├── Makefile
├── README.md
├── arcade_sql_customers
│   ├── __init__.py
│   └── tools
│   ├── __init__.py
│   └── hello.py
├── codecov.yaml
├── evals
│   └── eval_sql_customers.py
├── pyproject.toml
├── tests
│   ├── __init__.py
│   └── test_sql_customers.py
└── tox.ini

Le répertoire tools est l’endroit où va le code des tools. Par défaut, on y trouve hello.py, qui inclut un tool basique prenant un nom et le saluant. Nous allons le remplacer par notre propre tool.

Comprendre le schéma de base de données pour l’appel de tools

Pour simplifier, nous utiliserons Supabase pour héberger une base de données. Voici le schéma de la table :

create table people
(
    id         INTEGER  primary key,
    name       TEXT,
    age        INTEGER,
    location   TEXT,
    occupation TEXT,
    email      TEXT
);

Nous avons préparé une version téléchargeable de ce schéma avec 100 lignes, que vous pouvez exécuter pour recréer la même base de données. Si vous souhaitez suivre le tutoriel, créez un compte Supabase, et chargez le schéma dans une nouvelle base de données.

Écrire une fonction de génération de requêtes pour le LLM

Ouvrez le répertoire sql_customer dans votre éditeur de code, et remplacez le fichier hello.py par un fichier au nom plus explicite query.py sous arcade_sql_customer/tools. Nous commençons par importer les packages nécessaires et configurer un logger simple :

import sqlite3
from typing import Annotated, Optional

from arcade.sdk import tool, ToolContext
from arcade_sql_customers.utils import get_database_connection

# Configure the logger
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

Si vous suivez le tutoriel, téléchargez le script utils.py depuis le dépôt, ou mieux encore, clonez l’intégralité du dépôt pour faciliter l’exécution !

Commençons par l’approche text-to-SQL classique, qui demande au LLM de générer du SQL directement à partir d’un prompt en langage naturel :

@tool(requires_secrets=["database_url"])
def direct_query(
    context: ToolContext,
    query: Annotated[str, "The query to run in the database"]
) -> Annotated[dict, "The data returned from the database"]:
    """
    Query the data from the 'people' table with a query generated by the LLM.
    """
    logger.info("Starting query_customer_data function")
    logger.debug(f"Query received: {query}")

    # Connect to the database
    conn = get_database_connection(context)
    cursor = conn.cursor()

    # Execute the query and get the rows
    cursor.execute(query)
    rows = cursor.fetchall()
    logger.info(f"Query executed successfully. Rows fetched: {len(rows)}")

    # Prepare the results as a list of dictionaries
    column_names = [description[0] for description in cursor.description]
    results = [dict(zip(column_names, row)) for row in rows]
    logger.debug(f"Results prepared: {results}")
    conn.close()

    return {"results": results}

Consultez l’implémentation complète sur le dépôt du tutoriel : https://github.com/ArcadeAI/arcade-sql-agent-tutorial/blob/main/arcade_sql_customers/tools/query.py#L14

Le décorateur @tool placé au-dessus de notre définition indique au worker Arcade d’exporter celle-ci comme un tool que l’engine peut rendre disponible aux LLMs. Le ToolContext que nous recevons nous permettra d’obtenir les informations utilisateur appropriées pour configurer notre tool. Dans ce cas, nous utilisons require_secrets pour nous assurer de récupérer le chemin vers la base de données SQLite en tant que secret du tool.

Déployons ce tool et voyons comment il fonctionne. Nous pouvons déployer un worker cloud en exécutant les commandes suivantes (la première fois, cela peut prendre plusieurs secondes) :

arcade deploy

Cela déploiera un worker avec les nouveaux tools dans le cloud et l’enregistrera sur notre compte Arcade. Si nous nous connectons maintenant, nos tools apparaissent dans la liste de l’onglet « Tools », ainsi qu’un avertissement nous demandant de renseigner les secrets requis manquants :

Pour configurer le secret requis par notre tool, récupérons la chaîne de connexion depuis Supabase. Cliquez sur le lien orange et collez-la dans le champ « Secret Value ».

Nous pouvons maintenant tester notre direct_query tool. Cette fonction demande au LLM de générer une requête SQL et tente de l’exécuter directement sur la base de données. Pratique dans les cas simples, mais pas fiable à tous les coups :

Les LLM sont sujets aux hallucinations. Ici, le modèle a supposé à tort que « male » dans le champ Occupation ou « Mr. » dans le nom du client suffisait à déterminer le genre. Pourtant, d’après notre schéma, il aurait dû signaler l’absence des colonnes nécessaires pour répondre avec précision. Ce type d’erreur peut amener les utilisateurs à croire que M. Phillips est le seul client masculin dans la base, avec des décisions biaisées à la clé. Mais le risque va plus loin : si on lui en laisse la liberté, le LLM pourrait générer desDELETE ou des DROP instructions, ou halluciner des requêtes si complexes qu’elles impactent les performances de toute la base. Ou simplement utiliser le mauvais dialecte SQL, ou référencer des colonnes inexistantes :

Pour y remédier, on peut offrir au LLM une interface plus robuste et contrôlée vers notre base de données, avec des paramètres explicites et clairement délimités. La stratégie : proposer un ensemble précis de paramètres que le LLM peut utiliser pour filtrer la table. Le tool se charge de sanitiser les entrées et de construire la requête, en garantissant le bon dialecte et en bloquant tout ce qu’on ne veut pas voir atteindre notre base :

@tool(requires_secrets=["database_url"])
def query_customer_data(
    context: ToolContext,
    columns_to_select: Annotated[
        Optional[list[str]],
        "List of columns to select from the 'people' table."
        " If None, all columns are selected.",
    ] = None,
    filter_by_id: Annotated[
        Optional[int],
        "Filter the results by ID."
    ] = None,
    filter_by_name: Annotated[
        Optional[str],
        "Filter the results by name."
    ] = None,
    # ... more parameters in the repo!
    order_by: Annotated[
        Optional[str],
        "Column to order the results by. Must be a valid column name."
    ] = None,
    limit: Annotated[
        int,
        "The maximum number of rows to return."
    ] = 20,
) -> Annotated[
        dict,
        "The data returned from the database."]:
    """
    Query the data from the 'people' table with the provided parameters.
    """
    # Build the base query

    valid_columns_list = ["id", "name", "age",
                          "location", "occupation", "email"]
    valid_columns = "*"
    if columns_to_select:
        # Collect valid columns to select, ignore any others
        valid_columns = [
            col for col in columns_to_select if col in valid_columns_list]

    query = f"SELECT {valid_columns} FROM people"
    params = []
    logger.debug(f"Initial query: {query}")

    # Build WHERE clause with filters
    where_clauses = []
    if filter_by_id is not None:
        where_clauses.append('id = %s')
        params.append(filter_by_id)
        logger.debug(f"Filtering by id: {filter_by_id}")
    if filter_by_name is not None:
        where_clauses.append('Name ILIKE %s')
        params.append(f"%{filter_by_name}%")
        logger.debug(f"Filtering by Name: {filter_by_name}")

    # ... handle more parameters

    # add the WHERE clause if needed
    if where_clauses:
        query += " WHERE " + " AND ".join(where_clauses)
        logger.debug(f"Added WHERE clauses: {' AND '.join(where_clauses)}")

    # Add ORDER BY clause if provided
    if order_by:
        if order_by not in valid_columns_list:
            logger.error(f"Invalid order_by column: {order_by}")
            return {"results": []}
        query += f' ORDER BY "{order_by}"'
        logger.debug(f"Ordering by: {order_by}")

    # Add LIMIT clause
    query += " LIMIT %s"
    params.append(limit)

    # Connect to the database
    conn = get_database_connection(context)
    cursor = conn.cursor()

    # Execute the query and get the rows
    cursor.execute(query, params)
    rows = cursor.fetchall()
    logger.info(f"Query executed successfully. Rows fetched: {len(rows)}")

    # Prepare the results as a list of dictionaries
    column_names = [description[0] for description in cursor.description]
    results = [dict(zip(column_names, row)) for row in rows]
    logger.debug(f"Results prepared: {results}")
    conn.close()

    return {"results": results}

Nous avons condensé le code dans ce tutoriel pour éviter des fonctions trop longues. Retrouvez l’implémentation complète sur le dépôt : https://github.com/ArcadeAI/arcade-sql-agent-tutorial/blob/main/arcade_sql_customers/tools/query.py#L76

C’est le bon moment pour déployer et réessayer (vous ne vous souvenez plus comment faire ? Remontez un peu 😉)

Créer des evals pour les tool-calls

Les tests permettent de vérifier que la fonction se comporte de façon prévisible selon les valeurs passées. Mais il faut aussi tester la qualité des interactions entre les LLM et nos tools. Arcade nous offre un moyen d’évaluer cette qualité en mesurant :

  • Que le LLM choisit le bon tool au bon moment
  • Qu’il passe les bons paramètres au tool selon le contexte
  • Qu’il appelle les tools dans le bon ordre pour les tâches en plusieurs étapes

Ici, on ne fournit qu’un seul tool au LLM, donc on ne teste que le deuxième aspect. On va quand même utiliser tous les mécanismes d’évaluation d’Arcade : Rubrics, Evaluation Suites et Critics. Remplaçons le contenu de sql_customers/evals/eval_sql_customers.py :

from arcade.sdk import ToolCatalog
from arcade.sdk.eval import (
    EvalRubric,
    EvalSuite,
    ExpectedToolCall,
    SimilarityCritic,
    BinaryCritic,
    tool_eval,
)

import arcade_sql_customers
from arcade_sql_customers.tools.query import query_customer_data

# Evaluation rubric
rubric = EvalRubric(
    fail_threshold=0.85,
    warn_threshold=0.95,
)


catalog = ToolCatalog()
catalog.add_module(arcade_sql_customers)


@tool_eval()
def sql_toolkit_query_customer_eval_suite() -> EvalSuite:
    suite = EvalSuite(
        name="sql_toolkit Tools Evaluation",
        system_message=(
            "You are an IA assistant with access to sql_toolkit tools. "
            "Use them to help the user with their tasks."
        ),
        catalog=catalog,
        rubric=rubric,
    )

    # We'll add our evaluations here

    return suite

Voyons ce qui se passe ici :

  • L’objet EvalRubric indique à Arcade de considérer tout score inférieur à 85 % comme un échec, les scores entre 85 % et 95 % comme des avertissements, et tout ce qui dépasse 95 % comme un succès.
  • L’objet ToolCatalog regroupe les tools qu’on veut rendre disponibles au LLM.
  • On utilise le décorateur @tool_eval() pour indiquer à Arcade qu’on veut utiliser la fonction sql_toolkit_query_customer_eval_suite pour définir une suite d’évaluations spécifiques.
  • Dans la fonction, on initialise la suite avec un message système, puis on ajoute des cas précis pour tester différents scénarios avec un LLM :
suite.add_case(
    name="Getting names and emails from a given Name",
    user_message="Get the names and emails of all customers named David",
    expected_tool_calls=[ExpectedToolCall(
        func=query_customer_data,
        args={
            "filter_by_name": "David",
            "columns_to_select": ["name", "email"],
        })],
    rubric=rubric,
    critics=[
        SimilarityCritic(critic_field="filter_by_name", weight=0.5),
        BinaryCritic(critic_field="columns_to_select", weight=0.5),
    ]
)

Retrouvez l’ensemble des cas d’évaluation sur le dépôt de ce tutoriel : https://github.com/ArcadeAI/arcade-sql-agent-tutorial/blob/main/evals/eval_sql_customers.py

Chaque cas représente un prompt qu’un utilisateur pourrait soumettre pour amener le LLM à appeler notre tool. Le LLM doit être capable d’analyser le langage naturel contenu dans user_message et de renseigner les paramètres de notre tool avec les bonnes valeurs.

On utilise ExpectedToolCall pour définir que query_customer_data est la bonne fonction à appeler, ainsi que les valeurs à utiliser dans ce scénario selon le contexte. Pour le premier cas, on vérifie que le LLM choisit correctement les valeurs à passer à filter_by_name ( "David" ), et columns_to_select (["name", "email"]).

Comme on utilise ILIKE dans notre générateur de requêtes, la valeur exacte passée à filter_by_name le paramètre n’est pas exactement identique à celui de notre prompt (« david » fonctionnerait aussi bien que « DAVID », « David » ou même « DaViD »). Dans ce cas, on utilise un SimilarityCritic, qui évalue la similarité entre la valeur attendue définie dans ExpectedToolCall et la valeur réellement inférée par le LLM à partir du contexte.

En revanche, la valeur passée à columns_to_select doit être correcte ("E-mail" ne permettra pas de sélectionner la colonne "email"). Dans ce cas, on utilise le BinaryCritic, qui vérifie la correspondance exacte entre la valeur attendue et la valeur réelle.

Les weight paramètres transmis aux critics déterminent le poids du score de chaque critic dans l’évaluation globale. La bonne pratique, et l’approche la plus intuitive, consiste à faire en sorte que la somme de tous les poids soit égale à 1.0 pour chaque cas d’évaluation, afin de l’interpréter facilement comme une importance en pourcentage. Vous pouvez en savoir plus sur les types de critic et quand les utiliser.

Pour évaluer cela, nous allons utiliser Arcade Engine, qui a déjà accès à nos outils.

Installez le toolkit en local :

make install

Puis lancez la suite d’évaluation :

arcade evals --cloud evals

Cela se connectera à Arcade et exécutera chaque cas de notre suite d’évaluation. Le résultat devrait ressembler à ceci :

Invoquez votre nouvel outil depuis le Playground

Félicitations ! Toutes les évaluations sont passées, et nous sommes prêts à voir comment notre outil se comporte dans un vrai scénario de chat. Connectez-vous au tableau de bord Arcade, et demandez-lui de récupérer des clients

Nous pouvons maintenant interagir avec notre base de données en langage naturel !

Prochaines étapes

Maintenant que vous avez vu comment créer une interface de base de données alimentée par IA avec Arcade, vous pouvez concevoir votre propre solution :

  1. Adaptez les requêtes aux besoins de votre équipe
  2. Testez en profondeur avant de déployer

L’ensemble du processus prend environ une demi-heure et ouvre vos données clients à toute votre équipe, quel que soit leur niveau technique.

Prêt à l’essayer ? Rendez-vous sur arcade.dev pour commencer.