Retour au Blog
AI & Development14 janvier 202511 min de lecture

RAG local avec Obsidian : une base de connaissances d'équipe pour agents, sans payer d'API

DR

Doug Ribeiro

11 min de lecture

Quand une équipe décide que ses agents ont besoin d'une base de connaissances, la première question est en général quel produit SaaS payer. Aucun. Tout tourne dans le même Docker que vous avez déjà : un dossier de markdown pour les notes, pgvector (extension gratuite de Postgres) pour la recherche sémantique, et un modèle d'embedding local qui se télécharge une fois et ne fait plus jamais d'appel externe. Zéro API hébergée, zéro clé, zéro coût par requête. L'éditeur, si vous voulez, c'est Obsidian, qui est aussi local et gratuit d'usage, à la maison comme au travail. VS Code ouvre les mêmes fichiers.

C'est le setup que je fais tourner sur mon propre vault, et celui que je mettrais devant une équipe : un accès via retrieval-augmented aux conventions, aux runbooks et aux décisions d'architecture, sans SaaS, sans coût par siège et sans lock-in fournisseur. L'agent lit depuis un vault que des humains éditent. Les humains l'éditent parce que l'expérience d'édition est agréable. Cette dernière partie, c'est tout le truc.

Ce qu'est vraiment le RAG

Retrieval-Augmented Generation. Vous ne mettez pas votre base de connaissances dans le LLM. Vous la mettez à côté du LLM et, au moment de l'inférence, vous récupérez les passages pertinents et les injectez dans le prompt. C'est comme ça que les agents sont au courant de choses qui n'étaient pas dans les données d'entraînement : conventions d'équipe, runbooks, décisions d'architecture du dernier trimestre. Pur in-context, sans fine-tuning, sans fournisseur.

Les pièces dont vous avez besoin : un endroit pour stocker le texte (fichiers markdown), un endroit pour stocker les embeddings (pgvector) et un pipeline qui maintient les deux en synchro. C'est tout. La complexité vient de l'opération en production, pas de la construction.

Ce qu'est un embedding et pourquoi vous en avez besoin

La recherche par mot-clé (SQL LIKE '%...%') ne sert à rien pour le RAG. Si une note parle de 'déploiement en production' et que l'agent demande 'comment pousser du code sur le serveur', vous voulez que la note remonte même si les mots ne correspondent pas. C'est de la recherche par sens, pas par chaîne. Les embeddings résolvent ça : chaque chunk de texte devient un vecteur de nombres (par exemple, 384 ou 1024 floats) qui représente le sens du texte. Des textes au sens proche génèrent des vecteurs proches. La recherche devient de la géométrie. Donne-moi les vecteurs les plus proches de celui-ci. C'est ce que fait pgvector.

Quelqu'un doit générer ces vecteurs. Ce quelqu'un, c'est le modèle d'embedding, et c'est la décision où la plupart des tutoriels vous enterrent dans une API payante sans justification. Il existe une option 100% locale qui tourne dans votre propre stack Docker à côté de Postgres, sans API externe, sans clé, sans coût. Utilisez ça par défaut. Ne considérez une API hébergée que si vous avez une raison concrète (volume de re-embedding, problèmes de recall mesurés, notes que le modèle local n'arrive pas à lire), et le post couvre les trois à la fin.

