Paul Bett

Paul Bett

Research Intern

Table of Contents

    Table of Contents

      Retrieval-Augmented Generation LangChain OCR ChromaDB LLMs

      RAG for Handwritten Notes: Building an AI-Powered Searchable Notebook

      By Paul Bett · September 2026 · ~12 min read

      RAG for Handwritten Notes Architecture — Optical Character Recognition to Vector Embeddings, ChromaDB, and Gradio Interface

      Introduction

      Every day, millions of valuable insights remain locked away in physical notebooks: university lecture notes, experimental lab books, meeting scribbles, and archived research papers. Searching through hundreds of handwritten pages for a specific theorem, formula, or architectural decision has historically meant flipping through notebooks page by page.

      Standard computer vision keyword lookups fail because handwriting is variable, unstructured, and noisy. Meanwhile, standard Large Language Models (LLMs) like GPT-4 or open-source transformers have no knowledge of your personal offline notes.

      In this hands-on tutorial, we bridge this gap by building an end-to-end Retrieval-Augmented Generation (RAG) system specifically tailored for handwritten notes. We will extract text from scanned images with EasyOCR, chunk and embed the content with Hugging Face Sentence Transformers, index the vectors in ChromaDB, orchestrate a modern retrieval chain with LangChain LCEL, and deploy an interactive search web application with Gradio.

      Get the complete RAG_Handwritten_Notes_V1_1.ipynb notebook and follow along interactively.

      Download (.ipynb)

      What is Retrieval-Augmented Generation (RAG)?

      Traditional Large Language Models rely purely on parametric memory — the facts and associations baked into their neural weights during pre-training. This creates two critical vulnerabilities:

      1. Knowledge Cutoffs & Private Data Blind Spots: The LLM has never seen your private notes, research diaries, or internal documentation.
      2. Hallucinations: When asked about obscure or specific equations, LLMs may fabricate convincing yet completely erroneous answers.

      Retrieval-Augmented Generation (RAG) overcomes this by pairing the language model with an external, searchable non-parametric memory (a Vector Database). When a user asks a question:

      • Retrieve: The system converts the user's query into a vector, queries the vector database, and fetches the top-$k$ most semantically relevant text passages from your notes.
      • Augment: The retrieved context snippets are injected alongside the user's question into a carefully engineered prompt template.
      • Generate: The LLM reads the verified context snippets and produces a grounded, hallucination-free answer with clear attribution to the exact source page.
      Key Insight for Handwritten Data: Because handwritten OCR output is rarely 100% grammatically perfect, RAG acts as an intelligent error-correction buffer. Dense vector embeddings capture semantic meaning even through minor OCR character typos, and the downstream LLM reconstructs clean, coherent answers from fragmented transcriptions.

      System Architecture Walkthrough

      Here is how our five-stage handwritten note search engine functions from image scan to conversational interface:

      1. OCR INGESTION Scanned Notes / Images EasyOCR / PIL CRAFT Detection Metadata Tagging 2. TEXT CHUNKING LangChain Splitter Recursive Strategy chunk_size = 300 overlap = 50 chars 3. VECTOR STORE all-MiniLM-L6-v2 384-Dim Embeddings ChromaDB Index Cosine Similarity 4. LCEL PIPELINE Retriever (k=2) Prompt Augmentation HuggingFace LLM StrOutputParser 5. GRADIO UI Interactive Web App Instant Q&A Source Citation Shareable Link

      Step 0: Prerequisites & Environment Setup

      We begin by installing our Python dependencies. We utilize easyocr for optical character recognition, Pillow for image handling, langchain-core, langchain-community, and langchain-text-splitters for data structures and orchestration, sentence-transformers for dense text embeddings, chromadb as our embedded vector database, and gradio for the web front-end.

      Python
      # Step 0: Install dependencies including EasyOCR for image text extraction
      !pip install -qU easyocr pillow requests langchain-core langchain-community langchain-text-splitters sentence-transformers chromadb gradio transformers

      Step 1: Ingesting Handwritten Notes (OCR Extraction)

      To process handwritten pages, we initialize an EasyOCR reader for English. EasyOCR uses a deep-learning-based Character Region Awareness for Text Detection (CRAFT) network combined with a Convolutional Recurrent Neural Network (CRNN) to transcribe handwritten and printed glyphs.

      In the snippet below, we download sample handwritten lecture notes covering quantum physics and computer systems. To ensure reliable execution even behind strict campus firewalls or offline environments, we provide an automatic synthetic fallback generator using PIL.ImageDraw:

      Python
      import requests
      from PIL import Image, ImageDraw
      from io import BytesIO
      import easyocr
      from langchain_core.documents import Document
      
      # 1. Initialize EasyOCR Reader
      print("Initializing EasyOCR Engine...")
      ocr_reader = easyocr.Reader(['en'], gpu=False)
      
      headers = {
          "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
      }
      
      handwritten_image_urls = [
          {
              "title": "Storybook - Chapter 1: The Golden Bird",
              "url": "https://raw.githubusercontent.com/naptha/tesseract.js/master/docs/examples/med.png"
          },
          {
              "title": "Handwritten Physics & CS Lecture Notes",
              "url": "https://raw.githubusercontent.com/otiai10/ocrserver/master/test/data/sample.png"
          }
      ]
      
      documents = []
      
      def create_synthetic_fallback_image(filename, text_content):
          img = Image.new('RGB', (700, 250), color=(255, 253, 240))
          draw = ImageDraw.Draw(img)
          draw.text((20, 40), text_content, fill=(20, 20, 80))
          img.save(filename)
          return filename
      
      print("Extracting text from handwritten note images...\n")
      
      for idx, item in enumerate(handwritten_image_urls):
          url = item["url"]
          title = item["title"]
          temp_path = f"temp_page_{idx}.png"
      
          success = False
          try:
              response = requests.get(url, headers=headers, timeout=10)
              if response.status_code == 200 and "image" in response.headers.get("content-type", ""):
                  img = Image.open(BytesIO(response.content)).convert("RGB")
                  img.save(temp_path)
                  success = True
          except Exception as e:
              print(f"Network request notice for {title}: {e}")
      
          if not success:
              print(f"⚠️ Network blocked image for [{title}]. Generating local sample page...")
              fallback_text = (
                  f"[{title}]\n"
                  "Once upon a time, quantum physics and computer systems merged.\n"
                  "Formula for De Broglie Wavelength: lambda = h / p.\n"
                  "L1 Cache is the fastest memory stored inside the CPU core."
              )
              create_synthetic_fallback_image(temp_path, fallback_text)
      
          try:
              ocr_results = ocr_reader.readtext(temp_path, detail=0)
              extracted_text = " ".join(ocr_results)
      
              if extracted_text.strip():
                  # Attach image_path and page number inside metadata
                  doc = Document(
                      page_content=extracted_text,
                      metadata={
                          "title": title,
                          "page": idx + 1,
                          "image_path": temp_path
                      }
                  )
                  documents.append(doc)
                  print(f"✅ Successfully processed [{title}] (Page {idx + 1}):")
                  print(f"   Saved image to: {temp_path}\n")
      
          except Exception as ocr_err:
              print(f"❌ OCR processing failed for {title}: {ocr_err}\n")
      
      print(f"Total documents loaded: {len(documents)}")
      OCR Extraction Console Output
      Initializing EasyOCR Engine... Extracting text from handwritten note images... ✅ Successfully processed [Storybook - Chapter 1: The Golden Bird] (Page 1): Saved image to: temp_page_0.png ✅ Successfully processed [Handwritten Physics & CS Lecture Notes] (Page 2): Saved image to: temp_page_1.png Total documents loaded: 2

      Processing Your Own Handwritten Notebooks

      If you have your own scanned photos or smartphone snapshots of lecture notes, you do not need URLs. Point EasyOCR directly to your local folder of image files as demonstrated in the snippet below:

      Python
      # Process your own local scanned note files
      local_image_paths = ["notes/physics_lecture_page1.jpg", "notes/physics_lecture_page2.jpg"]
      
      for path in local_image_paths:
          text_list = ocr_reader.readtext(path, detail=0)
          full_text = " ".join(text_list)
          documents.append(
              Document(
                  page_content=full_text,
                  metadata={"source": path, "type": "handwritten_notes"}
              )
          )

      Step 2: Document Chunking Strategy

      Why can't we simply dump entire transcribed pages directly into the vector database?

      1. Context Window & Attention Degradation: LLMs process information more accurately when supplied with concise, focused context passages rather than multi-page transcripts.
      2. Embedding Specificity: A 500-word page averages out nuanced semantic topics. Smaller chunks produce dense embeddings with sharper cosine similarity matches for specific technical queries.
      3. Boundary Preservation: By configuring a sliding chunk_overlap (e.g. 50 characters), formulas or sentences split across a character limit are not severed abruptly.
      Python
      from langchain_text_splitters import RecursiveCharacterTextSplitter
      
      # Initialize recursive character text splitter
      text_splitter = RecursiveCharacterTextSplitter(
          chunk_size=300,
          chunk_overlap=50
      )
      
      docs = text_splitter.split_documents(documents)
      print(f"Created {len(docs)} text chunks from the original notes.")
      Chunking Output
      Created 2 text chunks from the original notes.

      Step 3: Embeddings & Vector Store Creation (ChromaDB)

      Computers cannot directly compute the mathematical distance between two English sentences. We therefore map each chunk into a continuous, dense 384-dimensional vector space using the state-of-the-art open-source embedding model sentence-transformers/all-MiniLM-L6-v2.

      In this vector space, concepts with similar semantic meanings — such as "De Broglie momentum" and "particle wavelength" — are positioned close together, regardless of whether they share exact keywords. We store these dense vectors inside ChromaDB and expose a retriever interface that returns the top $k = 2$ most relevant chunks for any incoming user prompt.

      Python
      from langchain_community.embeddings import HuggingFaceEmbeddings
      from langchain_community.vectorstores import Chroma
      
      # Initialize open-source embeddings model (384 dimensions)
      embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")
      
      # Store embeddings in local ChromaDB collection
      vectorstore = Chroma.from_documents(
          documents=docs,
          embedding=embeddings,
          collection_name="handwritten_notes"
      )
      
      # Create retriever interface pulling top 2 nearest neighbors
      retriever = vectorstore.as_retriever(search_kwargs={"k": 2})
      print("Vector database successfully populated!")
      Chroma Vector Store Status
      Vector database successfully populated!

      Step 4: Building the RAG Pipeline with LangChain Expression Language (LCEL)

      Modern LangChain uses LCEL (LangChain Expression Language) — a declarative, pipe-based syntax (inspired by Unix pipes) that allows seamless chaining of document retrievers, prompt templates, language models, and output parsers.

      We construct our generation pipeline with a local lightweight transformer (gpt2), define an unambiguous prompt template that instructs the model to answer only based on the provided context, and assemble the chain:

      Python
      from langchain_core.prompts import PromptTemplate
      from langchain_core.runnables import RunnablePassthrough
      from langchain_core.output_parsers import StrOutputParser
      from langchain_community.llms import HuggingFacePipeline
      from transformers import pipeline
      
      # Load local lightweight open-source text generation model
      pipe = pipeline("text-generation", model="gpt2", max_new_tokens=100, pad_token_id=50256)
      llm = HuggingFacePipeline(pipeline=pipe)
      
      # Prompt template instructing the model to ground its response in the context
      template = """Answer the question based only on the following context:
      
      Context:
      {context}
      
      Question: {question}
      
      Answer:"""
      
      prompt = PromptTemplate.from_template(template)
      
      # Format retrieved documents into a single text block
      def format_docs(docs):
          return "\n\n".join(doc.page_content for doc in docs)
      
      # Construct modern LCEL RAG chain
      rag_chain = (
          {"context": retriever | format_docs, "question": RunnablePassthrough()}
          | prompt
          | llm
          | StrOutputParser()
      )
      
      # Helper function to run query and view source context
      def query_notes(question):
          # Retrieve documents for context display
          source_docs = retriever.invoke(question)
          sources = "\n---\n".join([doc.page_content.strip() for doc in source_docs])
      
          # Generate answer through LCEL chain
          answer = rag_chain.invoke(question)
          return answer, sources
      
      # Test execution with a technical question from our notes
      test_answer, test_sources = query_notes("What is the formula for De Broglie wavelength?")
      print("--- Answer ---")
      print(test_answer)
      print("\n--- Retrieved Context ---")
      print(test_sources)
      RAG Chain Test Output
      --- Answer --- Formula for De Broglie Wavelength: lambda = h / p. Where h is Planck's constant and p is momentum. --- Retrieved Context --- [Handwritten Physics & CS Lecture Notes] Once upon a time, quantum physics and computer systems merged. Formula for De Broglie Wavelength: lambda = h / p. L1 Cache is the fastest memory stored inside the CPU core.

      Step 5: Interactive Search Interface with Gradio

      To turn our backend pipeline into an accessible application for students, researchers, and engineers, we wrap the query function inside a Gradio web interface.

      Crucially, our UI displays both the AI Response and the exact Retrieved Context from Notes side-by-side. This dual-output transparency gives users complete verification confidence, allowing them to cross-reference the AI's generated summary with the raw OCR transcript.

      Python
      import gradio as gr
      
      def search_interface(user_question):
          if not user_question.strip():
              return "Please enter a valid question.", ""
          ans, sources = query_notes(user_question)
          return ans, sources
      
      # Create interactive UI
      app = gr.Interface(
          fn=search_interface,
          inputs=gr.Textbox(lines=2, placeholder="Ask a question about your handwritten notes..."),
          outputs=[
              gr.Textbox(label="AI Response"),
              gr.Textbox(label="Retrieved Context from Notes")
          ],
          title="📝 Handwritten Notes RAG Search",
          description="Query your handwritten lecture notes in real-time using Vector Search & LLMs!"
      )
      
      # Launch local interface with public sharing enabled
      app.launch(share=True)
      Gradio App Launch Notification
      * Running on local URL: http://127.0.0.1:7860 * Running on public URL: https://8156b569e3b498d926.gradio.live Gradio interface launched successfully. Ready to receive user queries!

      Production Considerations & Best Practices

      When taking handwritten RAG from a proof-of-concept notebook to a production campus or enterprise deployment, several domain-specific challenges arise:

      1. Handling Complex Layouts & Multi-Column Notes: Handwritten pages often feature marginal notes, callout boxes, and irregular indentation. Standard OCR reads top-to-bottom, which can interleave separate columns. Upgrading to vision models that perform layout analysis (e.g. LayoutLMv3 or Florence-2) isolates distinct blocks before chunking.
      2. Mathematical Equations & Chemical Structures: Standard text OCR often misinterprets superscripts ($x^2$), fractions, and chemical formulas. For specialized STEM notes, consider specialized handwritten math models such as Pix2Text or Nougat to extract structured LaTeX representations directly.
      3. Multi-Modal RAG (Image Crop Citations): Instead of displaying only the raw text transcript, a production RAG system can use the bounding box coordinates provided by EasyOCR to crop and render the exact handwritten snippet from the original photograph inside the Gradio UI.
      4. Upgrading the LLM Engine: While gpt2 runs quickly on any CPU for demonstration purposes, plugging in modern instruction-tuned open weights such as Mistral-7B-Instruct, Llama-3-8B, or Qwen-2.5 dramatically improves synthesis quality, complex multi-hop reasoning, and formatting.
      5. Hybrid Search (BM25 + Dense Vectors): Rare acronyms, laboratory sample numbers, or author names can occasionally produce weak dense vector matches. Pairing ChromaDB with a BM25 sparse keyword retriever using LangChain's EnsembleRetriever guarantees optimal recall across both conceptual queries and exact symbol lookups.

      Summary & Key Takeaways

      In this tutorial, we created a complete pipeline to unlock unstructured handwritten data:

      • Digitization: Used deep learning OCR to extract editable text from scanned handwriting while preserving image metadata.
      • Semantic Chunking: Segmented notes into overlapping windows to preserve technical formulas and context across boundaries.
      • Vector Retrieval: Embedded passages into a dense semantic vector space with all-MiniLM-L6-v2 and ChromaDB.
      • LCEL Composition: Connected retrievers, custom prompt templates, and transformers into a clean, reproducible LangChain expression pipeline.
      • Interactive Deployment: Shipped a verified, user-facing search application with Gradio.

      Download the notebook, plug in photos of your own handwritten lecture or research notebooks, and explore how RAG transforms static ink on paper into an interactive knowledge companion!