FREN

RAG local sur les lois du Québec : Cloudflare Workers AI sans serveur

Implémenter un RAG souverain sans serveur. BGE embeddings, Llama 3.1 8B, cosine similarity in-memory.

TL;DR — Un système RAG (Retrieval-Augmented Generation) sur les lois québécoises sans serveur dédié : corpus de textes juridiques chunkés, embeddings BGE via Cloudflare Workers AI, cosine similarity in-memory, génération via Llama 3.1 8B. Tout tourne dans un Cloudflare Worker — déploiement à l’edge, zéro infrastructure à gérer. Pour les cas nécessitant une souveraineté complète (données sensibles), l’alternative Ollama + Qdrant local est aussi couverte.


Les lois québécoises sont publiques, longues, et rédigées dans un langage technique qui décourage la lecture. La Loi sur la protection du consommateur fait 180 articles. La Loi sur les normes du travail, 140. Pour un OSBL qui veut vérifier ses obligations sans payer un avocat à chaque question, un assistant IA capable de répondre en langage naturel sur la base du texte officiel est un outil concret.

Le défi technique : rester souverain (données des clients ne sortent pas du Québec) tout en évitant de gérer une infrastructure GPU coûteuse.

Architecture RAG minimale

Un RAG se décompose en trois étapes :

[Question utilisateur]

[Retrieval] → cherche les chunks de texte pertinents par similarité sémantique

[Augmentation] → construit un prompt avec les chunks récupérés

[Generation] → LLM génère la réponse basée sur les sources

L’avantage sur un LLM seul : le modèle répond à partir de sources précises, pas de sa mémoire d’entraînement. On peut vérifier les sources, mettre à jour le corpus sans ré-entraîner.

Chunking du corpus juridique

Le corpus est constitué des textes de loi en format texte brut, téléchargés depuis LégisQuébec. Le chunking est la première décision critique : trop court = perd le contexte, trop long = dilue la pertinence.

interface Chunk {
  id: string;
  text: string;
  source: string;  // "Loi 25, article 12"
  law: string;     // "loi-25"
}

function chunkLaw(text: string, source: string, law: string): Chunk[] {
  // Découper par article (pattern "ARTICLE N")
  const articlePattern = /(?=ARTICLE\s+\d+)/gi;
  const articles = text.split(articlePattern).filter(a => a.trim().length > 50);

  return articles.map((article, i) => ({
    id: `${law}-article-${i}`,
    text: article.slice(0, 1200), // ~300 tokens max par chunk
    source: extractArticleRef(article, source),
    law,
  }));
}

function extractArticleRef(article: string, fallback: string): string {
  const match = article.match(/ARTICLE\s+(\d+[\w.-]*)/i);
  return match ? `${fallback}, article ${match[1]}` : fallback;
}

Pour les lois québécoises, le découpage par article est naturel — chaque article est une unité sémantique complète. Les articles courts (< 100 caractères) sont fusionnés avec le suivant.

Embeddings BGE via Cloudflare Workers AI

Cloudflare Workers AI expose plusieurs modèles d’embeddings. @cf/baai/bge-small-en-v1.5 produit des vecteurs de dimension 384 — compact et rapide pour du in-memory.

interface Env {
  AI: Ai;
}

async function getEmbedding(text: string, env: Env): Promise<number[]> {
  const result = await env.AI.run('@cf/baai/bge-small-en-v1.5', {
    text: [text],
  });
  return result.data[0];
}

async function buildIndex(chunks: Chunk[], env: Env): Promise<IndexedChunk[]> {
  // Traiter en batches pour éviter les timeouts Workers
  const BATCH_SIZE = 10;
  const indexed: IndexedChunk[] = [];

  for (let i = 0; i < chunks.length; i += BATCH_SIZE) {
    const batch = chunks.slice(i, i + BATCH_SIZE);
    const embeddings = await Promise.all(
      batch.map(chunk => getEmbedding(chunk.text, env))
    );
    batch.forEach((chunk, j) => {
      indexed.push({ ...chunk, embedding: embeddings[j] });
    });
  }

  return indexed;
}

L’index est sérialisé en JSON et stocké dans Cloudflare KV lors du build — pas recalculé à chaque requête.

Cosine similarity in-memory

La recherche par similarité cosinus est simple à implémenter et suffisante pour un corpus de quelques centaines de chunks :

function cosineSimilarity(a: number[], b: number[]): number {
  let dot = 0;
  let normA = 0;
  let normB = 0;

  for (let i = 0; i < a.length; i++) {
    dot += a[i] * b[i];
    normA += a[i] * a[i];
    normB += b[i] * b[i];
  }

  const denom = Math.sqrt(normA) * Math.sqrt(normB);
  return denom === 0 ? 0 : dot / denom;
}

