All articles
Build Guides

Generate Anki Flashcards from Lecture Notes with Ollama & LangChain

FDE Coach EditorialAugust 5, 202611 min read

What We're Building

A local CLI tool that takes a folder of Markdown lecture notes, splits them into digestible chunks, and uses a local LLM (Llama 3.1 8B or Mistral) via Ollama to generate question-answer pairs. The output is a CSV file you can drag directly into Anki.

Feature list:

  • Ingests .md files from a directory (recursive or flat)
  • Splits notes by heading boundaries so chunks stay semantically coherent
  • Prompts a local LLM to produce question;answer pairs per chunk
  • Deduplicates flashcards by embedding similarity (optional, free)
  • Exports a clean anki_import.csv — double-click to import
  • Runs 100% locally, zero API calls, zero cost

If you've ever crammed for an exam by manually typing flashcards from a 40-page markdown dump, this saves you hours. The workflow mirrors patterns we use in forward-deployed engineering: ingest unstructured data, chunk it intelligently, pipe it through an LLM, and ship a structured artifact the customer can use immediately. For a deeper dive into that weekly shipping rhythm, see From Messy Problem to Shipped Prototype: The FDE Weekly Workflow.

Architecture: How the Pieces Fit

The flow is linear by design. No vector database required unless you add the optional dedup step. The LangChain splitter respects Markdown heading hierarchy, so a chunk about "Krebs Cycle" won't bleed into "Electron Transport Chain." Each chunk hits a carefully tuned prompt that forces the LLM to output strictly formatted question;answer lines. The CSV writer handles escaping so commas in your answers don't break the import.

Prerequisites

Everything here is free and open-source. No credit card, no API key, no cloud account.

ToolPurposeInstall Link
Python 3.10+Runtimepython.org/downloads
OllamaLocal LLM serverollama.com/download
Llama 3.1 8B (or Mistral)The brainollama pull llama3.1:8b
LangChainChunking, prompt chainingpip install langchain langchain-community
sentence-transformers (optional)Dedup via embeddingspip install sentence-transformers

Hardware note: Llama 3.1 8B runs comfortably on any machine with 16 GB RAM. If you're on an 8 GB MacBook Air, swap to Mistral 7B (ollama pull mistral) — it'll be slightly less crisp but still perfectly usable. The 4-bit quantized versions Ollama serves by default keep memory pressure low.

Before you start, verify Ollama is alive:

ollama serve          # start the server if not running
ollama run llama3.1:8b "Say hello in JSON"  # quick smoke test

Step-by-Step Implementation

Create a project directory and a virtual environment:

mkdir flashcard-gen && cd flashcard-gen
python -m venv venv && source venv/bin/activate  # Windows: venv\Scripts\activate
pip install langchain langchain-community langchain-ollama

1. The Markdown Splitter

LangChain's MarkdownHeaderTextSplitter splits documents on header boundaries, preserving the header stack as metadata. This matters because a flashcard about "Photosynthesis" should carry the context "Biology > Chapter 3" in its metadata.

# splitter.py
from langchain_text_splitters import MarkdownHeaderTextSplitter

headers_to_split_on = [
    ("##", "h2"),
    ("###", "h3"),
]

splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=headers_to_split_on,
    strip_headers=False,  # keep headers in chunk text for context
)

def split_markdown_file(filepath: str) -> list[dict]:
    with open(filepath, "r", encoding="utf-8") as f:
        text = f.read()
    docs = splitter.split_text(text)
    return [{"content": doc.page_content, "metadata": doc.metadata} for doc in docs]

2. The Flashcard Prompt

This is where most builders screw up. If you say "generate flashcards," the LLM will produce prose, bullet points, or refuse because it thinks you want it to study. You need a prompt that's ruthlessly specific about output format.

# prompt.py
FLASHCARD_SYSTEM_PROMPT = """You are a flashcard generator. Given a chunk of lecture notes, produce question-answer pairs.

Rules:
- Output EXACTLY one pair per line in the format: question;answer
- Questions must test understanding, not just recall definitions
- Answers must be concise (1-3 sentences)
- If the chunk contains no testable material, output: SKIP
- Do not number the questions. Do not add commentary.
- Escape any semicolons inside questions or answers with a backslash (\;)

Example output:
What is the primary function of the Krebs Cycle?;To oxidize acetyl-CoA to CO2 and produce NADH and FADH2 for the electron transport chain.
SKIP
"""

def build_flashcard_prompt(chunk_content: str) -> str:
    return f"""Generate flashcards from these lecture notes:

{chunk_content}"""

The SKIP directive is critical. Not every chunk contains testable material (think: "Chapter 3 — continued" headers, or pure diagram captions). Without it, the LLM hallucinates questions about nothing.

3. The Ollama LLM Wrapper

LangChain's ChatOllama class handles the HTTP connection to your local Ollama server. We set temperature=0.3 to keep output deterministic — high-temperature flashcard generation produces creative but wrong answers.

