How to Build a Local AI Wiki with Markdown + GPT + SQLite

Young girls focused on learning in a computer lab, showcasing modern education.
Young girls focused on learning in a computer lab, showcasing modern education.

How to Build a Local AI Wiki with Markdown + GPT + SQLite

Navigating the ever-growing sea of information, from development notes to research findings, can feel like a losing battle. Screenshots, ephemeral chat messages, and fragmented notes pile up, making true knowledge retrieval a nightmare. What if you could have a personal, private wiki that understands natural language, finds relevant information even if you don’t remember keywords, and summarizes complex topics on demand?

Enter the Local AI Wiki. This post details how to build exactly that, leveraging the simplicity of Markdown, the robustness of SQLite, and the intelligence of Large Language Models (LLMs) like GPT. It’s a local-first approach, meaning your data stays private, accessible offline, and free from recurring cloud costs.

Why This Stack?

  • Markdown: The universal standard for human-readable, plain-text documents. Simple, portable, and future-proof. You already write in it, so why not store your knowledge this way?
  • SQLite: A zero-configuration, transactional, file-based database. It’s perfect for local applications where you don’t want to manage a server. It’s fast, reliable, and Python has excellent built-in support for it.
  • GPT (or other LLMs): The brain of our wiki. LLMs can generate “embeddings” (numerical representations of text meaning) for semantic search, and process retrieved information for summarization or Q&A. While we’ll use OpenAI’s API for simplicity in examples, the architecture easily supports local LLMs via tools like Ollama for true privacy and offline capability.

This combination gives you a powerful, yet incredibly simple, local knowledge management system.

Project Setup & Prerequisites

We’ll use Python for our scripts. Ensure you have Python 3.8+ installed.

First, create a project directory and set up a virtual environment:

mkdir local-ai-wiki
cd local-ai-wiki
python3 -m venv venv
source venv/bin/activate # On Windows, use `venv\Scripts\activate`

Now, install the necessary Python packages. We’ll need python-dotenv for managing API keys, openai for interacting with OpenAI’s models, and markdown if we want to parse Markdown explicitly (though we’ll mainly treat it as plain text for ingestion).

pip install python-dotenv openai markdown
Collecting python-dotenv
  Downloading python_dotenv-1.0.0-py3-none-any.whl (19 kB)