function retrieveTopK(
  queryEmbedding: number[],
  index: IndexedChunk[],
  k = 4,
): IndexedChunk[] {
  return index
    .map(chunk => ({
      ...chunk,
      score: cosineSimilarity(queryEmbedding, chunk.embedding),
    }))
    .sort((a, b) => b.score - a.score)
    .slice(0, k);
}

Pour un corpus de 500 chunks × 384 dimensions, la recherche prend < 5ms en JavaScript. Au-delà de 5 000 chunks, passer à une structure ANN (Approximate Nearest Neighbor) devient pertinent.

Génération avec Llama 3.1 8B

async function generateAnswer(
  question: string,
  chunks: IndexedChunk[],
  env: Env,
): Promise<string> {
  const context = chunks
    .map(c => `[${c.source}]\n${c.text}`)
    .join('\n\n---\n\n');

  const systemPrompt = `Tu es un assistant juridique québécois. Tu réponds UNIQUEMENT
à partir des extraits de loi fournis. Si l'information n'est pas dans les extraits,
dis-le clairement. Tu cites toujours la source (article et loi).
Tu t'exprimes en français québécois standard, en termes clairs et accessibles.`;

  const userPrompt = `Extraits de loi pertinents :
${context}

Question : ${question}

Réponds en citant les articles pertinents.`;

  const result = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
    messages: [
      { role: 'system', content: systemPrompt },
      { role: 'user', content: userPrompt },
    ],
    max_tokens: 512,
    temperature: 0.1,  // Bas pour des réponses factuelles
  });

  return result.response ?? 'Aucune réponse générée.';
}

La température basse (0.1) est délibérée : on veut des réponses factuelles et reproductibles sur un corpus juridique, pas de la créativité.

Limitation importante : Llama 3.1 8B via Workers AI produit des réponses correctes sur des questions directes, mais peut halluciner sur des questions impliquant plusieurs articles en interaction. Toujours afficher les sources pour permettre la vérification humaine.

Le Worker complet

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== 'POST') {
      return new Response('Method not allowed', { status: 405 });
    }

    const { question } = await request.json<{ question: string }>();

    if (!question || question.length > 500) {
      return new Response(JSON.stringify({ error: 'Question invalide' }), {
        status: 400,
        headers: { 'Content-Type': 'application/json' },
      });
    }

    // Charger l'index depuis KV
    const indexJson = await env.LAW_INDEX.get('index', 'text');
    if (!indexJson) {
      return new Response(JSON.stringify({ error: 'Index non disponible' }), {
        status: 503,
      });
    }
    const index: IndexedChunk[] = JSON.parse(indexJson);

    // Retrieval
    const queryEmbedding = await getEmbedding(question, env);
    const topChunks = retrieveTopK(queryEmbedding, index, 4);

    // Generation
    const answer = await generateAnswer(question, topChunks, env);

    return new Response(
      JSON.stringify({
        answer,
        sources: topChunks.map(c => ({ source: c.source, score: c.score })),
      }),
      { headers: { 'Content-Type': 'application/json' } },
    );
  },
};

Alternative : Ollama + Qdrant pour souveraineté complète

Pour les cas où les données des utilisateurs sont sensibles (secteur de la santé, dossiers clients OSBL), une approche 100% locale sur serveur dédié :

# Lancer Ollama avec le modèle local
ollama pull llama3.1:8b
ollama pull nomic-embed-text

# Lancer Qdrant (base vectorielle)
docker run -d -p 6333:6333 qdrant/qdrant
// Embeddings via Ollama (même interface)
async function getEmbeddingLocal(text: string): Promise<number[]> {
  const response = await fetch('http://localhost:11434/api/embeddings', {
    method: 'POST',
    body: JSON.stringify({
      model: 'nomic-embed-text',
      prompt: text,
    }),
  });
  const data = await response.json();
  return data.embedding;
}

// Recherche via Qdrant
async function searchQdrant(
  embedding: number[],
  k: number,
): Promise<QdrantResult[]> {
  const response = await fetch('http://localhost:6333/collections/lois-qc/points/search', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ vector: embedding, limit: k, with_payload: true }),
  });
  const data = await response.json();
  return data.result;
}

La différence de coût : Cloudflare Workers AI = ~$0.01 par 1000 tokens de génération (Workers AI pricing). Ollama + serveur dédié = coût fixe mensuel du serveur, mais zéro coût variable et données qui ne quittent jamais l’infrastructure.

Choix de déploiement selon le contexte

CritèreCF Workers AIOllama + Qdrant
Infrastructure à gérerAucuneServeur Linux + Docker
Coût fixe0~20-50$/mois
Données sortent du QCOui (Cloudflare ≠ QC)Non (si serveur QC)
Latence~500ms (cold)~200ms (warm)
ScalabilitéAutomatiqueManuelle

Pour les démos publiques sans données personnelles : Workers AI. Pour les clients avec obligations de résidence des données : Ollama + serveur OVH Montréal ou Hetzner avec colocation QC.

Pour aller plus loin