Local (le chemin par défaut)sentence-transformers all-MiniL…nomic-embed-text via Ollama (76…Runs in the same container as t…Zero network call, zero API keyCost: zeroAPI hébergée (upgrade optionnel)Voyage AI voyage-3 (1024 dim, t…OpenAI text-embedding-3-small (…Marginally better quality on hi…Extra network latency per callCost: pay per token embeddedVS

Pourquoi Obsidian spécifiquement

J'utilise Obsidian par défaut parce que le vault, c'est juste un dossier de fichiers markdown. Mettez Obsidian au-dessus de ce dossier et vous avez un excellent éditeur avec backlinks, tags, graph view et recherche. Retirez Obsidian et vous avez toujours un dossier de fichiers markdown. Pas de lock-in. Git versionne le vault. L'agent lit les fichiers directement.

Obsidian est une desktop app (Windows, Mac, Linux, mobile) qui tourne 100% sur votre machine. Le vault ne quitte jamais votre disque. Leurs services payants (Sync et Publish) sont des add-ons optionnels que vous ignorez complètement : la synchro entre appareils dans ce setup se fait avec git. L'utiliser au travail est gratuit aussi. Depuis février 2025, Obsidian est gratuit pour un usage commercial, et la Commercial license (50 $ par utilisateur par an) est un soutien optionnel plutôt qu'une obligation. Ça a changé un mois après la première publication de ce post, quand les entreprises de deux personnes ou plus devaient encore acheter la licence.

Si l'équipe préfère ne pas utiliser Obsidian, le setup fonctionne à l'identique. L'agent lit les fichiers .md. Il se fiche de savoir si vous avez édité dans VS Code, dans Obsidian ou dans vim.

L'architecture

Vault Obsidian File watcher Chunker + embedder Store pgvector Query de l'agent

L'indexeur

L'indexeur est un petit service en Python. Il observe le dossier du vault, fait le chunking des notes quand elles changent, génère l'embedding de chaque chunk avec un modèle qui tourne dans son propre container et fait l'upsert dans pgvector. Aucun appel externe. Tourne en Docker à côté du reste de la stack.

python
# libs/memory/indexer.py
import os
from pathlib import Path
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from sentence_transformers import SentenceTransformer
import psycopg

VAULT_PATH = Path(os.environ["VAULT_PATH"])
DB_URL = os.environ["DATABASE_URL"]

# Local model. Downloads once (~90MB) to the container disk.
# Zero API, zero key, zero per-call cost.
# Resulting vector: 384 dimensions.
model = SentenceTransformer("all-MiniLM-L6-v2")

def chunk(text: str, max_chars: int = 1500) -> list[str]:
    paragraphs = text.split("\n\n")
    chunks, buf = [], ""
    for p in paragraphs:
        if len(buf) + len(p) > max_chars:
            chunks.append(buf)
            buf = p
        else:
            buf = f"{buf}\n\n{p}" if buf else p
    if buf:
        chunks.append(buf)
    return chunks

def embed(text: str) -> list[float]:
    return model.encode(text).tolist()

def reindex(file_path: Path):
    text = file_path.read_text()
    source = str(file_path.relative_to(VAULT_PATH))
    chunks = chunk(text)
    with psycopg.connect(DB_URL) as conn:
        for i, c in enumerate(chunks):
            emb = embed(c)
            # The %s::vector cast is not optional. psycopg3 sends a Python
            # list as float8[] and Postgres refuses to store that in a
            # VECTOR column. The alternative is register_vector(conn) from
            # pgvector.psycopg, which teaches psycopg the type once.
            conn.execute(
                "INSERT INTO knowledge (source, chunk_index, content, embedding) "
                "VALUES (%s, %s, %s, %s::vector) "
                "ON CONFLICT (source, chunk_index) DO UPDATE SET "
                "content = EXCLUDED.content, "
                "embedding = EXCLUDED.embedding, "
                "updated_at = NOW()",
                (source, i, c, emb)
            )
        # A note that got shorter leaves orphan chunks behind.
        conn.execute(
            "DELETE FROM knowledge WHERE source = %s AND chunk_index >= %s",
            (source, len(chunks))
        )
        conn.commit()

class Handler(FileSystemEventHandler):
    def on_modified(self, event):
        if event.src_path.endswith(".md"):
            reindex(Path(event.src_path))

if __name__ == "__main__":
    # Full initial indexing
    for md in VAULT_PATH.rglob("*.md"):
        reindex(md)
    # Watch for changes
    observer = Observer()
    observer.schedule(Handler(), str(VAULT_PATH), recursive=True)
    observer.start()
    observer.join()

C'est l'indexeur en entier. Ce n'est pas du pseudo-code minimal. C'est du vrai Python qui tourne. Il s'occupe de l'indexation initiale, de la détection de changement de fichier et de la re-génération d'embedding quand il y a une édition. La taille de chunk de 1500 caractères est un compromis : assez petit pour récupérer des snippets précis, assez grand pour préserver le contexte du paragraphe. Une réserve : all-MiniLM-L6-v2 tronque toute entrée de plus de 256 word pieces, environ 1 000 caractères d'anglais, donc la fin d'un chunk complet de 1500 caractères n'atteint jamais l'embedding. Si vos notes sont denses, baissez max_chars ou prenez un modèle avec une fenêtre d'entrée plus longue.

Le schéma

sql
CREATE EXTENSION IF NOT EXISTS vector;

-- VECTOR(384) matches all-MiniLM-L6-v2.
-- If you swap the embedding model, adjust the dimension and reindex.
CREATE TABLE knowledge (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  source TEXT NOT NULL,
  chunk_index INTEGER NOT NULL,
  content TEXT NOT NULL,
  embedding VECTOR(384) NOT NULL,
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (source, chunk_index)
);

CREATE INDEX idx_knowledge_embedding
  ON knowledge USING hnsw (embedding vector_cosine_ops);

CREATE INDEX idx_knowledge_source ON knowledge (source);

Une table. Texte du chunk, embedding, chemin d'origine. Le chemin d'origine, c'est ce qui compte quand l'agent montre le contexte récupéré à un humain : 'ceci vient de conventions/testing.md', pas 'ceci vient de l'embedding ID 8d3f'.

Comment l'agent utilise ça

python
# libs/memory/retrieval.py
# Reuses the same model loaded in the indexer (one instance, CPU).
def retrieve(query: str, top_k: int = 5) -> list[dict]:
    q_emb = model.encode(query).tolist()
    with psycopg.connect(DB_URL) as conn:
        rows = conn.execute(
            # Inner query stays on the HNSW index. Outer query re-ranks
            # the candidates and demotes anything older than 6 months.
            "WITH candidates AS ("
            "  SELECT source, chunk_index, content, updated_at, "
            "         1 - (embedding <=> %s::vector) AS similarity "
            "  FROM knowledge "
            "  ORDER BY embedding <=> %s::vector "
            "  LIMIT 20"
            ") "
            "SELECT source, chunk_index, content, "
            "       similarity - CASE "
            "         WHEN updated_at < NOW() - INTERVAL '6 months' THEN 0.05 "
            "         ELSE 0 END AS score "
            "FROM candidates "
            "ORDER BY score DESC "
            "LIMIT %s",
            (q_emb, q_emb, top_k)
        ).fetchall()
    return [
        {"source": r[0], "chunk": r[1], "content": r[2], "score": r[3]}
        for r in rows
    ]

L'agent monte une query à partir du payload du trigger plus son raisonnement actuel, appelle retrieve et reçoit en retour les top 5 chunks. Ces chunks vont dans le system prompt avec leurs chemins d'origine. Si l'agent cite la base de connaissances dans sa sortie, il cite le chemin d'origine, pas un ID opaque. Un humain peut ouvrir exactement ce fichier et vérifier. La requête en deux temps est délibérée : on tire un large ensemble de candidats avec l'index vectoriel, puis on re-classe en SQL. Re-classer dans la requête externe empêche la pénalité de fraîcheur de jeter l'index à la poubelle.

Quoi mettre dans le vault

Pas tout. Soyez délibéré sur ce qui appartient à une base de connaissances d'équipe que des agents lisent.

Appartient au vaultConventions d'équipe (nommage, …Runbooks (playbooks d'oncall, r…Décisions d'architecture (ADRs)Glossaire de domaine (ce que de…Post-mortems (ce qui a foiré, c…N'appartient pasCredentials ou secrets (jamais)PII de client (jamais)Notes de standup quotidien (éph…Comptes-rendus de réunion sans …Réponses d'API copiées-collées …VS

La règle que j'utilise : si la note sera encore utile dans 12 mois, elle appartient au vault. Si c'est un snapshot d'un moment, ça va dans Slack ou dans un ticket.

Coût

Par défaut, ce setup coûte zéro. Le modèle d'embedding se télécharge une fois sur le disque du container (autour de 90MB) et tourne sur le CPU du serveur que vous avez déjà. pgvector est une extension gratuite de Postgres. La seule infra payante, c'est le serveur lui-même où tout tourne, et si vous faites déjà tourner Postgres pour autre chose, c'est zéro infrastructure additionnelle.

Pour un vault de 500 notes avec en moyenne 2 chunks chacune, l'indexation initiale avec all-MiniLM-L6-v2 sur CPU moderne se termine en bien moins d'une minute. Le re-embedding continu sur édition est instantané du point de vue de celui qui édite. Si vous montez à des dizaines de milliers de notes et commencez à sentir une latence d'indexation, là ça vaut le coup de revisiter la décision. D'ici là, résistez à la tentation d''améliorer' en passant à une API payante avant d'avoir un chiffre qui le justifie.

Les erreurs que j'ai déjà commises

Première erreur : j'ai construit une stratégie de chunking compliquée avec overlap et splitting sémantique dans la première version. Elle a moins bien performé que le chunking par paragraphe parce que les frontières étaient moins prévisibles. Commencez simple. Chunks par paragraphe avec une taille max. Ajustez seulement quand la qualité du retrieval vous dit d'ajuster.

Deuxième erreur : j'ai traité la base de connaissances comme append-only et laissé du contenu obsolète s'accumuler. Laissé comme ça, ça finit avec l'agent qui cite une convention dépréciée d'un runbook que personne n'a touché depuis un an. La requête de retrieval ci-dessus est le correctif : elle tire un ensemble de candidats plus large, puis soustrait une petite pénalité à tout ce dont le updated_at a plus de 6 mois. Une note obsolète peut quand même gagner si rien de plus frais n'est proche, et c'est le comportement que vous voulez.

Troisième erreur : j'ai donné à l'agent un accès en écriture au vault pour qu'il puisse 'apprendre' en mettant à jour des notes. C'est le mauvais réflexe. Une exécution à faible confiance peut réécrire une note en quelque chose de subtilement faux, rien dans le pipeline ne l'attrape, et le retrieval suivant traite le texte de l'agent comme du matériau source. Le vault est édité par des humains. La sortie de l'agent va dans un dossier séparé agent_learnings/ qu'un humain lit avant que quoi que ce soit ne soit promu dans le vault.

La base de connaissances depuis laquelle vos agents lisent et la base de connaissances que votre équipe édite devraient être le même dossier. Toute architecture qui sépare les deux les fait diverger, et une mémoire divergente est pire qu'une absence de mémoire.

Mises à niveau optionnelles

Trois situations où le modèle local par défaut cesse d'être la bonne réponse. Seulement ces trois.

Volume massif. Si le vault dépasse les dizaines de milliers de notes et que le re-embedding local commence à prendre des heures, une API hébergée termine en minutes. Voyage AI voyage-3 est l'upgrade le plus direct ici parce qu'il a été entraîné spécifiquement pour le retrieval, et supporte input_type='query' vs 'document' pour améliorer le recall.

Qualité de recall validée par benchmark. Si vous avez mesuré le retrieval local et qu'il récupère des chunks non pertinents dans des scénarios spécifiques, des modèles hébergés plus gros résolvent parfois ça. Mais mesurez avant de changer. Souvent le problème est le chunking ou le ranking, pas le modèle.

Une entrée que le modèle local n'arrive pas à représenter. all-MiniLM-L6-v2 tronque à 256 word pieces et a été entraîné sur de l'anglais. Si vos notes sont longues ou multilingues, la qualité de retrieval baisse pour une raison qu'aucun réglage ne corrige. Essayez d'abord la plus grosse option locale : nomic-embed-text via Ollama accepte une entrée bien plus longue, gère plus de langues, et reste gratuit. Un modèle hébergé avec une longue fenêtre de contexte, c'est l'étape d'après, pas avant.

Quand le chiffre tient debout, le changement, c'est quelques lignes :

python
# --- Local default (zero cost) ---
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")  # 384 dim

def embed(text: str) -> list[float]:
    return model.encode(text).tolist()


# --- Upgrade to Voyage (pay per call) ---
import voyageai
vo = voyageai.Client()  # reads VOYAGE_API_KEY from env

def embed_doc(text: str) -> list[float]:
    result = vo.embed([text], model="voyage-3", input_type="document")
    return result.embeddings[0]

def embed_query(text: str) -> list[float]:
    result = vo.embed([text], model="voyage-3", input_type="query")
    return result.embeddings[0]

# WARNING: voyage-3 returns 1024 dim. Adjust VECTOR(1024) in the schema
# and reindex the entire vault. The migration is not transparent.

Références

RAGObsidianpgvectorKnowledge BaseAI Agents