Collecting openai
  Downloading openai-1.3.7-py3-none-any.whl (226 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 226.7/226.7 KB 2.2 MB/s eta 0:00:00
Collecting markdown
  Downloading Markdown-3.5-py3-none-any.whl (99 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 99.7/99.7 KB 1.9 MB/s eta 0:00:00
... (truncated output)
Successfully installed markdown-3.5 openai-1.3.7 python-dotenv-1.0.0

Next, create a .env file in your local-ai-wiki directory to store your OpenAI API key. Get your key from the OpenAI API Keys page.

# .env
OPENAI_API_KEY="sk-your-openai-api-key-here"

Step 1: Designing the SQLite Database Schema

Our database will store the raw Markdown content, file metadata, and importantly, the embeddings generated by our LLM. Storing embeddings directly in SQLite as BLOB is perfectly suitable for a local, personal wiki.

We’ll create a documents table:

  • id: Primary key for unique identification.
  • title: The title of the document (can be derived from filename or Markdown front matter).
  • filepath: The original path to the Markdown file, ensuring uniqueness and allowing easy lookup.
  • content: The full text content of the Markdown file.
  • embedding: The vector embedding generated by the LLM, stored as a binary BLOB.
  • last_modified: Timestamp of when the file was last modified, useful for incremental updates.

Let’s create a Python script db_init.py to set this up:

# db_init.py
import sqlite3
import os

DATABASE_FILE = 'wiki.db'

def init_db():
    conn = sqlite3.connect(DATABASE_FILE)
    cursor = conn.cursor()

    cursor.execute('''
        CREATE TABLE IF NOT EXISTS documents (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            title TEXT NOT NULL,
            filepath TEXT UNIQUE NOT NULL,
            content TEXT NOT NULL,
            embedding BLOB,
            last_modified TEXT NOT NULL
        )
    ''')
    conn.commit()
    conn.close()
    print(f"Database '{DATABASE_FILE}' initialized successfully.")

if __name__ == '__main__':
    init_db()

Run this script to create your database:

python db_init.py
Database 'wiki.db' initialized successfully.

You should now see a wiki.db file in your project directory.

Step 2: Ingesting Markdown Files & Generating Embeddings

This is the core of our wiki. We’ll write a script that walks through a directory of Markdown files, reads their content, generates embeddings using the OpenAI API, and stores everything in our SQLite database.

First, let’s create a sample wiki_content directory and add a few Markdown files:

mkdir wiki_content
<!-- wiki_content/linux_basics.md -->
# Linux Command Line Basics

## Navigating the File System
- `pwd`: Print Working Directory. Shows your current location.
- `ls`: List files in the current directory. Use `ls -l` for long format, `ls -a` for all files (including hidden).
- `cd <directory>`: Change Directory. `cd ..` moves up one level. `cd ~` goes to home.

## File Operations
- `touch <filename>`: Create an empty file.
- `cp <source> <destination>`: Copy files or directories.
- `mv <source> <destination>`: Move or rename files/directories.
- `rm <file>`: Remove (delete) a file. Use `rm -rf <directory>` with extreme caution to remove directories recursively.

## Permissions
- `chmod`: Change file permissions. E.g., `chmod +x script.sh` makes a script executable.
- `chown`: Change file ownership.

## Process Management
- `ps aux`: List running processes.
- `kill <PID>`: Terminate a process by Process ID.
- `top`: Interactive process viewer.
<!-- wiki_content/python_decorators.md -->
# Python Decorators Explained

Decorators in Python are a powerful and elegant way to modify or enhance functions or methods. They allow you to "wrap" another function, adding functionality before or after the wrapped function runs, without permanently modifying it.

## Basic Structure
A decorator is essentially a function that takes another function as an argument and returns a new function (or the modified original function).

```python
def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("Something is happening before the function is called.")
        result = func(*args, **kwargs)
        print("Something is happening after the function is called.")
        return result
    return wrapper

@my_decorator
def say_hello():
    print("Hello!")

say_hello()

Use Cases

  • Logging
  • Authentication
  • Caching
  • Measuring execution time

Now, the `ingest.py` script. We'll use OpenAI's `text-embedding-ada-002` model, which is cost-effective and performs well.

```python
# ingest.py
import sqlite3
import os
import datetime
from dotenv import load_dotenv
from openai import OpenAI
import time

load_dotenv() # Load environment variables from .env

DATABASE_FILE = 'wiki.db'
WIKI_CONTENT_DIR = 'wiki_content'
EMBEDDING_MODEL = "text-embedding-ada-002"
OPENAI_CLIENT = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

def get_embedding(text, model=EMBEDDING_MODEL):
    """Generates an embedding for the given text using OpenAI API."""
    try:
        text = text.replace("\n", " ") # Replace newlines for better embedding quality
        response = OPENAI_CLIENT.embeddings.create(input=[text], model=model)
        return response.data[0].embedding
    except Exception as e:
        print(f"Error getting embedding for text: {e}")
        return None

def ingest_document(filepath):
    """Ingests a single Markdown file into the database."""
    conn = sqlite3.connect(DATABASE_FILE)
    cursor = conn.cursor()

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

        title = os.path.basename(filepath).replace('.md', '').replace('_', ' ').title()
        last_modified = datetime.datetime.fromtimestamp(os.path.getmtime(filepath)).isoformat()

        # Check if document already exists and if it's modified
        cursor.execute("SELECT last_modified FROM documents WHERE filepath = ?", (filepath,))
        existing_doc = cursor.fetchone()

        if existing_doc and existing_doc[0] == last_modified:
            print(f"Skipping '{filepath}': No changes detected.")
            return

        print(f"Processing '{filepath}'...")
        embedding = get_embedding(content)

        if embedding is None:
            print(f"Failed to get embedding for '{filepath}'. Skipping.")
            return

        embedding_bytes = sqlite3.Binary(bytearray(list(embedding))) # Convert list of floats to bytes

        if existing_doc:
            # Update existing document
            cursor.execute('''
                UPDATE documents
                SET title = ?, content = ?, embedding = ?, last_modified = ?
                WHERE filepath = ?
            ''', (title, content, embedding_bytes, last_modified, filepath))
            print(f"Updated '{filepath}' in database.")
        else:
            # Insert new document
            cursor.execute('''
                INSERT INTO documents (title, filepath, content, embedding, last_modified)
                VALUES (?, ?, ?, ?, ?)
            ''', (title, filepath, content, embedding_bytes, last_modified))
            print(f"Ingested '{filepath}' into database.")

        conn.commit()

    except Exception as e:
        print(f"Error ingesting '{filepath}': {e}")
    finally:
        conn.close()

def ingest_all_markdown_files(directory=WIKI_CONTENT_DIR):
    """Walks through a directory and ingests all Markdown files."""
    if not os.path.exists(directory):
        print(f"Error: Directory '{directory}' not found.")
        return

    for root, _, files in os.walk(directory):
        for file in files:
            if file.endswith('.md'):
                filepath = os.path.join(root, file)
                ingest_document(filepath)
                time.sleep(0.1) # Be kind to the API rate limits

if __name__ == '__main__':
    ingest_all_markdown_files()

Now, run the ingestion script:

python ingest.py
Processing 'wiki_content/linux_basics.md'...
Ingested 'wiki_content/linux_basics.md' into database.
Processing 'wiki_content/python_decorators.md'...
Ingested 'wiki_content/python_decorators.md' into database.

If you run it again without modifying files, you’ll see:

Skipping 'wiki_content/linux_basics.md': No changes detected.
Skipping 'wiki_content/python_decorators.md': No changes detected.

This ensures efficient incremental updates.

Note: Storing embeddings as BLOB is simple, but querying them requires loading them into memory and performing vector similarity search in Python. For extremely large datasets, dedicated vector databases (e.g., Qdrant, Milvus, ChromaDB) or SQLite extensions (e.g., vss) might be more performant, but for a local personal wiki, this approach is perfectly fine.

Step 3: Building the Query Engine (Semantic Search + LLM Response)

This is where the AI magic truly shines. Instead of keyword-matching, we’ll perform a semantic search. We’ll take a natural language query, generate an embedding for it, and then find documents whose embeddings are “closest” (most similar) to the query embedding.

Once we have the most relevant documents, we’ll feed their content to an LLM along with the original query to generate a concise, context-aware answer or summary. This technique is known as Retrieval Augmented Generation (RAG).

To calculate similarity between embeddings, we’ll use cosine similarity.

# query.py
import sqlite3
import os
from dotenv import load_dotenv
from openai import OpenAI
import numpy as np

load_dotenv()

DATABASE_FILE = 'wiki.db'
EMBEDDING_MODEL = "text-embedding-ada-002"
OPENAI_CLIENT = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
CHAT_MODEL = "gpt-3.5-turbo" # Or gpt-4 if you prefer

def get_embedding(text, model=EMBEDDING_MODEL):
    """Generates an embedding for the given text using OpenAI API."""
    text = text.replace("\n", " ")
    response = OPENAI_CLIENT.embeddings.create(input=[text], model=model)
    return np.array(response.data[0].embedding) # Convert to numpy array for vector operations

def cosine_similarity(v1, v2):
    """Calculates cosine similarity between two vectors."""
    return np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2))

