33800 Docs

← Retour

Proposition : Mémoire Vectorielle Claude

Date : 27/01/2026 Status : IMPLEMENTEE Priorité : HAUTE Effort estimé : 1 session Stack : Ollama (win11) + Supabase pgvector (prod-portainer) Projet GitLab : claude-memory (avec CI/CD) Schema Supabase : claude_memory (isolé du public)


Problème

Après compression du contexte, Claude perd des informations critiques (credentials, procédures, conventions) et fait des erreurs. Le système actuel basé sur grep ne permet pas de recherche sémantique.

Solution

Système hybride :


Architecture

~/.claude/memory/*.md
        │
        │ Script ingestion (cron 5min ou hook)
        ▼
┌─────────────────────────────────────┐
│  Ollama (win11:11434)               │
│  Modèle: nomic-embed-text           │
│  768 dimensions, ~500Mo VRAM        │
└─────────────────┬───────────────────┘
                  │
                  ▼
┌─────────────────────────────────────┐
│  Supabase pgvector (prod-portainer) │
│  Table: claude_memory               │
└─────────────────┬───────────────────┘
                  │
                  ▼
┌─────────────────────────────────────┐
│  API: POST /api/memory/search       │
│  → Retourne chunks pertinents       │
└─────────────────────────────────────┘

1. Projet GitLab : claude-memory

Repo : https://gitlab.33800.nowhere84.com/gouroubleu/claude-memory

Structure du projet

claude-memory/
├── .gitlab-ci.yml          # Pipeline CI/CD
├── Dockerfile              # Image Python
├── requirements.txt        # Dépendances
├── src/
│   ├── __init__.py
│   ├── sync.py             # Script de synchronisation
│   ├── api.py              # API FastAPI (optionnel)
│   ├── embeddings.py       # Client Ollama
│   └── config.py           # Configuration
├── migrations/
│   └── 001_init_schema.sql # Schema Supabase
├── tests/
│   └── test_sync.py
└── README.md

.gitlab-ci.yml

stages:
  - test
  - build
  - deploy

variables:
  IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  IMAGE_LATEST: $CI_REGISTRY_IMAGE:latest

test:
  stage: test
  image: python:3.11-slim
  script:
    - pip install -r requirements.txt
    - pip install pytest
    - pytest tests/ -v
  only:
    - merge_requests
    - main

build:
  stage: build
  image: docker:24.0.5
  services:
    - docker:24.0.5-dind
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $IMAGE_TAG -t $IMAGE_LATEST .
    - docker push $IMAGE_TAG
    - docker push $IMAGE_LATEST
  only:
    - main

deploy:
  stage: deploy
  image: curlimages/curl:latest
  script:
    - |
      curl -X POST "http://192.168.1.12:9000/api/stacks/webhooks/${PORTAINER_WEBHOOK_TOKEN}" \
        -H "Content-Type: application/json"
  environment:
    name: production
  only:
    - main

Dockerfile

FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY src/ ./src/

# Cron intégré pour sync toutes les 5 min
RUN apt-get update && apt-get install -y cron && rm -rf /var/lib/apt/lists/*

COPY crontab /etc/cron.d/claude-memory
RUN chmod 0644 /etc/cron.d/claude-memory && crontab /etc/cron.d/claude-memory

CMD ["python", "-m", "src.api"]

requirements.txt

supabase>=2.0.0
requests>=2.31.0
fastapi>=0.109.0
uvicorn>=0.27.0
python-dotenv>=1.0.0

2. Schema SQL (Supabase) - Schema dédié claude_memory

-- Créer le schema dédié (isolé de public)
CREATE SCHEMA IF NOT EXISTS claude_memory;

-- Activer pgvector si pas déjà fait
CREATE EXTENSION IF NOT EXISTS vector;

-- Table principale dans le schema dédié
CREATE TABLE claude_memory.chunks (
    id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
    content TEXT NOT NULL,
    embedding vector(768),  -- nomic-embed-text = 768 dims
    namespace VARCHAR(100) DEFAULT 'default',  -- multi-env: "claude", "stack-86000", etc.
    file_source VARCHAR(255) NOT NULL,  -- ex: "credentials.md"
    section VARCHAR(255),  -- ex: "## SSH Linux"
    chunk_index INTEGER DEFAULT 0,
    checksum VARCHAR(64),  -- MD5 du contenu pour détecter changements
    metadata JSONB DEFAULT '{}',  -- données flexibles (tags, priorité, etc.)
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Table de métadonnées des fichiers (pour tracking sync)
CREATE TABLE claude_memory.files (
    id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
    namespace VARCHAR(100) DEFAULT 'default',
    file_name VARCHAR(255) NOT NULL,
    checksum VARCHAR(64) NOT NULL,
    chunks_count INTEGER DEFAULT 0,
    last_sync TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE(namespace, file_name)
);

-- Table de logs des recherches (analytics)
CREATE TABLE claude_memory.search_logs (
    id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
    query TEXT NOT NULL,
    namespace VARCHAR(100),
    results_count INTEGER,
    top_similarity FLOAT,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Index pour recherche vectorielle rapide
CREATE INDEX idx_chunks_embedding ON claude_memory.chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);

-- Index pour filtrage par namespace + fichier
CREATE INDEX idx_chunks_namespace ON claude_memory.chunks(namespace);
CREATE INDEX idx_chunks_file ON claude_memory.chunks(file_source);
CREATE INDEX idx_chunks_ns_file ON claude_memory.chunks(namespace, file_source);

-- Fonction de recherche sémantique (avec namespace optionnel)
CREATE OR REPLACE FUNCTION claude_memory.search(
    query_embedding vector(768),
    match_threshold FLOAT DEFAULT 0.7,
    match_count INT DEFAULT 5,
    filter_namespace VARCHAR(100) DEFAULT NULL  -- NULL = tous les namespaces
)
RETURNS TABLE (
    id UUID,
    content TEXT,
    namespace VARCHAR(100),
    file_source VARCHAR(255),
    section VARCHAR(255),
    similarity FLOAT
)
LANGUAGE plpgsql
AS $$
BEGIN
    RETURN QUERY
    SELECT
        c.id,
        c.content,
        c.namespace,
        c.file_source,
        c.section,
        1 - (c.embedding <=> query_embedding) AS similarity
    FROM claude_memory.chunks c
    WHERE 1 - (c.embedding <=> query_embedding) > match_threshold
      AND (filter_namespace IS NULL OR c.namespace = filter_namespace)
    ORDER BY c.embedding <=> query_embedding
    LIMIT match_count;
END;
$$;

-- Fonction pour stats
CREATE OR REPLACE FUNCTION claude_memory.stats()
RETURNS TABLE (
    total_chunks BIGINT,
    total_files BIGINT,
    last_sync TIMESTAMPTZ
)
LANGUAGE plpgsql
AS $$
BEGIN
    RETURN QUERY
    SELECT
        (SELECT COUNT(*) FROM claude_memory.chunks),
        (SELECT COUNT(*) FROM claude_memory.files),
        (SELECT MAX(last_sync) FROM claude_memory.files);
END;
$$;

3. Code source (src/)

src/config.py

"""Configuration centralisée"""
import os
from pathlib import Path

# Chemins
MEMORY_DIR = Path(os.environ.get("MEMORY_DIR", "/data/memory"))

# AI-Orchestrator (pour accès Ollama)
AI_ORCHESTRATOR_URL = os.environ.get("AI_ORCHESTRATOR_URL", "http://192.168.1.12:5501")
EMBEDDING_MODEL = os.environ.get("EMBEDDING_MODEL", "nomic-embed-text")

# Supabase
SUPABASE_URL = os.environ.get("SUPABASE_URL", "https://supabase-api-prod.33800.nowhere84.com")
SUPABASE_KEY = os.environ.get("SUPABASE_SERVICE_KEY")
SUPABASE_SCHEMA = "claude_memory"

# Chunking
CHUNK_SIZE = int(os.environ.get("CHUNK_SIZE", "500"))

src/embeddings.py

"""Client AI-Orchestrator pour embeddings (via Ollama)"""
import requests
import time
from .config import AI_ORCHESTRATOR_URL, EMBEDDING_MODEL

def get_embedding(text: str) -> list[float]:
    """Génère embedding via AI-Orchestrator → Ollama"""

    # Créer un job embedding
    response = requests.post(
        f"{AI_ORCHESTRATOR_URL}/api/jobs",
        json={
            "tool_id": "ollama",
            "job_type": "embeddings",
            "priority": "normal",
            "input_params": {
                "model": EMBEDDING_MODEL,
                "prompt": text
            }
        },
        timeout=10
    )
    response.raise_for_status()
    job = response.json()
    job_id = job["job_id"]

    # Attendre le résultat (polling)
    max_wait = 60  # secondes
    start = time.time()
    while time.time() - start < max_wait:
        status_response = requests.get(
            f"{AI_ORCHESTRATOR_URL}/api/jobs/{job_id}",
            timeout=10
        )
        status = status_response.json()

        if status["status"] == "completed":
            return status["output"]["embedding"]
        elif status["status"] == "failed":
            raise Exception(f"Embedding failed: {status.get('error')}")

        time.sleep(0.5)

    raise TimeoutError(f"Embedding job {job_id} timeout")

def get_embeddings_batch(texts: list[str]) -> list[list[float]]:
    """Génère embeddings en batch (plus efficace)"""
    # TODO: Implémenter batch si ai-orchestrator le supporte
    return [get_embedding(text) for text in texts]

def check_ollama_health() -> bool:
    """Vérifie si Ollama est accessible via ai-orchestrator"""
    try:
        response = requests.get(f"{AI_ORCHESTRATOR_URL}/api/tools", timeout=5)
        tools = response.json()
        ollama = next((t for t in tools if t["id"] == "ollama"), None)
        return ollama and ollama.get("status") == "running"
    except:
        return False

src/sync.py

#!/usr/bin/env python3
"""
Sync memory/*.md vers Supabase pgvector
Usage: python -m src.sync [--force]
"""

import hashlib
from supabase import create_client
from .config import MEMORY_DIR, SUPABASE_URL, SUPABASE_KEY, SUPABASE_SCHEMA, CHUNK_SIZE
from .embeddings import get_embedding

def chunk_markdown(content: str, file_name: str) -> list[dict]:
    """Découpe un fichier markdown en chunks par section"""
    chunks = []
    current_section = "Introduction"
    current_content = []

    for line in content.split('\n'):
        if line.startswith('## '):
            if current_content:
                text = '\n'.join(current_content).strip()
                if text:
                    chunks.append({
                        "content": text,
                        "section": current_section,
                        "file_source": file_name
                    })
            current_section = line[3:].strip()
            current_content = []
        else:
            current_content.append(line)

    if current_content:
        text = '\n'.join(current_content).strip()
        if text:
            chunks.append({
                "content": text,
                "section": current_section,
                "file_source": file_name
            })

    # Sous-découper les gros chunks
    final_chunks = []
    for chunk in chunks:
        if len(chunk["content"]) > CHUNK_SIZE:
            paragraphs = chunk["content"].split('\n\n')
            current = ""
            idx = 0
            for para in paragraphs:
                if len(current) + len(para) > CHUNK_SIZE:
                    if current:
                        final_chunks.append({
                            **chunk,
                            "content": current.strip(),
                            "chunk_index": idx
                        })
                        idx += 1
                    current = para
                else:
                    current += "\n\n" + para if current else para
            if current:
                final_chunks.append({
                    **chunk,
                    "content": current.strip(),
                    "chunk_index": idx
                })
        else:
            chunk["chunk_index"] = 0
            final_chunks.append(chunk)

    return final_chunks

def md5(text: str) -> str:
    return hashlib.md5(text.encode()).hexdigest()

def sync_memory(force: bool = False):
    """Synchronise les fichiers memory vers Supabase"""
    supabase = create_client(SUPABASE_URL, SUPABASE_KEY)

    # Charger checksums existants depuis table files
    existing = {}
    if not force:
        result = supabase.schema(SUPABASE_SCHEMA).table("files").select("file_name,checksum").execute()
        for row in result.data:
            existing[row["file_name"]] = row["checksum"]

    stats = {"synced": 0, "skipped": 0, "chunks": 0}

    for md_file in MEMORY_DIR.glob("*.md"):
        content = md_file.read_text()
        checksum = md5(content)
        file_name = md_file.name

        if not force and existing.get(file_name) == checksum:
            print(f"[SKIP] {file_name}")
            stats["skipped"] += 1
            continue

        print(f"[SYNC] {file_name}")

        # Supprimer anciens chunks
        supabase.schema(SUPABASE_SCHEMA).table("chunks").delete().eq("file_source", file_name).execute()

        # Créer nouveaux chunks
        chunks = chunk_markdown(content, file_name)

        for chunk in chunks:
            embedding = get_embedding(chunk["content"])
            supabase.schema(SUPABASE_SCHEMA).table("chunks").insert({
                "content": chunk["content"],
                "embedding": embedding,
                "file_source": chunk["file_source"],
                "section": chunk["section"],
                "chunk_index": chunk["chunk_index"],
                "checksum": checksum
            }).execute()

        # Mettre à jour table files
        supabase.schema(SUPABASE_SCHEMA).table("files").upsert({
            "file_name": file_name,
            "checksum": checksum,
            "chunks_count": len(chunks)
        }, on_conflict="file_name").execute()

        print(f"  → {len(chunks)} chunks")
        stats["synced"] += 1
        stats["chunks"] += len(chunks)

    print(f"\n[DONE] {stats['synced']} fichiers sync, {stats['skipped']} skipped, {stats['chunks']} chunks total")
    return stats

if __name__ == "__main__":
    import sys
    force = "--force" in sys.argv
    sync_memory(force)

src/api.py

"""API FastAPI pour recherche mémoire"""
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from supabase import create_client
import uvicorn

from .config import SUPABASE_URL, SUPABASE_KEY, SUPABASE_SCHEMA
from .embeddings import get_embedding, check_ollama_health

app = FastAPI(title="Claude Memory API", version="1.0.0")

class SearchRequest(BaseModel):
    query: str
    namespace: str | None = None  # None = recherche tous les namespaces
    limit: int = 5
    threshold: float = 0.7

class SearchResult(BaseModel):
    content: str
    namespace: str
    file_source: str
    section: str
    similarity: float

@app.get("/health")
def health():
    """Health check"""
    return {
        "status": "ok",
        "ollama": check_ollama_health()
    }

@app.get("/stats")
def stats():
    """Statistiques de la mémoire"""
    supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
    result = supabase.schema(SUPABASE_SCHEMA).rpc("stats").execute()
    return result.data[0] if result.data else {}

@app.post("/search", response_model=list[SearchResult])
def search(req: SearchRequest):
    """Recherche sémantique dans la mémoire"""
    if not req.query.strip():
        raise HTTPException(400, "Query cannot be empty")

    # Générer embedding
    embedding = get_embedding(req.query)

    # Recherche (namespace optionnel)
    supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
    result = supabase.schema(SUPABASE_SCHEMA).rpc("search", {
        "query_embedding": embedding,
        "match_threshold": req.threshold,
        "match_count": req.limit,
        "filter_namespace": req.namespace  # None = tous
    }).execute()

    # Log la recherche
    supabase.schema(SUPABASE_SCHEMA).table("search_logs").insert({
        "query": req.query,
        "namespace": req.namespace,
        "results_count": len(result.data),
        "top_similarity": result.data[0]["similarity"] if result.data else None
    }).execute()

    return result.data

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8080)

4. Docker Compose (stack PROD)

Fichier : docker-compose.yml

version: "3.8"

services:
  claude-memory:
    image: registry.33800.nowhere84.com/gouroubleu/claude-memory:latest
    container_name: claude-memory
    restart: unless-stopped
    environment:
      - MEMORY_DIR=/data/memory
      - AI_ORCHESTRATOR_URL=http://192.168.1.12:5501
      - SUPABASE_URL=https://supabase-api-prod.33800.nowhere84.com
      - SUPABASE_SERVICE_KEY=${SUPABASE_SERVICE_KEY}
      - EMBEDDING_MODEL=nomic-embed-text
    volumes:
      # Mount les fichiers memory depuis PVE
      - /mnt/stock_8to/33800-stack/claude/memory:/data/memory:ro
    ports:
      - "5510:8080"
    networks:
      - traefik-public
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.claude-memory.rule=Host(`claude-memory.33800.nowhere84.com`)"
      - "traefik.http.routers.claude-memory.tls=true"
      - "traefik.http.services.claude-memory.loadbalancer.server.port=8080"

networks:
  traefik-public:
    external: true

URL API : https://claude-memory.33800.nowhere84.com

Endpoints :

GET  /health              # Health check + status Ollama
GET  /stats               # Stats mémoire (chunks, fichiers, last sync)
POST /search              # Recherche sémantique

Usage :

curl -X POST https://claude-memory.33800.nowhere84.com/search \
  -H "Content-Type: application/json" \
  -d '{"query": "comment configurer le split-dns", "limit": 5}'

5. Intégration Claude

Script bash pour recherche rapide

#!/bin/bash
# /home/gouroubleu/scripts/claude-search.sh
# Usage: claude-search.sh "ma question"

QUERY="$1"
curl -s -X POST https://claude-memory.33800.nowhere84.com/search \
  -H "Content-Type: application/json" \
  -d "{\"query\": \"$QUERY\", \"limit\": 5}" | jq -r '.[] | "[\(.file_source)] \(.section) (score: \(.similarity | . * 100 | floor)%)\n\(.content)\n---"'

Ajout dans CLAUDE.md

## Recherche Mémoire Vectorielle

Après compression ou si info manquante :
\`\`\`bash
/home/gouroubleu/scripts/claude-search.sh "ta question"
\`\`\`

API directe :
- GET  https://claude-memory.33800.nowhere84.com/stats
- POST https://claude-memory.33800.nowhere84.com/search

6. Synchronisation automatique

Le container claude-memory inclut un cron interne qui sync toutes les 5 minutes.

Fichier crontab (dans le container) :

*/5 * * * * python -m src.sync >> /var/log/sync.log 2>&1

