<!--
  canonical-url: https://www.avonture.be/fr/blog/docusaurus-ollama-tags
  published:     2026-08-31
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# Jouons avec Ollama - Créer un analyseur d'articles de blog avec un LLM local

> Un devcontainer Dockerisé avec deux scripts Python : le premier envoie chaque article Markdown à un modèle Ollama local pour générer des tags, le second compare ces tags entre les articles pour suggérer lesquels devraient se lier entre eux. Aucune API cloud, aucun tag manuel.

---
![Jouons avec Ollama - Créer un analyseur d'articles de blog avec un LLM local](/img/v2/ollama_docusaurus_tags.webp)

**TL;DR**

Un devcontainer qui fait tourner Ollama à côté de deux scripts Python : `01_generate_tags.py` lit chaque article Markdown dans `data/posts`, envoie son contenu à un modèle local et enregistre les tags générés dans un fichier JSON. `02_analyze_and_link.py` compare ensuite les tags entre les articles pour suggérer lesquels devraient être reliés, et indique la fréquence de chaque tag dans le corpus. Tout tourne en local — aucune API cloud, aucun tag manuel.

Tagger ses articles à la main, ça ne passe pas à l'échelle — et repérer, parmi quelques centaines d'articles, ceux qui vont réellement ensemble non plus. Je voulais voir si un modèle local, avec pour seule entrée le Markdown brut, pouvait générer des tags corrects puis utiliser ces tags pour suggérer lui-même des liens internes.

**Quick Jump:** [Le résultat en action](#the-result) | [Installation](#installation)

## Le résultat {#the-result}

Une fois les deux scripts exécutés, `output/all_articles_with_tags.json` contient les tags générés pour chaque article :

![Les tags générés](./images/the_generated_tags.webp)

Le deuxième script compare ensuite ces tags entre les articles. `suggested_interlinks.json` est la première sortie intéressante :

```json
[
  {
    "source_article": "001-intro-to-docker.md",
    "suggested_links": []
  },
  {
    "source_article": "004-local-llms-future.md",
    "suggested_links": ["002-getting-started-with-ollama.md"]
  },
  {
    "source_article": "003-advanced-python.md",
    "suggested_links": []
  },
  {
    "source_article": "002-getting-started-with-ollama.md",
    "suggested_links": ["004-local-llms-future.md"]
  }
]
```

Les articles 002 et 004 sont correctement identifiés comme liés — ils parlent tous les deux de LLM locaux. Les articles 001 et 003 sont laissés de côté, puisqu'ils traitent de sujets sans rapport. Plutôt pas mal !

Le second fichier de sortie, `tag_analysis.json`, montre les tags et leur nombre d'occurrences dans les articles. Sur ce petit échantillon, « local LLMs » apparaît dans deux articles (002 et 004) alors que tous les autres tags n'apparaissent qu'une seule fois :

```json
{
  "total_unique_tags": 23,
  "top_tags": [
    {
      "tag": "local",
      "count": 2
    },
    {
      "tag": "LLMs",
      "count": 2
    },
    {
      "tag": "docker",
      "count": 1
    },
    {
      "tag": "containers",
      "count": 1
    },
    {
      "tag": "images",
      "count": 1
    },
    {
      "tag": "compose",
      "count": 1
    },
    {
      "tag": "future",
      "count": 1
    },
    {
      "tag": "AI",
      "count": 1
    },
    {
      "tag": "development",
      "count": 1
    },
    {
      "tag": "advanced",
      "count": 1
    },
    {
      "tag": "python",
      "count": 1
    },
    {
      "tag": "scripting",
      "count": 1
    },
    {
      "tag": "decorators",
      "count": 1
    },
    {
      "tag": "generators",
      "count": 1
    },
    {
      "tag": "context",
      "count": 1
    },
    {
      "tag": "managers",
      "count": 1
    },
    {
      "tag": "rest",
      "count": 1
    },
    {
      "tag": "api",
      "count": 1
    },
    {
      "tag": "language models",
      "count": 1
    },
    {
      "tag": "Ollama",
      "count": 1
    }
  ]
}
```

## Pourquoi ça fonctionne {#why-it-works}

* Un script par responsabilité : le premier script ne parle qu'au LLM et produit des tags ; le second se contente de lire ces tags et de calculer les relations — aucun script ne fait les deux.
* Les tags sont le seul signal utilisé pour les liens. Deux articles partageant un tag sont considérés comme liés ; plus ils partagent de tags, plus la suggestion est forte.
* Rien ici n'est spécifique à Docusaurus — remplacez `data/posts` par n'importe quel dossier de fichiers Markdown et les deux mêmes scripts produisent le même type d'analyse.

## Installation {#installation}

### Copier la structure de répertoires et les fichiers {#copy-the-directory-structure-and-files}

Lancez la commande suivante dans votre terminal pour copier la structure de répertoires et les fichiers de ce tutoriel :

**Project: /tmp/tags**

> **Note:**

**.devcontainer/devcontainer.json**

```json
{
  "name": "Blog Analyzer Dev Environment",
  "dockerComposeFile": ["../compose.yaml"],
  "service": "app",
  "workspaceFolder": "/workspaces/blog_analyzer",
  "containerUser": "vscode",
  "remoteUser": "vscode",
  "updateRemoteUserUID": true,
  "customizations": {
    "vscode": {
      "settings": {
        "window.title": "${dirty}${rootName} ${separator} [DevContainer]",
        "workbench.colorTheme": "Visual Studio Light",
        "workbench.startupEditor": "terminal"
      },
      "extensions": ["ms-python.python", "ms-python.vscode-pylance"]
    }
  },
  "postCreateCommand": "pip install --user -r requirements.txt"
}
```

**.devcontainer/Dockerfile**

```docker
# Dockerfile for the development environment
FROM mcr.microsoft.com/devcontainers/python:3.11

# Add any OS-level dependencies here if needed
# RUN apt-get update && apt-get install -y ...

# Set the working directory
WORKDIR /workspaces/blog_analyzer
```

**data/posts/001-intro-to-docker.md**

```markdown
# Introduction to Docker

Docker is an open platform for developing, shipping, and running applications. Docker enables you to separate your applications from your infrastructure so you can deliver software quickly. With Docker, you can manage your infrastructure in the same ways you manage your applications. This article covers the basics of Docker containers and images. We also touch upon Docker Compose for multi-container applications.
```

**data/posts/002-getting-started-with-ollama.md**

```markdown
# Getting Started with Ollama

Ollama is a fantastic tool that allows you to run large language models (LLMs) like Llama 3 and Mistral locally on your own machine. This guide will walk you through installing Ollama, pulling your first model, and interacting with it via the command line and its REST API. Local LLMs offer privacy and cost-effectiveness.
```

**data/posts/003-advanced-python.md**

```markdown
# Advanced Python Scripting

This post dives into more advanced topics in Python programming. We will explore decorators, generators, and context managers. These powerful features can make your code more efficient and readable. We will also see how to interact with a REST API using the popular `requests` library, a key skill for any modern developer.
```

**data/posts/004-local-llms-future.md**

```markdown
# Why Local LLMs are the Future

Running Large Language Models (LLMs) locally provides unmatched privacy, control, and cost savings. Tools like Ollama make it easier than ever. This article explores the benefits of a local-first approach to AI development and contrasts it with cloud-based solutions. We will cover hardware requirements and suitable use cases.
```

**output/all\_articles\_with\_tags.json**

```json
[]
```

**scripts/01\_generate\_tags.py**

```python
import json
import os
import requests

from tqdm import tqdm

# --- Configuration ---
OLLAMA_API_URL = "http://ollama:11434/api/generate"
MODEL_NAME = "llama3:8b"

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

OUTPUT_DIR = os.path.join(BASE_DIR, "output")
OUTPUT_FILE = os.path.join(OUTPUT_DIR, "all_articles_with_tags.json")

POSTS_DIRECTORY = os.path.join(BASE_DIR, "data/posts")

def get_tags_for_article(filepath):
    try:
        with open(filepath, "r", encoding="utf-8") as f:
            content = f.read()

        prompt = f"""Analyze this blog post and return EXACTLY this JSON format: {{"tags": ["tag1", "tag2"]}}

        --- CONTENT ---
        {content}
        """

        payload = {
            "model": MODEL_NAME,
            "prompt": prompt,
            "stream": False,
            "format": "json"
        }

        response = requests.post(OLLAMA_API_URL, json=payload)
        response.raise_for_status()

        data = response.json()
        raw_response = data.get("response", "")

        # Parse the JSON returned by the model
        tags_data = json.loads(raw_response)

        # Look for "tags" either at the root or within a nested key
        if "tags" in tags_data:
            return tags_data["tags"]
        elif "response" in tags_data:
            return tags_data["response"].get("tags", [])
        else:
            print(f"\nUnexpected JSON structure for {os.path.basename(filepath)}: {tags_data}")
            return []

    except Exception as e:
        print(f"\nError on {os.path.basename(filepath)}: {e}")
        return None

def main():
    if not os.path.exists(OUTPUT_DIR):
        os.makedirs(OUTPUT_DIR)

    markdown_files = [f for f in os.listdir(POSTS_DIRECTORY) if f.endswith(".md")]
    all_articles_data = []

    for filename in tqdm(markdown_files, desc="Processing"):
        filepath = os.path.join(POSTS_DIRECTORY, filename)
        tags = get_tags_for_article(filepath)
        if tags:
            all_articles_data.append({"filename": filename, "tags": tags})

    with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
        json.dump(all_articles_data, f, indent=4)
    print(f"\nDone ! Saved in {OUTPUT_FILE}")

if __name__ == "__main__":
    main()
```

**scripts/02\_analyze\_and\_link.py**

```python
import json
import os

from collections import Counter

# --- Configuration ---
TOP_TAGS_COUNT = 20

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUTPUT_DIR = os.path.join(BASE_DIR, "output")

ANALYSIS_OUTPUT_FILE = os.path.join(OUTPUT_DIR, "tag_analysis.json")
INTERLINK_OUTPUT_FILE = os.path.join(OUTPUT_DIR, "suggested_interlinks.json")
TAG_DATA_FILE = os.path.join(OUTPUT_DIR, "all_articles_with_tags.json")

def analyze_tags(articles_data):
    """Analyzes tag frequency and identifies top tags."""
    print("--- 1. Analyzing Tag Frequency ---")
    all_tags = [tag for article in articles_data for tag in article["tags"]]

    if not all_tags:
        print("No tags found to analyze.")
        return {}

    tag_counts = Counter(all_tags)
    most_common_tags = tag_counts.most_common(TOP_TAGS_COUNT)

    print(f"Total unique tags: {len(tag_counts)}")
    print(f"Top {TOP_TAGS_COUNT} most effective tags:")
    for tag, count in most_common_tags:
        print(f"- {tag}: ({count} articles)")

    analysis_result = {
        "total_unique_tags": len(tag_counts),
        "top_tags": [{"tag": tag, "count": count} for tag, count in most_common_tags]
    }

    # Save analysis
    with open(ANALYSIS_OUTPUT_FILE, "w", encoding="utf-8") as f:
        json.dump(analysis_result, f, indent=4)
    print(f"Tag analysis saved to {ANALYSIS_OUTPUT_FILE}")

    return tag_counts

def suggest_interlinks(articles_data):
    """Suggests interlinks between articles based on shared tags."""
    print("--- 2. Generating Interlinking Suggestions ---")

    if not articles_data:
        print("No article data to process for interlinking.")
        return

    # Create an inverted index: tag -> [list of article filenames]
    tag_to_articles = {}
    for article in articles_data:
        for tag in article["tags"]:
            if tag not in tag_to_articles:
                tag_to_articles[tag] = []
            tag_to_articles[tag].append(article["filename"])

    interlink_suggestions = []
    for article in articles_data:
        current_filename = article["filename"]
        suggested_links = set()

        for tag in article["tags"]:
            # Find other articles with the same tag
            shared_tag_articles = tag_to_articles.get(tag, [])
            for linked_article in shared_tag_articles:
                if linked_article != current_filename:
                    suggested_links.add(linked_article)

        interlink_suggestions.append({
            "source_article": current_filename,
            "suggested_links": sorted(list(suggested_links))
        })

    # Save suggestions
    with open(INTERLINK_OUTPUT_FILE, "w", encoding="utf-8") as f:
        json.dump(interlink_suggestions, f, indent=4)
    print(f"Interlinking suggestions saved to {INTERLINK_OUTPUT_FILE}")


def main():
    """Main function to load tag data and perform analysis."""
    try:
        with open(TAG_DATA_FILE, "r", encoding="utf-8") as f:
            articles_data = json.load(f)
    except FileNotFoundError:
        print(f"Error: Tag data file not found at {TAG_DATA_FILE}")
        print("Please run 01_generate_tags.py first.")
        return
    except json.JSONDecodeError:
        print(f"Error: Could not parse JSON from {TAG_DATA_FILE}. It might be empty or corrupted.")
        return

    analyze_tags(articles_data)
    suggest_interlinks(articles_data)
    print("Analysis and linking process complete.")

if __name__ == "__main__":
    main()
```

**compose.yaml**

```yaml
# This is the main Docker Compose file.
# It defines two services:
# 1. ollama: The LLM service itself.
# 2. app: A service for our development environment.

services:
  ollama:
    image: ollama/ollama
    ports:
      - "11444:11434"
    volumes:
      - ollama_data:/root/.ollama
    # Uncomment the following lines if you have an NVIDIA GPU and want to use it
    # deploy:
    #   resources:
    #     reservations:
    #       devices:
    #         - driver: nvidia
    #           count: 1
    #           capabilities: [gpu]

  app:
    # This service is for the VS Code Dev Container
    build:
      context: .
      dockerfile: .devcontainer/Dockerfile
    volumes:
      - .:/workspaces/blog_analyzer
    depends_on:
      - ollama
    # Keep the container running
    command: sleep infinity

volumes:
  ollama_data:
```

**requirements.txt**

```txt
# Python dependencies
requests
tqdm
```

Une fois terminé, lancez `code .` pour ouvrir le répertoire courant dans Visual Studio Code puis appuyez sur `Ctrl`+`Shift`+`P` et choisissez `Devcontainers: Reopen in Container` pour ouvrir le projet dans un container de développement.

Le build du container et l'installation des dépendances prendront quelques minutes. Une fois le devcontainer démarré, vous verrez dans Docker Desktop que les deux containers tournent :

![Les containers tournent](./images/containers_are_running.webp)

### Télécharger le modèle LLM {#download-the-llm-model}

Revenez ensuite à votre terminal (sur votre host) et lancez la commande suivante pour télécharger le modèle LLM :

```bash
$ docker exec -it tags-ollama-1 ollama pull llama3:8b
```

![Télécharger le modèle LLM](./images/download_the_llm_model.webp)

### Tester le service Ollama {#test-the-ollama-service}

Nous venons de télécharger le modèle LLM, vérifions maintenant que le service Ollama fonctionne correctement. Lancez la commande suivante dans votre terminal (sur votre host) :

```bash
$ curl --silent http://localhost:11444/api/tags | jq
```

![Test du service Ollama](./images/testing_ollama_tags.webp)

> **Si vous n'avez pas jq:**
>
> La commande `jq` sert à formater la sortie JSON pour la rendre plus lisible. Si `jq` n'est pas installé chez vous, consultez [The jq utility for Linux](/blog/linux-jq) pour apprendre à l'installer et à l'utiliser, ou lancez simplement la commande `curl` sans lui pour voir la réponse JSON brute.

```bash
curl --silent http://localhost:11444/api/generate \
  -d '{\
    "model": "llama3:8b", \
    "prompt": "Explain in one sentence why Docker is so amazing.", \
    "stream": false \
  }' | jq .response
```

![Pourquoi Docker est génial ?](./images/docker_is_great.webp)

Le service fonctionne, et on voit que l'endpoint `tags` est disponible. C'est cet endpoint que nos scripts Python utiliseront pour envoyer le contenu des articles et récupérer les tags générés.

## Autres démos {#more-demos}

### Lancer le script de génération de tags {#run-the-tag-generation-script}

Retournez dans Visual Studio Code, ouvrez le terminal (dans VSCode) et exécutez le fichier `scripts/01_generate_tags.py`. Ce script lit tous les fichiers markdown du répertoire `data/posts`, envoie leur contenu au modèle LLM qui tourne dans le container, et enregistre les tags générés dans un fichier JSON.

![Exécution du script de génération de tags](./images/running_01_generate_tags.webp)

Et effectivement, vous constatez que le fichier `output/all_articles_with_tags.json` a été créé — les tags montrés plus haut dans « Le résultat » sont exactement ce que cette exécution a produit.

> **Port de l'host vs port du container:**
>
> Remarquez que `scripts/01_generate_tags.py` appelle `http://ollama:11434/api/generate`, et non `http://ollama:11444/api/generate` comme les commandes `curl` ci-dessus.
>
> **Sur votre host**, `curl` doit utiliser `11444` — le port publié par `compose.yaml` (`"11444:11434"`). Mais `01_generate_tags.py` tourne **à l'intérieur du container `app`**, sur le même réseau Docker Compose qu'`ollama`. De là, `11444` n'existe pas : sur ce réseau, les containers se joignent via le port du container lui-même, `11434`. Mélanger le nom du service (résolvable uniquement à l'intérieur du réseau Compose) et le port publié sur l'host est un piège classique, et ça se termine par un `Connection refused`.

### Lancer le script d'analyse et de liens {#run-the-analyze-and-link-script}

Le second script, `scripts/02_analyze_and_link.py`, lit les tags générés depuis ce fichier JSON et produit les fichiers `suggested_interlinks.json` et `tag_analysis.json` montrés plus haut.

![Exécution du script d'analyse et de liens](./images/running_02_analyze_and_link.webp)

## Sous le capot (à sauter si vous voulez juste l'utiliser) {#under-the-hood-skip-this-if-you-just-want-to-use-it}

### Choisir le bon modèle {#choosing-the-right-model}

Dans cet article, j'ai pris `llama3:8b` comme point de départ. Mais la vraie force d'Ollama, c'est la possibilité de changer de « cerveau » selon la tâche. Le registre Ollama propose une immense bibliothèque de modèles, mais le bon choix dépend de votre matériel — en particulier de votre RAM et de votre GPU.

#### Comparaison des modèles en un coup d'œil {#model-comparison-at-a-glance}

| Modèle            | Cas d'usage idéal                                       | Avantages                                                   | Inconvénients                                                      | Matériel requis        |
| :---------------- | :------------------------------------------------------ | :---------------------------------------------------------- | :----------------------------------------------------------------- | :--------------------- |
| **Llama 3 (8B)**  | Discussion informelle, scripts simples, tâches rapides. | Extrêmement rapide, léger, tourne presque partout.          | Capacité de raisonnement plus faible sur la logique complexe.      | \~8 Go de RAM          |
| **Mistral (7B)**  | Code, écriture créative, raisonnement général.          | Souvent plus « humain » et plus concis.                     | Peut être moins rigoureux que Llama 3 sur les données structurées. | \~8 Go de RAM          |
| **Llama 3 (70B)** | Analyse approfondie, code complexe, raisonnement lourd. | Intelligence proche de l'humain, très peu d'hallucinations. | Gourmand en ressources ; génération plus lente.                    | \~48 Go de RAM et plus |

#### Lequel choisir ? {#which-one-should-you-pick}

* **Prenez `llama3:8b`** si vous débutez ou si votre RAM est limitée. Il est parfait pour du prototypage rapide et des tâches d'automatisation simples qui ne demandent pas de raisonnement logique poussé.
* **Essayez `mistral`** si vous trouvez les réponses de Llama 3 un peu trop rigides. Beaucoup de développeurs préfèrent Mistral pour son côté créatif et son efficacité. C'est un excellent modèle « du quotidien » pour l'aide au code en général.
* **Passez à `llama3:70b`** si vous faites des tâches qui exigent une grande précision — refactoring de bases de code complexes, débogage logique poussé, ou traitement de gros jeux de données où la justesse est critique. Ce modèle étant énorme, il réduit fortement les « hallucinations », ce qui en fait le choix le plus fiable pour un travail professionnel.

#### Télécharger vos modèles {#downloading-your-models}

Vous pouvez ajouter n'importe lequel de ces modèles à votre environnement local immédiatement. Lancez simplement les commandes suivantes dans votre terminal :

**Pour récupérer le modèle standard Llama 3 (8B) :**

```bash
$ docker exec -it tags-ollama-1 ollama pull llama3:8b
```

**Pour récupérer le très performant Llama 3 (70B) :**

```bash
$ docker exec -it tags-ollama-1 ollama pull llama3:70b
```

**Pour récupérer le modèle Mistral :**

```bash
$ docker exec -it tags-ollama-1 ollama pull mistral
```

> **Note :** le modèle `70b` est bien plus gros (\~40 Go). Assurez-vous d'avoir assez d'espace disque et, surtout, au moins 48 Go de RAM disponibles pour que le modèle tourne sans ralentir votre système.

> **Pas assez de RAM ?:**
>
> Sur ma machine avec 64 Go de RAM, je peux faire tourner le modèle `70b`, mais il consomme beaucoup de ressources. Avec moins de RAM, mieux vaut rester sur `8b` ou `mistral` pour une expérience plus fluide.

## Conclusion {#conclusion}

Deux petits scripts Python et un modèle local ont transformé un dossier d'articles Markdown en un index de tags et une série de suggestions de liens internes — aucune API cloud, aucun tag manuel, et rien dans ce pipeline n'est spécifique à Docusaurus. Faites pointer `data/posts` vers n'importe quel dossier de fichiers Markdown et les deux mêmes scripts feront le même travail.

Transformer une suggestion du type « les articles 002 et 004 sont liés » en un vrai `<Link>` dans le texte reste ici une étape manuelle — la suite logique, c'est que le second script propose la phrase exacte et le texte du lien, et pas seulement la paire de fichiers.