# llm.py
from langchain_ollama import ChatOllama
from langchain_core.messages import HumanMessage, SystemMessage

def generate_flashcards_for_chunk(chunk_content: str, model_name: str = "llama3.1:8b") -> list[tuple[str, str]]:
    llm = ChatOllama(model=model_name, temperature=0.3)
    messages = [
        SystemMessage(content=FLASHCARD_SYSTEM_PROMPT),
        HumanMessage(content=build_flashcard_prompt(chunk_content)),
    ]
    response = llm.invoke(messages)
    raw_output = response.content.strip()

    pairs = []
    for line in raw_output.split("\n"):
        line = line.strip()
        if line == "SKIP" or not line:
            continue
        if ";" in line:
            # Split on first unescaped semicolon
            parts = line.split(";", 1)
            question = parts[0].replace("\\;", ";").strip()
            answer = parts[1].replace("\\;", ";").strip() if len(parts) > 1 else ""
            if question and answer:
                pairs.append((question, answer))
    return pairs

4. The CSV Exporter

Anki expects a CSV with columns Front,Back (or Question,Answer depending on your note type). We'll add optional Tags for organization.

# exporter.py
import csv
from pathlib import Path

def export_to_anki_csv(flashcards: list[tuple[str, str]], output_path: str = "anki_import.csv"):
    with open(output_path, "w", newline="", encoding="utf-8") as f:
        writer = csv.writer(f)
        writer.writerow(["Front", "Back", "Tags"])
        for question, answer in flashcards:
            writer.writerow([question, answer, "lecture-notes"])
    print(f"Exported {len(flashcards)} flashcards to {output_path}")

5. The Main Orchestrator

Glue everything together. Walk a directory, split every .md file, generate flashcards per chunk, collect them all, optionally deduplicate, and export.

# main.py
import argparse
from pathlib import Path
from splitter import split_markdown_file
from llm import generate_flashcards_for_chunk
from exporter import export_to_anki_csv

def main():
    parser = argparse.ArgumentParser(description="Generate Anki flashcards from Markdown notes.")
    parser.add_argument("notes_dir", help="Directory containing .md lecture notes")
    parser.add_argument("--model", default="llama3.1:8b", help="Ollama model name")
    parser.add_argument("--output", default="anki_import.csv", help="Output CSV path")
    parser.add_argument("--dedup", action="store_true", help="Deduplicate similar flashcards")
    args = parser.parse_args()

    notes_path = Path(args.notes_dir)
    if not notes_path.exists():
        print(f"Error: {args.notes_dir} does not exist.")
        return

    md_files = list(notes_path.rglob("*.md"))
    print(f"Found {len(md_files)} Markdown files.")

    all_flashcards = []
    for md_file in md_files:
        print(f"Processing: {md_file.name}")
        chunks = split_markdown_file(str(md_file))
        for i, chunk in enumerate(chunks):
            pairs = generate_flashcards_for_chunk(chunk["content"], args.model)
            all_flashcards.extend(pairs)
            print(f"  Chunk {i+1}/{len(chunks)}: generated {len(pairs)} cards")

    if args.dedup:
        all_flashcards = deduplicate_flashcards(all_flashcards)
        print(f"After dedup: {len(all_flashcards)} unique cards")

    export_to_anki_csv(all_flashcards, args.output)

if __name__ == "__main__":
    main()

6. Optional: Deduplication with Embeddings

When you have 200 pages of notes, the LLM will generate near-identical flashcards for concepts that appear across multiple lectures. A lightweight dedup pass using sentence-transformers catches these.

# dedup.py
from sentence_transformers import SentenceTransformer, util

def deduplicate_flashcards(flashcards: list[tuple[str, str]], threshold: float = 0.85) -> list[tuple[str, str]]:
    model = SentenceTransformer("all-MiniLM-L6-v2")  # free, 80 MB, fast
    questions = [q for q, _ in flashcards]
    embeddings = model.encode(questions, convert_to_tensor=True)

    kept = []
    kept_embeddings = []
    for i, (q, a) in enumerate(flashcards):
        if i == 0:
            kept.append((q, a))
            kept_embeddings.append(embeddings[i])
            continue
        # Compare against all kept embeddings
        similarities = util.cos_sim(embeddings[i], kept_embeddings)[0]
        if similarities.max().item() < threshold:
            kept.append((q, a))
            kept_embeddings.append(embeddings[i])
    return kept

all-MiniLM-L6-v2 downloads on first run (~80 MB) and runs on CPU in milliseconds per comparison. The 0.85 threshold is conservative — tune it based on your tolerance for near-duplicates.

How to Run It

  1. Drop your notes into a folder, e.g., ./bio-101-notes/
  2. Pull the model if you haven't: ollama pull llama3.1:8b
  3. Run the script:
    python main.py ./bio-101-notes --model llama3.1:8b --dedup
    
  4. Import into Anki:
    • Open Anki → File → Import
    • Select anki_import.csv
    • Choose note type "Basic" (or create a custom one with Front/Back/Tags fields)
    • Map fields: Front → Front, Back → Back, Tags → Tags
    • Import

