Home R&D Webnet Transformez vos documents privés en chatbot IA : la méthode simple avec Streamlit, Ollama et ChromaDB

Transformez vos documents privés en chatbot IA : la méthode simple avec Streamlit, Ollama et ChromaDB

  Fanamperantsoa Rakotoasimbola 5 min 10 mars 2026

Transformez vos documents privés en chatbot IA : la méthode simple avec Streamlit, Ollama et ChromaDB

Vous avez des dossiers remplis de PDF et vous rêvez de les interroger comme si vous parliez à un collègue ? Plus besoin de monter une infrastructure complexe avec des API multiples. Aujourd’hui, il est possible de tout centraliser dans une seule application.

Voici comment bâtir un outil de chat documentaire souverain, performant et 100 % local.


1. L’Architecture : le circuit court de l’information

Le processus suit une logique rigoureuse de traitement de la donnée, appelée RAG (Retrieval-Augmented Generation).

Comment l’information circule-t-elle ?

  1. L’Ingestion : vous déposez vos PDF dans l’interface.
  2. Le Découpage (Chunking) : le texte est segmenté en petits morceaux afin que l’IA puisse traiter l’information de manière ciblée sans être saturée.
  3. Le Stockage (ChromaDB) : ces segments sont transformés en vecteurs (représentations mathématiques) et stockés localement dans ChromaDB. C’est votre « bibliothèque de recherche » qui permet de retrouver instantanément un paragraphe à partir de son sens.
  4. La Discussion (Ollama) : quand vous posez une question, l’application recherche les segments les plus pertinents dans ChromaDB. Elle les transmet ensuite à Ollama, qui utilise un modèle de langage pour rédiger une réponse précise basée sur ces extraits.

2. Implémentation de la stack technique

Pour réaliser ce projet, nous n’avons besoin que de trois briques, toutes intégrées en Python :

🧠 Le Cerveau : Ollama

Pour garantir la confidentialité des données, nous privilégions une solution locale plutôt que des services tiers. Cela permet d’utiliser des modèles puissants (comme le modèle IBM Granite4) sans qu’aucune information ne quitte votre ordinateur.

Vous pouvez installer Ollama et le modèle d’IBM en suivant les commandes suivantes :

curl -fsSL https://ollama.com/install.sh | sh

Puis :

ollama pull granite4:latest # ou un autre modèle de votre choix

Une fois l’installation terminée, nous pouvons configurer le projet Python. Nous utiliserons ici uv pour la gestion des dépendances :

# Installer UV
curl -LsSf https://astral.sh/uv/install.sh | sh
# Créer le projet
uv init mon-projet
cd mon-projet

Créez un dossier services, puis les deux fichiers suivants :

  • limiter.py : il servira de limitateur de débit (Rate Limiter) pour stabiliser l’application.
import time
import threading

class RateLimiter:
    """
    Implémente un algorithme de Token Bucket pour limiter le débit.
    Thread-safe pour une utilisation fluide.
    """
    def __init__(self, max_calls_per_second=10):
        self.rate = max_calls_per_second
        self.tokens = max_calls_per_second  
        self.last_update = time.time()
        self.lock = threading.Lock()

    def wait_for_slot(self):
        with self.lock:
            current_time = time.time()
            time_passed = current_time - self.last_update
            self.tokens += time_passed * self.rate

            if self.tokens > self.rate:
                self.tokens = self.rate

            self.last_update = current_time

            if self.tokens >= 1:
                self.tokens -= 1
                return
            else:
                needed = 1 - self.tokens
                wait_time = needed / self.rate
                self.tokens -= 1

        if wait_time > 0:
            print(f"⏳ Limite atteinte. Attente de {wait_time:.4f}s...")
            time.sleep(wait_time)
  • llm.py : l’outil qui appellera l’IA. Installez d’abord les librairies nécessaires :
uv add langchain_ollama langchain

Voici le code de l’agent :

from langchain_ollama import ChatOllama
from langchain.agents import create_agent
from services.limiter import RateLimiter

# 1. SETUP DE L'AGENT PRINCIPAL (OLLAMA)
llm_ollama = ChatOllama(
    model="granite4:7b-a1b-h",
    temperature=0,
    timeout=10
)

TOOLS = []

agent_executor = create_agent(
    model=llm_ollama,
    tools=TOOLS,
    system_prompt="Vous êtes un assistant professionnel. Utilisez les outils si nécessaire."
)