def semantic_search(query_text, top_k=3):
    """Performs semantic search on the database."""
    query_embedding = get_embedding(query_text)
    if query_embedding is None:
        print("Failed to get embedding for query.")
        return []

    conn = sqlite3.connect(DATABASE_FILE)
    cursor = conn.cursor()

    cursor.execute("SELECT title, content, embedding FROM documents")
    documents = cursor.fetchall()

    results = []
    for title, content, embedding_bytes in documents:
        # Convert BLOB back to numpy array
        doc_embedding = np.frombuffer(embedding_bytes, dtype=np.float32) # Ensure correct dtype
        similarity = cosine_similarity(query_embedding, doc_embedding)
        results.append((title, content, similarity))

    conn.close()

    # Sort by similarity in descending order and return top_k
    results.sort(key=lambda x: x[2], reverse=True)
    return results[:top_k]

def ask_llm(query, context_docs):
    """Asks the LLM a question based on retrieved context."""
    if not context_docs:
        return "No relevant documents found to answer your question."

    context = "\n\n".join([f"### {doc[0]}\n{doc[1]}" for doc in context_docs])

    messages = [
        {"role": "system", "content": "You are a helpful assistant for a personal knowledge base. Answer the user's question based *only* on the provided context. If the answer isn't in the context, say so gracefully."},
        {"role": "user", "content": f"Context:\n{context}\n\nQuestion: {query}"}
    ]

    try:
        response = OPENAI_CLIENT.chat.completions.create(
            model=CHAT_MODEL,
            messages=messages,
            max_tokens=500,
            temperature=0.3 # Keep temperature low for factual answers
        )
        return response.choices[0].message.content.strip()
    except Exception as e:
        return f"Error communicating with LLM: {e}"

