<!--
  canonical-url: https://www.avonture.be/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.
-->

# Playing with Ollama - Create a blog post analyzer with a local LLM

> A Dockerized devcontainer with two Python scripts: the first sends each Markdown blog post to a local Ollama model to generate tags, the second compares those tags across posts to suggest which articles should link to each other. No cloud API, no manual tagging.

---
![Playing with Ollama - Create a blog post analyzer with a local LLM](/img/v2/ollama_docusaurus_tags.webp)

**TL;DR**

A devcontainer running Ollama alongside two Python scripts: `01_generate_tags.py` reads every Markdown post in `data/posts`, sends its content to a local model, and saves the generated tags to a JSON file. `02_analyze_and_link.py` then compares tags across posts to suggest which articles should be interlinked, and how often each tag occurs across the corpus. Everything runs locally — no cloud API, no manual tagging.

Tagging blog posts by hand does not scale — and neither does spotting, across a few hundred posts, which ones actually belong together. I wanted to see whether a local model, given nothing but the raw Markdown, could generate reasonable tags and then use those tags to suggest interlinks on its own.

**Quick Jump:** [See it in Action](#the-result) | [Installation](#installation)

## The Result

Once both scripts have run, `output/all_articles_with_tags.json` holds the generated tags for every post:

![The generated tags](./images/the_generated_tags.webp)

The second script then compares those tags across posts. `suggested_interlinks.json` is the first output worth looking at:

```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"]
  }
]
```

Articles 002 and 004 are correctly identified as related — they both talk about local LLMs. Articles 001 and 003 are left alone, since they cover unrelated topics. Sounds good!

The second output file, `tag_analysis.json`, shows the tags and their occurrences across the articles. In this small sample, "local LLMs" appears in two articles (002 and 004) while every other tag appears in only one:

```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
    }
  ]
}
```

## Why It Works

* One script per responsibility: the first script only talks to the LLM and produces tags; the second only reads those tags and computes relationships — no single script does both.
* Tags are the only signal used for linking. Two posts sharing a tag are considered related; the more tags they share, the stronger the suggestion.
* Nothing here is Docusaurus-specific — swap `data/posts` for any folder of Markdown files and the same two scripts produce the same kind of analysis.

## Installation

### Copy the directory structure and files

Please run the following command in your terminal to copy the directory structure and files for this tutorial:

**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
```

Once done, please run `code .` to open the current directory in Visual Studio Code then press `Ctrl`+`Shift`+`P` and select `Devcontainers: Reopen in Container` to open the project in a development container.

It will take a few minutes to build the container and install the dependencies. Once the devcontainer is running, you'll see in your Docker Desktop that the two containers are running:

![The containers are running](./images/containers_are_running.webp)

### Download the LLM model

Then, return to your terminal (on your host) and run the following command to download the LLM model:

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

![Download the LLM model](./images/download_the_llm_model.webp)

### Test the Ollama service

So we've just downloaded the LLM model, now let's test that the Ollama service is working correctly. Run the following command in your terminal (on your host):

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

![Testing the Ollama service](./images/testing_ollama_tags.webp)

> **If you don't have jq:**
>
> The `jq` command is used to format the JSON output for better readability. If you don't have `jq` installed, check out [The jq utility for Linux](/blog/linux-jq) to learn how to install and use it, or simply run the `curl` command without it to see the raw JSON response.

```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
```

![Why Docker is great?](./images/docker_is_great.webp)

Ok, the service is working, and we can see that the `tags` endpoint is available. This endpoint will be used by our Python scripts to send the content of the blog posts and receive the generated tags.

## More Demos

### Run the tag generation script

Go back to Visual Studio Code, jump in the terminal (in VSCode) and run the `scripts/01_generate_tags.py` file. This script will read all the markdown files in the `data/posts` directory, send their content to the LLM model running in the container, and save the generated tags in a JSON file.

![Running the tag generation script](./images/running_01_generate_tags.webp)

And, indeed, you can see that the `output/all_articles_with_tags.json` file has been created — the tags shown earlier under "The Result" are exactly what this run produced.

> **Host port vs container port:**
>
> Notice that `scripts/01_generate_tags.py` calls `http://ollama:11434/api/generate`, not `http://ollama:11444/api/generate` like the `curl` commands above.
>
> **On your host**, `curl` must use `11444` — the port published by `compose.yaml` (`"11444:11434"`). But `01_generate_tags.py` runs **inside the `app` container**, on the same Docker Compose network as `ollama`. From there, `11444` doesn't exist: containers on that network reach each other through the container's own port, `11434`. Mixing the service name (only resolvable inside the Compose network) with the host-published port is a classic trap, and it results in a `Connection refused`.

### Run the analyze and link script

The second script, `scripts/02_analyze_and_link.py`, reads the generated tags from that JSON file and produces the `suggested_interlinks.json` and `tag_analysis.json` files shown above.

![Running the analyze and link script](./images/running_02_analyze_and_link.webp)

## Under the Hood (skip this if you just want to use it)

### Choosing the Right Model

In this article, I used `llama3:8b` as a starting point. However, the true beauty of Ollama lies in the flexibility to swap "brains" depending on the task at hand. The Ollama registry offers a vast library of models, but choosing the right one depends on your available hardware — specifically your RAM and GPU.

#### Model Comparison at a Glance

| Model             | Ideal Use Case                                  | Pros                                                  | Cons                                                   | Hardware Requirement |
| :---------------- | :---------------------------------------------- | :---------------------------------------------------- | :----------------------------------------------------- | :------------------- |
| **Llama 3 (8B)**  | Casual chat, simple scripts, fast tasks.        | Extremely fast, lightweight, runs on almost anything. | Lower reasoning capability for complex logic.          | \~8GB RAM            |
| **Mistral (7B)**  | Coding, creative writing, general reasoning.    | Often more "human-sounding" and concise.              | Can be less rigorous than Llama 3 for structured data. | \~8GB RAM            |
| **Llama 3 (70B)** | Deep analysis, complex coding, heavy reasoning. | Near-human intelligence, very low hallucination rate. | Resource-intensive; slower generation.                 | \~48GB+ RAM          |

#### Which one should you pick?

* **Go for `llama3:8b`** if you are just getting started or have limited RAM. It is perfect for rapid prototyping and simple automation tasks that don't require deep logical reasoning.
* **Try `mistral`** if you find Llama 3's responses a bit too rigid. Many developers prefer Mistral for its creative flair and efficiency. It's an excellent "daily driver" for general coding support.
* **Scale up to `llama3:70b`** if you are performing tasks that demand high precision — such as refactoring complex codebases, deep logical debugging, or processing large datasets where accuracy is critical. Because this model is massive, it significantly reduces "hallucinations," making it the most reliable choice for professional work.

#### Downloading your models

You can add any of these models to your local environment instantly. Simply run the following commands in your terminal:

**To pull the standard Llama 3 (8B) model:**

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

**To pull the highly capable Llama 3 (70B) model:**

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

**To pull the Mistral model:**

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

> **Note:** The `70b` model is significantly larger (\~40GB). Ensure you have enough disk space and, more importantly, at least 48GB of RAM available to ensure the model runs smoothly without slowing down your system.

> **Not enough RAM?:**
>
> On my machine with 64GB of RAM, I can run the `70b` model, but it does consume a lot of resources. If you have less RAM, you might want to stick with the `8b` or `mistral` models for a smoother experience.

## Conclusion

Two small Python scripts and a local model turned a folder of Markdown posts into a tag index and a set of interlink suggestions — no cloud API, no manual tagging, and nothing about the pipeline is specific to Docusaurus. Point `data/posts` at any folder of Markdown files and the same two scripts do the same job.

Turning a suggestion like "articles 002 and 004 are related" into an actual `<Link>` in the prose is still a manual step here — the natural next iteration is having the second script propose the exact sentence and anchor text, not just the pair of files.