# 2. MANAGER DE L'IA
class AIProviderManager:
    """Gère la logique : Rate Limit -> Agent Local."""
    def __init__(self):
        self.limiter = RateLimiter(max_calls_per_second=10)

    def call(self, messages):
        self.limiter.wait_for_slot()
        try:
            print("🤖 Tentative avec l'agent Ollama...")
            response = agent_executor.invoke({"messages": messages})
            return response['messages'][-1].content
        except Exception as e:
            raise e

🗄 La Mémoire : ChromaDB

Il faut maintenant donner au chatbot l’accès à une base de connaissances alimentée par vos PDF. Pour éviter de surcharger le LLM, nous allons segmenter (« chunker ») les documents via un fichier parser.py dans services/.

Installez la dépendance suivante :

uv add pymupdf4llm
  • parser.py : contient les méthodes pour convertir et découper le texte.
import pymupdf4llm
from typing import List, TypedDict

class Chunk(TypedDict):
    fileName: str
    title: str
    content: List[str]

class Parser:

    def __init__(self):
        return

    def pdf_to_markdown(self, path: str):
        md_text = pymupdf4llm.to_markdown(path)
        return md_text

    def markdown_to_chunks(self, file_name: str, markdown: str) -> List[Chunk]:
        # Séparer par ligne et filtrer les lignes vides
        lines = [line for line in markdown.split("\n") if line.strip() != ""]

        chunks: List[Chunk] = []
        current_chunk: Chunk | None = None

        for line in lines:
            is_title = line.startswith("#")

            current_title_level = len(line.split(" ")[0]) if is_title else 0

            last_title_level = 0
            if current_chunk and current_chunk["title"].startswith("#"):
                last_title_level = len(current_chunk["title"].split(" ")[0])

            if is_title and current_chunk and current_title_level == last_title_level:
                current_chunk["title"] += line
            elif is_title:
                if current_chunk:
                    chunks.append(current_chunk)
                current_chunk = {
                    "fileName": file_name,
                    "title": line,
                    "content": []
                }
            else:
                if current_chunk:
                    current_chunk["content"].append(line)
                else:
                    # Si pas de titre encore, commence un chunk avec un titre par défaut
                    current_chunk = {
                        "fileName": file_name,
                        "title": file_name,
                        "content": [line]
                    }

        if current_chunk:
            chunks.append(current_chunk)

        reduced_chunks: List[Chunk] = []
        for chunk in chunks:
            if len(chunk["content"]) == 1 and len(reduced_chunks) > 0:
                reduced_chunks[-1]["content"].append(chunk["content"][0])
                reduced_chunks[-1]["content"] = list(
                    dict.fromkeys(reduced_chunks[-1]["content"]))
            elif len(chunk["content"]) > 1:
                reduced_chunks.append(chunk)

        chunks = reduced_chunks

        # Si pas de titre dans le fichier entier, fusionne en un seul chunk
        has_real_title = any(chunk["title"] != file_name for chunk in chunks)

        if not has_real_title and len(chunks) > 1:
            # Équivalent de chunks.flatMap((c) => c.content)
            flat_content = [item for c in chunks for item in c["content"]]
            return [
                {
                    "fileName": file_name,
                    "title": file_name,
                    "content": flat_content,
                }
            ]

        return chunks

Créez ensuite un dossier entities/ avec un fichier document.py pour définir le schéma de stockage :

import uuid


class Document:
    def __init__(self, text: str, id: str | None = None, metadata: dict | None = None):
        self.id = id if id is not None else str(uuid.uuid4())
        self.text = text
        self.metadata = metadata

    @staticmethod
    def generateFilter(data: dict):
        return Document("", None, data)

Enfin, créez vectorestore.py pour gérer ChromaDB :

from chromadb import PersistentClient, Collection
from chromadb.utils import embedding_functions
from typing import Optional, List

from entities.document import Document

embed_fn = embedding_functions.DefaultEmbeddingFunction()