if __name__ == '__main__':
    while True:
        query = input("Ask your wiki (type 'quit' to exit): ")
        if query.lower() == 'quit':
            break

        print("\nSearching relevant documents...")
        relevant_docs = semantic_search(query, top_k=2) # Get top 2 most relevant documents

        if relevant_docs:
            print("\n--- Relevant Documents Found ---")
            for i, (title, _, sim) in enumerate(relevant_docs):
                print(f"{i+1}. {title} (Similarity: {sim:.4f})")
            print("--------------------------------")

            print("\nGenerating AI response...")
            # We pass the full content of the top documents to the LLM
            llm_response = ask_llm(query, relevant_docs)
            print("\n--- AI Response ---")
            print(llm_response)
            print("-------------------\n")
        else:
            print("No relevant documents found for your query.")

Run the query script:

python query.py
Ask your wiki (type 'quit' to exit): what are python decorators?

Searching relevant documents...

--- Relevant Documents Found ---
1. Python Decorators Explained (Similarity: 0.8654)
2. Linux Command Line Basics (Similarity: 0.7012)
--------------------------------

Generating AI response...

--- AI Response ---
Python decorators are a powerful and elegant way to modify or enhance functions or methods. They allow you to "wrap" another function, adding functionality before or after the wrapped function runs, without permanently modifying it. A decorator is essentially a function that takes another function as an argument and returns a new function (or the modified original function). Common use cases include logging, authentication, caching, and measuring execution time.
-------------------

Ask your wiki (type 'quit' to exit): basic linux commands

Searching relevant documents...

--- Relevant Documents Found ---
1. Linux Command Line Basics (Similarity: 0.8687)
2. Python Decorators Explained (Similarity: 0.7018)
--------------------------------

Generating AI response...

--- AI Response ---
Based on the context, here are some basic Linux command line commands:

**Navigating the File System:**
*   `pwd`: Print Working Directory, shows your current location.
*   `ls`: List files in the current directory. Use `ls -l` for long format, `ls -a` for all files (including hidden).
*   `cd <directory>`: Change Directory. `cd ..` moves up one level. `cd ~` goes to home.

**File Operations:**
*   `touch <filename>`: Create an empty file.
*   `cp <source> <destination>`: Copy files or directories.
*   `mv <source> <destination>`: Move or rename files/directories.
*   `rm <file>`: Remove (delete) a file. Use `rm -rf <directory>` with extreme caution to remove directories recursively.

**Permissions:**
*   `chmod`: Change file permissions. E.g., `chmod +x script.sh` makes a script executable.
*   `chown`: Change file ownership.

**Process Management:**
*   `ps aux`: List running processes.
*   `kill <PID>`: Terminate a process by Process ID.
*   `top`: Interactive process viewer.
-------------------

Ask your wiki (type 'quit' to exit): tell me about caching

Searching relevant documents...