Sync manuel (si besoin) :

docker exec claude-memory python -m src.sync --force

Plan d'exécution

Phase 1 : Évolution ai-orchestrator (support embeddings)

Étape Action
1.1 Cloner ai-orchestrator depuis GitLab
1.2 Ajouter job_type embeddings pour Ollama
1.3 Endpoint: POST /api/jobs avec {"tool_id": "ollama", "job_type": "embeddings", ...}
1.4 Push + CI/CD déploie automatiquement

Code à ajouter dans ai-orchestrator (workers/ollama.py ou équivalent) :

async def handle_ollama_job(job):
    if job["job_type"] == "embeddings":
        response = requests.post(
            f"{OLLAMA_URL}/api/embeddings",
            json={
                "model": job["input_params"]["model"],
                "prompt": job["input_params"]["prompt"]
            }
        )
        return {"embedding": response.json()["embedding"]}

    elif job["job_type"] == "chat":
        # ... existant

Phase 2 : Projet claude-memory

Étape Action
2.1 Créer repo GitLab claude-memory
2.2 Pull modèle nomic-embed-text sur Ollama
2.3 Exécuter migration SQL dans Supabase Studio
2.4 Push code source vers GitLab
2.5 Pipeline CI/CD build + deploy automatique
2.6 Première sync manuelle --force
2.7 Créer script claude-search.sh
2.8 Ajouter entrée nginx + dashboard
2.9 Test end-to-end
2.10 Mettre à jour CLAUDE.md

Prérequis


Résultat attendu

Après compression, Claude peut :

# Recherche sémantique
./claude-search.sh "credentials gitlab"
# → Retourne section credentials de credentials.md

./claude-search.sh "comment déployer un service"
# → Retourne procédures de ci-cd.md et workflows.md

./claude-search.sh "configuration split-dns pihole"
# → Retourne chunks pertinents de pihole-split-dns.md

Stats disponibles :

curl https://claude-memory.33800.nowhere84.com/stats
# {"total_chunks": 150, "total_files": 15, "last_sync": "2026-01-27T20:30:00Z"}

Fichiers à indexer

Dossier Fichiers Priorité
~/.claude/memory/*.md ~15 fichiers HAUTE
~/.claude/CLAUDE.md 1 fichier HAUTE
~/STATUS.md 1 fichier MOYENNE
~/docs/*.md Documentation BASSE (optionnel)

Livrables GitLab

gitlab.33800.nowhere84.com/gouroubleu/claude-memory
├── Code source complet
├── Dockerfile optimisé
├── Pipeline CI/CD fonctionnel
├── Migration SQL
└── README.md

Validation requise


En attente de validation pour exécution.