Built with Tutify.io

    AIStudyLabs

    What is Apache Tika? Explain with an Example

    What is Apache Tika? Explain with an Example

    Apache Tika: From Document Extraction to AI-Powered Search

    Who this is for: Software engineers and data engineers who want to build production-grade document ingestion pipelines — from raw files all the way to LLM-powered semantic search.


    Architecture Overview

    The end-to-end pipeline this article builds:

    ┌─────────────────────────────────────────────────────────────────────────┐
    │                      DOCUMENTS (Input Layer)                            │
    │          PDF  │  DOCX  │  XLSX  │  Images  │  HTML  │  Audio/Video      │
    └──────────────────────────────┬──────────────────────────────────────────┘
                                   │
                                   ▼
    ┌─────────────────────────────────────────────────────────────────────────┐
    │                    APACHE TIKA (Extraction Layer)                        │
    │                                                                         │
    │   ┌─────────────────┐   ┌──────────────────┐   ┌───────────────────┐   │
    │   │  MIME Detector  │──▶│  Parser Router   │──▶│  Content Handler  │   │
    │   │ (magic bytes,   │   │ (AutoDetect,     │   │ (SAX events →     │   │
    │   │  extensions)    │   │  1000+ parsers)  │   │  plain text)      │   │
    │   └─────────────────┘   └──────────────────┘   └───────────────────┘   │
    │                                                          │              │
    │                                           ┌──────────────┴───────────┐ │
    │                                           │  Metadata Object         │ │
    │                                           │  (Dublin Core, XMP,      │ │
    │                                           │   EXIF, Office props)    │ │
    │                                           └──────────────────────────┘ │
    └──────────────────────────────┬──────────────────────────────────────────┘
                                   │
                   ┌───────────────┴───────────────┐
                   │                               │
                   ▼                               ▼
    ┌──────────────────────────┐     ┌─────────────────────────────────────┐
    │   RELATIONAL DATABASE    │     │         VECTOR DATABASE             │
    │   (PostgreSQL)           │     │         (ChromaDB / Pinecone)       │
    │                          │     │                                     │
    │  • file path             │     │  chunk_1 → [0.12, -0.45, ...]      │
    │  • author                │     │  chunk_2 → [0.33,  0.81, ...]      │
    │  • created_date          │     │  chunk_3 → [-0.07, 0.24, ...]      │
    │  • content_type          │     │       (text-embedding-ada-002)      │
    │  • language              │     │                                     │
    │  • raw_text              │     └────────────────┬────────────────────┘
    └──────────────────────────┘                      │
                                                      │  semantic search
                                                      ▼
                                   ┌─────────────────────────────────────┐
                                   │           LLM QUERY LAYER           │
                                   │                                     │
                                   │  User question                      │
                                   │       │                             │
                                   │       ▼                             │
                                   │  Embed question                     │
                                   │       │                             │
                                   │       ▼                             │
                                   │  Retrieve top-k chunks              │
                                   │       │                             │
                                   │       ▼                             │
                                   │  Build prompt + context             │
                                   │       │                             │
                                   │       ▼                             │
                                   │  GPT-4 / local LLM (Ollama)        │
                                   │       │                             │
                                   │       ▼                             │
                                   │  Grounded answer                    │
                                   └─────────────────────────────────────┘
    

    1. What is Apache Tika?

    Apache Tika is a content detection and extraction toolkit maintained by the Apache Software Foundation. It solves one of the most tedious problems in data engineering: how do you extract plain text and metadata from an arbitrary file when you don't know what format it is?

    Before Tika, engineers writing document processing systems had to wire together a different library for each format — PDFBox for PDFs, Apache POI for Office files, ImageIO for images, JAudio Tagger for MP3s, and so on. Each library had a different API, different error handling, and different metadata conventions. Maintaining that collection at scale was a serious engineering burden.

    Tika collapses all of that behind a single, consistent API. You hand it a file (or a stream), and it hands back plain text and a metadata map. It does not matter whether the file is a Word document, a ZIP archive, a JPEG with GPS data, or an MP4 video — the calling code is identical.

    Core components

    | Component | Responsibility | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | MIME Detector | Identifies the file type using magic bytes, file extension, and XML root element hints. Returns a MIME type string such as application/pdf. | | Parser Router | Maps the detected MIME type to the correct parser implementation. Uses Java's ServiceLoader SPI so parsers can be plugged in without changing core code. | | ContentHandler | A SAX-style event receiver that collects the extracted text. The most common implementation, BodyContentHandler, builds a plain text string. | | Metadata | A mutable key-value map populated during parsing. Keys follow Dublin Core, XMP, and format-specific vocabularies. | | ParseContext | A thread-local configuration bag for passing options into parsers (passwords, locale, rendering hints). |

    Tika is written in Java and lives at tika.apache.org. It is released under the Apache 2.0 licence. The project has been active since 2007 and is a direct dependency of Apache Solr, Apache Nutch, and many enterprise search products.


    2. Key Advantages of Apache Tika

    Format breadth

    Tika ships with parsers for more than 1,000 file formats out of the box:

    Single unified API

    No matter the format, extraction always looks the same:

    Tika tika = new Tika();
    String text = tika.parseToString(new File("report.pdf"));
    

    This uniformity makes it trivial to add new file types to an existing pipeline.

    Language detection

    Tika bundles Apache OpenNLP and the Optimaize language detector. After parsing, you can call LanguageIdentifier to get an ISO 639-1 language code with a confidence score — useful for routing multilingual content to the right downstream model.

    OCR via Tesseract

    For scanned PDFs and images containing text, Tika integrates with Tesseract OCR. When Tesseract is installed and Tika detects an image-only PDF, it automatically rasterises each page and runs OCR. No code change required.

    ⚠ Gotcha: OCR is slow. On a standard laptop, expect 3–8 seconds per page. Use the Tika server with a dedicated OCR worker, or pre-filter documents to only OCR when normal text extraction returns empty content.

    REST server mode

    The tika-server JAR exposes all extraction functionality over HTTP. Any language — Python, Node.js, Go, Ruby — can call Tika by sending a file as a POST body and receiving text or JSON back. This also provides process isolation: a malformed file that crashes the JVM does not take down your main application.

    How it compares

    | | Apache Tika | Unstructured.io | AWS Textract | | --------------- | --------------------- | --------------------- | ----------------------- | | Hosting | Self-hosted | Self-hosted / cloud | Managed cloud | | Formats | 1,000+ | ~30 common | PDF, images | | OCR | Tesseract | Tesseract + layout AI | AWS proprietary | | Cost | Free | Free / paid tiers | Pay-per-page | | LLM integration | Manual | Built-in chunking | Manual | | Best for | Broad format coverage | Modern RAG pipelines | Regulated AWS workloads |


    3. Real-World Organizational Use Cases

    Enterprise search

    A large organisation typically has documents spread across SharePoint, file shares, email archives, and legacy ECM systems in dozens of formats. Tika is the extraction layer in front of Apache Solr or Elasticsearch. It normalises everything to plain text and a structured metadata envelope, which the search engine then indexes. Without Tika, the ingest pipeline would require separate connectors for every format.

    Legal e-discovery

    Law firms and corporate legal teams use Tika to process "custodian data" — everything on a person's laptop or email account. E-discovery platforms need to extract text from every file regardless of format (often millions of files), detect language, and extract creation/modification dates for privilege review timelines. Tika's recursive archive extraction is particularly important here: a single ZIP may contain nested emails with embedded attachments.

    ML training data preparation

    Building a large text corpus for pre-training or fine-tuning requires ingesting raw files from the web and internal repositories. Tika converts heterogeneous file dumps into clean UTF-8 text streams that can be fed directly to tokenisers. It also provides content-type metadata that lets data engineers filter out binary files or non-text formats before they enter the training loop.

    RAG / LLM pipelines

    Retrieval-Augmented Generation systems need a reliable way to ingest enterprise documents and make them searchable by meaning. Tika is the first stage: it extracts text from PDFs, Office files, and other formats, which is then chunked and embedded into a vector store. This article's pipeline (sections 7–9) is a working implementation of this pattern.

    Content migration

    When moving from one CMS or ECM to another, teams need to extract content from the old system's proprietary formats, transform it, and load it into the new system. Tika handles the extraction step, including stripping formatting and preserving structural metadata like headings and table structure in its XHTML output mode.

    Digital forensics

    Forensic investigators use Tika to extract text and metadata from evidence drives at scale. The metadata Tika extracts — GPS coordinates from camera photos, author fields from Office documents, creation timestamps — is often legally significant. The tika-app JAR can be scripted from the command line to produce machine-readable output for forensic toolchains.


    4. Installation and Setup

    Maven

    <dependencies>
      <!-- Core Tika API -->
      <dependency>
        <groupId>org.apache.tika</groupId>
        <artifactId>tika-core</artifactId>
        <version>2.9.1</version>
      </dependency>
    
      <!-- All standard parsers (PDF, Office, images, audio, archives, etc.) -->
      <dependency>
        <groupId>org.apache.tika</groupId>
        <artifactId>tika-parsers-standard-package</artifactId>
        <version>2.9.1</version>
      </dependency>
    </dependencies>
    

    Why tika-parsers-standard-package? tika-core contains only the interfaces and the detection/routing engine. Without a parsers JAR, Tika can detect MIME types but cannot extract any content. tika-parsers-standard-package bundles all Apache-maintained parsers and their transitive dependencies (PDFBox, POI, etc.) into a single convenient dependency. For a leaner build, you can instead include only the individual parser modules you need.

    Gradle

    dependencies {
        implementation 'org.apache.tika:tika-core:2.9.1'
        implementation 'org.apache.tika:tika-parsers-standard-package:2.9.1'
    }
    

    CLI (tika-app JAR)

    Download the fat JAR from the Apache Tika downloads page:

    wget https://dlcdn.apache.org/tika/2.9.1/tika-app-2.9.1.jar
    
    # Extract text from a PDF
    java -jar tika-app-2.9.1.jar --text report.pdf
    
    # Extract metadata as JSON
    java -jar tika-app-2.9.1.jar --metadata --json report.pdf
    
    # Detect MIME type only
    java -jar tika-app-2.9.1.jar --detect report.pdf
    
    # Detect language
    java -jar tika-app-2.9.1.jar --language report.pdf
    

    Java "Hello World"

    import org.apache.tika.Tika;
    import org.apache.tika.metadata.Metadata;
    import org.apache.tika.parser.AutoDetectParser;
    import org.apache.tika.parser.ParseContext;
    import org.apache.tika.sax.BodyContentHandler;
    
    import java.io.FileInputStream;
    import java.io.InputStream;
    
    public class TikaHelloWorld {
    
        public static void main(String[] args) throws Exception {
            // High-level facade — simplest possible usage
            Tika tika = new Tika();
            String text = tika.parseToString(new java.io.File("report.pdf"));
            System.out.println("=== Extracted Text (first 500 chars) ===");
            System.out.println(text.substring(0, Math.min(500, text.length())));
    
            // Lower-level API — gives you metadata too
            AutoDetectParser parser = new AutoDetectParser();
            BodyContentHandler handler = new BodyContentHandler(-1); // -1 = no size limit
            Metadata metadata = new Metadata();
            ParseContext context = new ParseContext();
    
            try (InputStream stream = new FileInputStream("report.pdf")) {
                parser.parse(stream, handler, metadata, context);
            }
    
            System.out.println("\n=== Metadata ===");
            for (String name : metadata.names()) {
                System.out.println(name + ": " + metadata.get(name));
            }
        }
    }
    

    5. Starting the Tika REST Server

    Running Tika as an HTTP server is the recommended deployment pattern for any multi-language environment, and it provides critical process isolation: a file that triggers a JVM crash does not kill your application server.

    Via JAR

    wget https://dlcdn.apache.org/tika/2.9.1/tika-server-standard-2.9.1.jar
    
    # Start on default port 9998
    java -jar tika-server-standard-2.9.1.jar
    
    # Custom host and port
    java -jar tika-server-standard-2.9.1.jar --host=0.0.0.0 --port=9998
    
    # Enable forked child process (recommended for production — isolates crash-prone parsers)
    java -jar tika-server-standard-2.9.1.jar --fork
    

    Via Docker

    # Pull and run the official image
    docker pull apache/tika:2.9.1
    
    docker run -d \
      --name tika \
      -p 9998:9998 \
      apache/tika:2.9.1
    
    # With OCR (Tesseract included in the full image)
    docker pull apache/tika:2.9.1-full
    
    docker run -d \
      --name tika-ocr \
      -p 9998:9998 \
      apache/tika:2.9.1-full
    

    Key REST endpoints

    | Endpoint | Method | Returns | | ------------------------------------ | ------ | ------------------------------- | | PUT /tika | PUT | Plain text body of the document | | PUT /meta | PUT | Metadata as JSON | | PUT /detect/stream | PUT | MIME type string | | PUT /language/stream | PUT | Detected language code | | PUT /tika with Accept: text/html | PUT | Structured XHTML output |

    curl examples

    # Extract plain text
    curl -T report.pdf http://localhost:9998/tika
    
    # Extract metadata as JSON
    curl -T report.pdf \
         -H "Accept: application/json" \
         http://localhost:9998/meta
    
    # Detect MIME type
    curl -T report.pdf http://localhost:9998/detect/stream
    
    # Detect language
    curl -T report.pdf http://localhost:9998/language/stream
    
    # Get XHTML output (preserves headings, tables, lists)
    curl -T report.pdf \
         -H "Accept: text/html" \
         http://localhost:9998/tika
    

    🔒 Security note: Do not expose the Tika server directly to the internet. It accepts arbitrary files and has no authentication. Place it behind an internal network boundary or a reverse proxy with access controls.


    6. Extracting Data from a PDF — Python

    Using tika-python

    pip install tika
    
    from tika import parser
    
    # Parse a single PDF
    parsed = parser.from_file("report.pdf")
    
    # parsed is a dict with two keys: 'content' and 'metadata'
    text     = parsed["content"]      # str — the full extracted text
    metadata = parsed["metadata"]     # dict — all metadata fields
    
    print("=== First 500 characters ===")
    print(text[:500] if text else "(no text extracted — likely a scanned PDF)")
    
    print("\n=== Key Metadata Fields ===")
    interesting_keys = [
        "dc:title", "dc:creator", "dcterms:created",
        "dcterms:modified", "pdf:PDFVersion",
        "Content-Type", "language"
    ]
    for key in interesting_keys:
        if key in metadata:
            print(f"{key}: {metadata[key]}")
    

    ℹ️ Note: tika-python automatically downloads and starts a Tika server JVM on the first call. Set TIKA_SERVER_ENDPOINT to point at an existing server instead:

    import os
    os.environ["TIKA_SERVER_ENDPOINT"] = "http://localhost:9998"
    

    Using the REST API directly (requests)

    This is the recommended approach for production — you control the server lifecycle.

    import requests
    import json
    
    TIKA_URL = "http://localhost:9998"
    
    def extract_text(file_path: str) -> str:
        with open(file_path, "rb") as f:
            response = requests.put(
                f"{TIKA_URL}/tika",
                data=f,
                headers={"Accept": "text/plain"}
            )
        response.raise_for_status()
        return response.text
    
    def extract_metadata(file_path: str) -> dict:
        with open(file_path, "rb") as f:
            response = requests.put(
                f"{TIKA_URL}/meta",
                data=f,
                headers={"Accept": "application/json"}
            )
        response.raise_for_status()
        return response.json()
    
    if __name__ == "__main__":
        text = extract_text("report.pdf")
        meta = extract_metadata("report.pdf")
    
        print(text[:500])
        print(json.dumps(meta, indent=2))
    

    Common gotchas

    Scanned PDFs return empty text. If content is None or an empty string, the PDF contains only images. Enable OCR by using the apache/tika:2.9.1-full Docker image (which includes Tesseract) and passing the X-Tika-OCRLanguage header:

    response = requests.put(
        f"{TIKA_URL}/tika",
        data=open("scanned.pdf", "rb"),
        headers={
            "Accept": "text/plain",
            "X-Tika-OCRLanguage": "eng",
            "X-Tika-PDFextractInlineImages": "true"
        }
    )
    

    Encoding issues. Always decode responses as UTF-8. The requests library does this automatically when you access .text, but if you read raw bytes, call .decode("utf-8", errors="replace").

    Large files. Tika's default in-memory limit is 10 MB of extracted text. Increase it by passing writeLimit=-1 in ParseContext (Java) or by setting the Content-Length header appropriately from the client.


    7. Loading Extracted Data into PostgreSQL

    This script walks a folder of PDFs, extracts text and metadata from each, and loads everything into a PostgreSQL table.

    Schema

    CREATE TABLE IF NOT EXISTS documents (
        id              SERIAL PRIMARY KEY,
        file_path       TEXT UNIQUE NOT NULL,
        file_name       TEXT,
        content_type    TEXT,
        author          TEXT,
        created_at      TIMESTAMPTZ,
        modified_at     TIMESTAMPTZ,
        language        TEXT,
        page_count      INT,
        raw_text        TEXT,
        ingested_at     TIMESTAMPTZ DEFAULT NOW()
    );
    
    -- Full-text search index (optional but recommended)
    CREATE INDEX IF NOT EXISTS idx_documents_fts
        ON documents USING gin(to_tsvector('english', COALESCE(raw_text, '')));
    

    Python ingestion script

    import os
    import requests
    import psycopg2
    from psycopg2.extras import execute_values
    from datetime import datetime
    from pathlib import Path
    
    TIKA_URL   = "http://localhost:9998"
    PG_DSN     = "postgresql://user:password@localhost:5432/mydb"
    DOCS_DIR   = "./documents"
    
    def extract(file_path: str) -> tuple[str, dict]:
        with open(file_path, "rb") as f:
            data = f.read()
    
        text_resp = requests.put(
            f"{TIKA_URL}/tika", data=data,
            headers={"Accept": "text/plain"}
        )
        meta_resp = requests.put(
            f"{TIKA_URL}/meta", data=data,
            headers={"Accept": "application/json"}
        )
        text_resp.raise_for_status()
        meta_resp.raise_for_status()
    
        return text_resp.text, meta_resp.json()
    
    def parse_date(value) -> datetime | None:
        if not value:
            return None
        if isinstance(value, list):
            value = value[0]
        try:
            return datetime.fromisoformat(value.replace("Z", "+00:00"))
        except (ValueError, AttributeError):
            return None
    
    def ingest_folder(docs_dir: str):
        conn = psycopg2.connect(PG_DSN)
        cur  = conn.cursor()
    
        pdf_files = list(Path(docs_dir).rglob("*.pdf"))
        print(f"Found {len(pdf_files)} PDFs")
    
        rows = []
        for pdf in pdf_files:
            print(f"  Processing {pdf.name} ...", end=" ")
            try:
                text, meta = extract(str(pdf))
                rows.append((
                    str(pdf),
                    pdf.name,
                    meta.get("Content-Type"),
                    meta.get("dc:creator") or meta.get("Author"),
                    parse_date(meta.get("dcterms:created") or meta.get("Creation-Date")),
                    parse_date(meta.get("dcterms:modified") or meta.get("Last-Modified")),
                    meta.get("language"),
                    int(meta.get("xmpTPg:NPages", 0) or 0),
                    text,
                ))
                print("OK")
            except Exception as e:
                print(f"FAILED: {e}")
    
        # Bulk insert — ON CONFLICT skips already-ingested files
        execute_values(cur, """
            INSERT INTO documents
                (file_path, file_name, content_type, author,
                 created_at, modified_at, language, page_count, raw_text)
            VALUES %s
            ON CONFLICT (file_path) DO NOTHING
        """, rows)
    
        conn.commit()
        cur.close()
        conn.close()
        print(f"\nInserted up to {len(rows)} rows.")
    
    if __name__ == "__main__":
        ingest_folder(DOCS_DIR)
    

    📝 Note on large text fields: PostgreSQL stores TEXT columns as TOAST (The Oversized Attribute Storage Technique) transparently, so you don't need to worry about column size limits. For full-text search, use the GIN index above — it's far faster than LIKE '%keyword%' on large corpora.


    8. Loading Extracted Data into a Vector Database

    With text in PostgreSQL, you can do keyword search. For semantic search ("find documents about supply chain risk" even if those exact words don't appear), you need a vector database.

    pip install langchain langchain-openai chromadb openai tiktoken
    
    import os
    import psycopg2
    import chromadb
    from langchain.text_splitter import RecursiveCharacterTextSplitter
    from langchain_openai import OpenAIEmbeddings
    # Alternative: from langchain_community.embeddings import HuggingFaceEmbeddings
    
    PG_DSN         = "postgresql://user:password@localhost:5432/mydb"
    CHROMA_PATH    = "./chroma_db"
    COLLECTION     = "documents"
    OPENAI_API_KEY = os.environ["OPENAI_API_KEY"]
    
    # --- Chunking ---
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=800,       # characters per chunk
        chunk_overlap=100,    # overlap to preserve context across chunk boundaries
        separators=["\n\n", "\n", ". ", " ", ""],
    )
    
    # --- Embeddings ---
    embeddings = OpenAIEmbeddings(
        model="text-embedding-3-small",   # cheaper than ada-002, similar quality
        openai_api_key=OPENAI_API_KEY,
    )
    
    # Local alternative (no API key required):
    # embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")
    
    # --- ChromaDB client ---
    chroma = chromadb.PersistentClient(path=CHROMA_PATH)
    collection = chroma.get_or_create_collection(
        name=COLLECTION,
        metadata={"hnsw:space": "cosine"},
    )
    
    def embed_documents():
        conn = psycopg2.connect(PG_DSN)
        cur  = conn.cursor()
        cur.execute("""
            SELECT id, file_name, raw_text, author, language
            FROM documents
            WHERE raw_text IS NOT NULL AND raw_text <> ''
        """)
        rows = cur.fetchall()
        cur.close()
        conn.close()
    
        total_chunks = 0
        for doc_id, file_name, raw_text, author, language in rows:
            chunks = splitter.split_text(raw_text)
    
            ids       = [f"doc{doc_id}_chunk{i}" for i in range(len(chunks))]
            metadatas = [
                {
                    "doc_id":    doc_id,
                    "file_name": file_name or "",
                    "author":    author or "",
                    "language":  language or "",
                    "chunk_idx": i,
                }
                for i in range(len(chunks))
            ]
    
            # Embed in batches of 100 to stay within rate limits
            batch_size = 100
            for start in range(0, len(chunks), batch_size):
                batch_texts = chunks[start:start + batch_size]
                batch_ids   = ids[start:start + batch_size]
                batch_meta  = metadatas[start:start + batch_size]
    
                vectors = embeddings.embed_documents(batch_texts)
    
                collection.upsert(
                    ids=batch_ids,
                    embeddings=vectors,
                    documents=batch_texts,
                    metadatas=batch_meta,
                )
    
            total_chunks += len(chunks)
            print(f"  {file_name}: {len(chunks)} chunks embedded")
    
        print(f"\nTotal chunks in vector DB: {total_chunks}")
    
    if __name__ == "__main__":
        embed_documents()
    

    💡 Tip — Pinecone alternative: Replace the ChromaDB block with:

    from pinecone import Pinecone, ServerlessSpec
    pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"])
    pc.create_index("documents", dimension=1536, spec=ServerlessSpec(cloud="aws", region="us-east-1"))
    index = pc.Index("documents")
    index.upsert(vectors=list(zip(batch_ids, vectors, batch_meta)))
    

    9. Using an LLM to Search and Query the Data

    With chunks embedded in ChromaDB, you can now answer natural language questions by retrieving the relevant chunks and passing them as context to an LLM.

    pip install openai
    
    import os
    import chromadb
    from openai import OpenAI
    from langchain_openai import OpenAIEmbeddings
    
    CHROMA_PATH    = "./chroma_db"
    COLLECTION     = "documents"
    OPENAI_API_KEY = os.environ["OPENAI_API_KEY"]
    
    chroma = chromadb.PersistentClient(path=CHROMA_PATH)
    collection = chroma.get_collection(COLLECTION)
    
    embeddings = OpenAIEmbeddings(
        model="text-embedding-3-small",
        openai_api_key=OPENAI_API_KEY,
    )
    
    client = OpenAI(api_key=OPENAI_API_KEY)
    
    # Ollama alternative (local, no API key):
    # client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
    # model  = "llama3"
    
    def answer(question: str, top_k: int = 5, model: str = "gpt-4o") -> str:
        # 1. Embed the user's question using the same model
        question_vector = embeddings.embed_query(question)
    
        # 2. Retrieve the top-k most similar chunks
        results = collection.query(
            query_embeddings=[question_vector],
            n_results=top_k,
            include=["documents", "metadatas", "distances"],
        )
    
        chunks    = results["documents"][0]
        metadatas = results["metadatas"][0]
        distances = results["distances"][0]
    
        # 3. Build a context string from retrieved chunks
        context_parts = []
        for chunk, meta, dist in zip(chunks, metadatas, distances):
            source = meta.get("file_name", "unknown")
            context_parts.append(f"[Source: {source} | similarity: {1 - dist:.2f}]\n{chunk}")
        context = "\n\n---\n\n".join(context_parts)
    
        # 4. Construct the RAG prompt
        system_prompt = """You are a precise document analyst.
    Answer the user's question using ONLY the context provided below.
    If the answer is not present in the context, say "I could not find this in the provided documents."
    Do not use any prior knowledge. Cite the source file for each claim."""
    
        user_prompt = f"""Context:
    {context}
    
    Question: {question}"""
    
        # 5. Call the LLM
        response = client.chat.completions.create(
            model=model,
            messages=[
                {"role": "system", "content": system_prompt},
                {"role": "user",   "content": user_prompt},
            ],
            temperature=0.1,   # low temperature for factual retrieval tasks
            max_tokens=1000,
        )
    
        return response.choices[0].message.content
    
    # --- Interactive REPL ---
    if __name__ == "__main__":
        print("Document QA system ready. Type 'quit' to exit.\n")
        while True:
            question = input("Question: ").strip()
            if question.lower() in ("quit", "exit", "q"):
                break
            if not question:
                continue
            print("\nAnswer:")
            print(answer(question))
            print()
    

    Why RAG is more reliable than asking the LLM from memory

    A base LLM has no access to your internal documents. It will either hallucinate an answer or refuse to answer. RAG grounds the LLM's response in actual retrieved text from your corpus. Setting temperature=0.1 and instructing the model to cite sources makes hallucination detectable: if a claimed fact does not appear in any retrieved chunk, it did not come from your documents.

    💡 Tip — Ollama for local inference: Replace the OpenAI client with:

    client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
    MODEL  = "llama3.1"
    

    Run ollama pull llama3.1 first. This gives you a fully air-gapped pipeline with no data leaving your network.


    10. End-to-End Pipeline Summary

    Documents (PDF, DOCX, XLSX, images, audio …)
              │
              ▼
      ┌──────────────────┐
      │   Apache Tika    │  ← single extraction API for all formats
      │   REST Server    │    runs in Docker, language-agnostic
      └──────┬───────────┘
             │
        text + metadata
             │
        ┌────┴─────────────────────────────────────────┐
        │                                              │
        ▼                                              ▼
    ┌──────────────────┐                  ┌────────────────────────┐
    │   PostgreSQL     │                  │   Chunker              │
    │                  │                  │   (LangChain splitter) │
    │  Structured      │                  └────────────┬───────────┘
    │  metadata +      │                               │
    │  keyword search  │                               ▼
    │  (GIN FTS index) │                  ┌────────────────────────┐
    └──────────────────┘                  │   Embeddings           │
                                          │   (OpenAI / local)     │
                                          └────────────┬───────────┘
                                                       │
                                                       ▼
                                          ┌────────────────────────┐
                                          │   ChromaDB / Pinecone  │
                                          │   (Vector store)       │
                                          └────────────┬───────────┘
                                                       │
                                           semantic retrieval
                                                       │
                                                       ▼
                                          ┌────────────────────────┐
                                          │   LLM (GPT-4 / Ollama) │
                                          │   + RAG prompt         │
                                          └────────────┬───────────┘
                                                       │
                                                       ▼
                                             Grounded answer
                                             with source citations
    

    Checklist for production


    Further Resources