--- Relevant Documents Found ---
1. Python Decorators Explained (Similarity: 0.7718)
2. Linux Command Line Basics (Similarity: 0.7061)
--------------------------------

Generating AI response...

--- AI Response ---
Based on the provided context, caching is mentioned as one of the use cases for Python decorators. Decorators can be used to add caching functionality to functions, meaning that the result of a function call can be stored and reused for subsequent calls with the same arguments, rather than re-executing the function.
-------------------

As you can see, the AI understands the query tell me about caching even though “caching” isn’t the primary topic of the python_decorators.md file. It successfully finds the relevant document and extracts the specific information about caching being a use case for decorators. This is the power of semantic search combined with RAG!

Step 4: A Simple CLI Tool

To make this easier to use, let’s combine our scripts into a single CLI tool using Python’s argparse.

# wiki.py
import argparse
import db_init
import ingest
import query
import os

def main():
    parser = argparse.ArgumentParser(description="Local AI Wiki CLI Tool")
    subparsers = parser.add_subparsers(dest='command', help='Available commands')

    # Init DB command
    parser_init = subparsers.add_parser('init-db', help='Initialize the SQLite database.')

    # Ingest command
    parser_ingest = subparsers.add_parser('ingest', help='Ingest Markdown files into the database.')
    parser_ingest.add_argument('--dir', type=str, default='wiki_content',
                                help='Directory containing Markdown files to ingest.')

    # Query command
    parser_query = subparsers.add_parser('query', help='Query the wiki using natural language.')
    parser_query.add_argument('query_text', nargs='?', type=str,
                                help='The natural language query to search for. If omitted, enters interactive mode.')
    parser_query.add_argument('--top-k', type=int, default=3,
                                help='Number of top relevant documents to retrieve for the AI response.')

    args = parser.parse_args()

    if args.command == 'init-db':
        db_init.init_db()
    elif args.command == 'ingest':
        ingest.ingest_all_markdown_files(args.dir)
    elif args.command == 'query':
        if args.query_text:
            print(f"Searching for: '{args.query_text}'")
            relevant_docs = query.semantic_search(args.query_text, top_k=args.top_k)
            if relevant_docs:
                print("\n--- Relevant Documents Found ---")
                for i, (title, _, sim) in enumerate(relevant_docs):
                    print(f"{i+1}. {title} (Similarity: {sim:.4f})")
                print("--------------------------------")

                print("\nGenerating AI response...")
                llm_response = query.ask_llm(args.query_text, relevant_docs)
                print("\n--- AI Response ---")
                print(llm_response)
                print("-------------------\n")
            else:
                print("No relevant documents found for your query.")
        else:
            # Interactive mode
            print("Entering interactive query mode. Type 'quit' to exit.")
            while True:
                user_query = input("Ask your wiki (type 'quit' to exit): ")
                if user_query.lower() == 'quit':
                    break

                print("\nSearching relevant documents...")
                relevant_docs = query.semantic_search(user_query, top_k=args.top_k)

                if relevant_docs:
                    print("\n--- Relevant Documents Found ---")
                    for i, (title, _, sim) in enumerate(relevant_docs):
                        print(f"{i+1}. {title} (Similarity: {sim:.4f})")
                    print("--------------------------------")

                    print("\nGenerating AI response...")
                    llm_response = query.ask_llm(user_query, relevant_docs)
                    print("\n--- AI Response ---")
                    print(llm_response)
                    print("-------------------\n")
                else:
                    print("No relevant documents found for your query.")
    else:
        parser.print_help()

if __name__ == '__main__':
    main()

Now you can use single commands:

# Initialize DB (if not already done)
python wiki.py init-db

# Ingest all markdown files
python wiki.py ingest

# Query directly from command line
python wiki.py query "how do python decorators work?"
Searching for: 'how do python decorators work?'

--- Relevant Documents Found ---
1. Python Decorators Explained (Similarity: 0.8654)
2. Linux Command Line Basics (Similarity: 0.7012)
--------------------------------

Generating AI response...