class VectoreStore:
    def __init__(self, collection_name: str = "test"):
       
        client = PersistentClient(path="./chroma_store")
       
        self.collection: Optional[Collection] = client.get_or_create_collection(
            name=collection_name)

    def add(self, document: Document):

        self.collection.add(
            ids=[document.id],
            documents=[
                document.text,
            ],
            metadatas=[document.metadata]

        )
        return

    def delete(self, filterData: Document):
        if not filterData.metadata:
            all_ids = self.collection.get(include=[])["ids"]

            self.collection.delete(
                ids=all_ids
            )
            return
        self.collection.delete(
            where=filterData.metadata
        )

    def get(self, text: str, n_results: int = 4):
        results = self.collection.query(
            
            query_texts=[text],
            n_results=n_results  
        )

        documents_results: List[Document] = []

        ids = results['ids'][0]
        documents = results['documents'][0]
        metadata = results['metadatas'][0]

        for i, id in enumerate(ids):
            doc = documents[i]
            document = Document(text=doc, id=id, metadata=metadata)
            documents_results.append(document)

        return documents_results

🎨 L’Interface : Streamlit

C’est le chef d’orchestre qui gère l’interface et la synchronisation.

uv add streamlit

Code de main.py :

import streamlit as st
import os
from services.parser import Parser
from entities.document import Document
from services.vectoreStore import VectoreStore
from services.llm import AIProviderManager

# --- Page Config ---
st.set_page_config(page_title="Contract AI Assistant", layout="wide")
st.title("Chatbot RAG")

# --- Initialization ---

if "store" not in st.session_state:
    st.session_state.store = VectoreStore()
    st.session_state.parser = Parser()
    st.session_state.client = AIProviderManager()
    st.session_state.messages = []
    st.session_state.indexed_file = None

# --- Sidebar: File Upload ---
with st.sidebar:
    st.header("Upload Document")
    uploaded_file = st.file_uploader("Upload a PDF contract", type="pdf")

    if uploaded_file and st.session_state.indexed_file != uploaded_file.name:
        with st.spinner("Parsing and indexing document..."):
            
            temp_path = os.path.join("./temp_", uploaded_file.name)
            os.makedirs("./temp_", exist_ok=True)
            with open(temp_path, "wb") as f:
                f.write(uploaded_file.getbuffer())

            
            print(f"reading : {temp_path} ...")
            markdown = st.session_state.parser.pdf_to_markdown(temp_path)
            print("chunking ...")
            chunks = st.session_state.parser.markdown_to_chunks(
                uploaded_file.name, markdown)

            for chunk in chunks:
                doc = Document(
                    "\n".join(chunk['content']),
                    None,
                    metadata={"title": chunk['title']}
                )
                st.session_state.store.add(doc)
                print(f"{doc.metadata}")

            st.session_state.indexed_file = uploaded_file.name
            st.success("Document indexed!")

# --- Chat Interface ---

for message in st.session_state.messages:
    with st.chat_message(message["role"]):
        st.markdown(message["content"])

# User Input
if prompt := st.chat_input("Ask something about the document..."):
    
    st.session_state.messages.append({"role": "user", "content": prompt})
    with st.chat_message("user"):
        st.markdown(prompt)

    # RAG Logic
    with st.chat_message("assistant"):
        with st.spinner("Thinking..."):
            # 1. Retrieval
            documents = st.session_state.store.get(prompt)
            context = [doc.__dict__ for doc in documents]

            # 2. Augmentation
            full_prompt = f"Context: {context}\n\nQuestion: {prompt}"

            # 3. Generation
            response = st.session_state.client.call(
                {"role": "user", "content": full_prompt})

            st.markdown(response)
            st.session_state.messages.append(
                {"role": "assistant", "content": response})

Lancement de l’application :

Bash

uv run streamlit run main.py

Rendez-vous sur : http://localhost:8501/

 


3. Pourquoi c’est une solution d’avenir ?

Passer par une solution locale et intégrée lève le principal frein à l’adoption de l’IA : la sécurité des données.Confidentialité absolue : vos informations restent sur votre machine ou serveur privé.

  • Maîtrise des coûts : aucun frais par jeton (token). Seule l’énergie consommée compte.
  • Indépendance technologique : vous ne dépendez d’aucun fournisseur externe ou changement de conditions tarifaires.

Conclusion

Le « Chat documentaire » n’est plus un luxe réservé aux géants de la tech. En combinant la souplesse de Streamlit avec la puissance locale d’Ollama, tout professionnel peut désormais transformer sa base de connaissances en un actif interactif et sécurisé.

Lire les articles similaires

Laisser un commentaire

Social Share Buttons and Icons powered by Ultimatelysocial