Expected output for a 30-page markdown file: ~80-120 flashcards, depending on information density. Generation takes about 2-5 minutes on an M1 MacBook Pro using Llama 3.1 8B.

Sensible Extensions

Image-aware flashcards: If your markdown references images (e.g., ![Krebs Cycle](./krebs.png)), you can extract those references and include them in the flashcard answer as Anki-compatible <img> tags. Swap the splitter to UnstructuredMarkdownLoader which preserves image links.

Incremental generation: Running on 500 pages? Add a SQLite checkpoint so you can resume if Ollama crashes. Store (filepath, chunk_index, status) and skip completed chunks on rerun.

Custom note types: Anki supports cloze deletions. Modify the prompt to output {{c1::answer}} format and change the CSV header accordingly. This is killer for fill-in-the-blank style cards.

Multi-model voting: Run the same chunk through Llama 3.1 and Mistral, keep only pairs where both models agree on the answer. Increases quality at the cost of doubled inference time. This pattern is similar to what we explore in Debugging in the Dark: How FDEs Solve Customer Issues Without Environment Access — using multiple signals to increase confidence when you can't verify directly.

Common Pitfalls

"Ollama connection refused": The Ollama server binds to localhost:11434 by default. If you installed via the macOS app, it auto-starts. On Linux, you need to run ollama serve in a separate terminal or set up a systemd service.

Empty or garbage output: Your prompt template is too loose. The LLM is outputting markdown, JSON, or free text instead of question;answer. Tighten the system prompt with explicit format examples and add a post-processing regex fallback.

Memory exhaustion: Llama 3.1 8B needs ~6 GB VRAM/RAM. If you're running other models simultaneously, Ollama will swap aggressively. Run ollama ps to see active models and ollama stop <model> to free memory.

Semicolons in answers breaking CSV: The prompt instructs the LLM to escape semicolons with \;, but it sometimes ignores this. The CSV writer in Python's csv module handles quoting automatically if you pass quoting=csv.QUOTE_ALL, but Anki's importer sometimes chokes on quoted fields. The escape-backslash approach is more reliable in practice.

Chunks too large for context window: Llama 3.1 8B has a 128k context window, so this is rarely an issue. But if you're using an older model with 4k context, set chunk_size on the splitter to ~3000 characters.

FAQ

Q: Why not just paste my notes into ChatGPT? A: Privacy, cost, and repeatability. Your lecture notes stay on your machine. No rate limits. No $20/month subscription. And you can tweak the prompt to match exactly how your professor phrases exam questions. For enterprise environments where data can't leave the building, this pattern is essential — see Case Study: Deploying an LLM Feature at a Risk-Averse Enterprise Customer.

Q: Can I use this for non-English notes? A: Yes. Llama 3.1 and Mistral both handle dozens of languages well. The prompt is in English, but the model will generate flashcards in whatever language the source notes are written in.

Q: How do I handle math/LaTeX in flashcards? A: Anki supports LaTeX via [latex]...[/latex] or $$...$$ delimiters. Modify the system prompt to tell the LLM to wrap LaTeX in $$. Test with a few chunks first — some models mangle LaTeX more than others.

Q: The flashcards feel shallow. How do I get deeper questions? A: Edit the system prompt. Replace "test understanding" with "generate questions that require synthesizing multiple concepts from the chunk" and add an example of a compare/contrast question. The model will follow your lead. Prompt engineering is the highest-leverage skill here — and it's exactly what we teach in FDE Coach's prototyping workshops.

Q: Will this work with PDF lecture notes? A: Not out of the box, but it's a one-line change. Swap the markdown loader for PyPDFLoader from langchain-community. You'll need pip install pypdf. The splitter changes from MarkdownHeaderTextSplitter to RecursiveCharacterTextSplitter since PDFs lack heading metadata. Same pipeline otherwise.

Q: How many flashcards should I expect per page? A: Roughly 3-5 flashcards per page of dense lecture notes. If you're getting 15+ per page, your chunks are too large or your prompt is too permissive. If you're getting 0-1, your chunks may be too small or the content is genuinely not testable (e.g., pure narrative prose).

Ready to build more local AI tools? This pattern — local LLM + structured output — powers everything from on-call incident summarizers to voice assistants. Check out Build an On-Call Incident Summarizer That Reads Logs and Drafts a Postmortem with Cloudflare Workers AI and Build a Voice Assistant for Your Terminal with Whisper, Piper TTS, and Groq for more zero-cost, high-impact builds.

#flashcards#ollama#langchain#education#local-llm

Want to build like a Forward Deployed Engineer?

FDE Coach is a cohort-based program in frontend, backend, AWS, and AI. Build real products and get referred to 200+ hiring partners.

Explore the program

More build guides

August 15 · 0d left
Enroll Now