--- AI Response ---
Python decorators are a way to modify or enhance functions or methods by "wrapping" them, adding functionality before or after the wrapped function runs without permanently changing it. They are functions that take another function as an argument and return a new function.
-------------------
# Enter interactive query mode
python wiki.py query
Entering interactive query mode. Type 'quit' to exit.
Ask your wiki (type 'quit' to exit): what's a good way to manage Linux processes?

Searching relevant documents...

--- Relevant Documents Found ---
1. Linux Command Line Basics (Similarity: 0.8105)
2. Python Decorators Explained (Similarity: 0.6975)
--------------------------------

Generating AI response...

--- AI Response ---
To manage Linux processes, you can use commands like:
*   `ps aux`: To list running processes.
*   `kill <PID>`: To terminate a process by its Process ID.
*   `top`: An interactive process viewer.
-------------------

Ask your wiki (type 'quit' to exit): quit

Advanced Considerations & Next Steps

This is a solid foundation, but you can extend it in many ways:

  1. Local LLMs with Ollama: For true privacy and offline use, integrate with Ollama. Instead of openai.OpenAI(), you’d use openai.OpenAI(base_url="http://localhost:11434/v1", api_key="ollama") and specify a local model (e.g., nomic-embed-text for embeddings, llama2 or mistral for chat completions).
  2. Markdown Front Matter Parsing: Enhance ingest.py to parse YAML front matter (e.g., title: My Custom Title, tags: [Python, AI]) for richer metadata. Python’s frontmatter library can help.
  3. Full-Text Search (FTS5): For keyword-based search alongside semantic search, leverage SQLite’s built-in FTS5 extension. It’s excellent for fast, fuzzy text matching.
  4. UI Layer: If you want a web interface, consider a simple Flask or Streamlit app to serve your wiki, allowing browser-based interaction.
  5. Versioning: Use Git to manage your wiki_content directory. This gives you version control over your knowledge base, allowing you to track changes, revert, and collaborate.
  6. Scheduled Ingestion: Automate python wiki.py ingest with a cron job or systemd timer to keep your wiki constantly updated.
  7. More AI Features:
    • Summarization on Demand: Add a command wiki.py summarize <document_title> that retrieves a document and summarizes it with an LLM.
    • Related Documents: Based on a document’s embedding, find and suggest other highly similar documents in your wiki.

Honesty and Trade-offs

Pros:

  • Privacy & Ownership: Your data never leaves your machine (unless you use OpenAI). You control everything.
  • Offline Access: Once ingested, you can search and query your wiki even without an internet connection (especially with local LLMs).
  • Cost-Effective: Minimal or no recurring costs, especially if you switch to local LLMs. OpenAI API costs are usage-based and generally low for personal use.
  • Flexible & Extensible: Built with simple, open technologies. You can customize, extend, or migrate it easily.
  • Semantic Search: Finds information based on meaning, not just keywords, leading to more relevant results.

Cons:

  • Requires Setup: Not a plug-and-play solution like commercial wiki software. Involves some coding and command-line interaction.
  • No Built-in UI: Out of the box, it’s CLI-only. Building a GUI requires additional effort.
  • Embedding Updates: If you switch embedding models, you’ll need to re-ingest all documents to generate new embeddings.
  • LLM Hallucinations: While RAG helps ground the LLM, it’s still an LLM. Always critically evaluate its responses, especially for sensitive or factual information.
  • Computational Resources: Generating embeddings and running local LLMs can be computationally intensive, though ada embeddings are fast, and modern consumer CPUs/GPUs can handle smaller local LLMs.

This approach is ideal for developers, researchers, or anyone who values data sovereignty, enjoys tinkering, and wants a powerful, personalized knowledge management system tailored to their needs.

Conclusion

You’ve just built a powerful, private, and intelligent local AI wiki. By combining Markdown’s simplicity, SQLite’s efficiency, and the semantic understanding of LLMs, you’ve created a system that not only stores your knowledge but truly helps you retrieve and understand it. This local-first approach ensures your information remains yours, providing a foundation for a truly personal and intelligent knowledge assistant. Experiment with different LLMs, explore UI options, and make this wiki truly your own!

Last updated on