n4nAI

LlamaIndex plus Ollama: indexing docs with DeepSeek-V3

Build a local-first document indexing pipeline with LlamaIndex, Ollama embeddings, and DeepSeek-V3 via OpenAI-compatible API.

n4n Team3 min read580 words

Audio narration

Coming soon — every post will get a voice note here.

You want to index and query documents locally without sending data to external APIs, but you still want a frontier-class model for synthesis. This tutorial shows how to wire LlamaIndex with Ollama for embeddings and DeepSeek-V3 via an OpenAI-compatible endpoint for generation. You’ll end up with a runnable pipeline that ingests a directory of files, builds a vector index, and answers questions with citations.

Prerequisites

  • Python 3.10+
  • Ollama installed and running (ollama serve)
  • An OpenAI-compatible endpoint for DeepSeek-V3 (OpenRouter, n4n.ai, or self-hosted vLLM)
  • A directory of documents to index (Markdown, PDF, text)

Install the dependencies:

pip install llama-index llama-index-llms-ollama llama-index-embeddings-ollama \
  llama-index-readers-file pypdf python-dotenv

Pull the embedding model in Ollama. We’ll use nomic-embed-text — 137M parameters, fast, and strong retrieval quality:

ollama pull nomic-embed-text

Verify it’s available:

ollama list
# NAME                ID        SIZE    MODIFIED
# nomic-embed-text    latest    274 MB  2 minutes ago

Create a .env file for your DeepSeek-V3 endpoint:

# .env
DEEPSEEK_API_KEY=sk-or-v1-xxxxxxxxxxxxx
DEEPSEEK_BASE_URL=https://openrouter.ai/api/v1
# Or if using n4n.ai:
# DEEPSEEK_BASE_URL=https://api.n4n.ai/v1

Project structure

rag-pipeline/
├── .env
├── data/
│   ├── doc1.md
│   ├── doc2.pdf
│   └── notes.txt
├── ingest.py
├── query.py
└── requirements.txt

Put a few files in data/ — any mix of Markdown, PDF, or plain text works.

Step 1: Configure LlamaIndex settings

Create settings.py to centralize configuration. This avoids scattering model names and parameters across scripts.

# settings.py
import os
from dotenv import load_dotenv
from llama_index.core import Settings
from llama_index.embeddings.ollama import OllamaEmbedding
from llama_index.llms.openai_like import OpenAILike

load_dotenv()

# Local embeddings via Ollama
Settings.embed_model = OllamaEmbedding(
    model_name="nomic-embed-text",
    base_url="http://localhost:11434",
    # Optional: tune for throughput
    # request_timeout=120.0,
)

# DeepSeek-V3 via OpenAI-compatible endpoint
Settings.llm = OpenAILike(
    model="deepseek/deepseek-v3",  # OpenRouter model slug
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    api_base=os.getenv("DEEPSEEK_BASE_URL"),
    is_chat_model=True,
    temperature=0.1,
    max_tokens=4096,
    # Context window for DeepSeek-V3
    context_window=128000,
)

# Chunking defaults — adjust for your corpus
Settings.chunk_size = 1024
Settings.chunk_overlap = 128

Run a quick sanity check:

python -c "from settings import Settings; print('Embed model:', Settings.embed_model.model_name); print('LLM:', Settings.llm.model)"

Expected output:

Embed model: nomic-embed-text
LLM: deepseek/deepseek-v3

Step 2: Build the ingestion script

ingest.py loads documents, splits them, embeds chunks, and persists a vector store to disk. We use LlamaIndex’s SimpleDirectoryReader and the default in-memory vector store backed by faiss (install faiss-cpu if you want persistence; otherwise it pickles the index).

# ingest.py
import sys
from pathlib import Path
from llama_index.core import (
    SimpleDirectoryReader,
    VectorStoreIndex,
    StorageContext,
)
from llama_index.core.node_parser import SentenceSplitter
from settings import Settings

DATA_DIR = Path("data")
PERSIST_DIR = Path("storage")

def main():
    if not DATA_DIR.exists():
        print(f"Data directory {DATA_DIR} does not exist")
        sys.exit(1)

    print(f"Loading documents from {DATA_DIR}...")
    documents = SimpleDirectoryReader(
        input_dir=str(DATA_DIR),
        recursive=True,
        required_exts=[".md", ".pdf", ".txt", ".html"],
    ).load_data()

    print(f"Loaded {len(documents)} documents")
    for doc in documents:
        print(f"  - {doc.metadata.get('file_name', 'unknown')}: {len(doc.text)} chars")

    # Explicit node parser gives you control over chunking
    parser = SentenceSplitter(
        chunk_size=Settings.chunk_size,
        chunk_overlap=Settings.chunk_overlap,
    )
    nodes = parser.get_nodes_from_documents(documents, show_progress=True)
    print(f"Created {len(nodes)} nodes")

    # Build index
    print("Building vector index...")
    index = VectorStoreIndex(nodes, show_progress=True)

    # Persist to disk
    PERSIST_DIR.mkdir(exist_ok=True)
    index.storage_context.persist(persist_dir=str(PERSIST_DIR))
    print(f"Index persisted to {PERSIST_DIR}")

if __name__ == "__main__":
    main()

Run it:

python ingest.py

Expected output (varies by corpus):

Loading documents from data...
Loaded 3 documents
  - doc1.md: 12450 chars
  - doc2.pdf: 8921 chars
  - notes.txt: 3102 chars
Created 47 nodes
Building vector index...
Index persisted to storage

The storage/ directory now contains default__vector_store.json, docstore.json, and index_store.json.

Step 3: Query the index

query.py loads the persisted index, configures a retriever, and runs a query engine with citation support.

# query.py
import sys
from pathlib import Path
from llama_index.core import (
    VectorStoreIndex,
    StorageContext,
    load_index_from_storage,
)
from llama_index.core.retrievers import VectorIndexRetriever
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core.response_synthesizers import CompactAndRefine
from settings import Settings

PERSIST_DIR = Path("storage")

def build_query_engine():
    if not PERSIST_DIR.exists():
        print(f"Index not found at {PERSIST_DIR}. Run ingest.py first.")
        sys.exit(1)

    print("Loading index from storage...")
    storage_context = StorageContext.from_defaults(persist_dir=str(PERSIST_DIR))
    index = load_index_from_storage(storage_context)

    # Configure retriever: top-k with similarity threshold
    retriever = VectorIndexRetriever(
        index=index,
        similarity_top_k=6,
        # Optional: filter by metadata
        # filters=MetadataFilters(...),
    )

    # CompactAndRefine fits more context per LLM call than TreeSummarize
    synthesizer = CompactAndRefine(
        llm=Settings.llm,
        verbose=True,
    )

    query_engine = RetrieverQueryEngine(
        retriever=retriever,
        response_synthesizer=synthesizer,
    )
    return query_engine

def main():
    if len(sys.argv) < 2:
        print("Usage: python query.py \"your question here\"")
        sys.exit(1)

    question = " ".join(sys.argv[1:])
    print(f"\nQuestion: {question}\n")

    engine = build_query_engine()
    response = engine.query(question)

    print("=" * 60)
    print("ANSWER")
    print("=" * 60)
    print(response.response)
    print()

    if response.source_nodes:
        print("=" * 60)
        print("SOURCES")
        print("=" * 60)
        for i, node in enumerate(response.source_nodes, 1):
            meta = node.metadata
            file_name = meta.get("file_name", "unknown")
            page = meta.get("page_label", meta.get("page", "?"))
            score = node.score
            print(f"\n[{i}] {file_name} (page {page}) — score: {score:.4f}")
            print(f"    {node.text[:200]}...")

if __name__ == "__main__":
    main()

Test it:

python query.py "What are the key differences between the approaches described in the documents?"

Expected output shape:

Question: What are the key differences between the approaches described in the documents?

Loading index from storage...

ANSWER
============================================================
Based on the provided documents, there are three key differences...

============================================================
SOURCES
============================================================

[1] doc1.md (page 1) — score: 0.8234
    The first approach uses a centralized coordinator...

[2] doc2.pdf (page 3) — score: 0.7912
    In contrast, the distributed model eliminates...

[3] notes.txt (page ?) — score: 0.7105
    Hybrid variants attempt to combine...

Step 4: Streaming responses

For interactive use, stream tokens as they arrive. Replace the query call in query.py:

# Streaming variant
response = engine.query(question)

print("ANSWER")
print("=" * 60)
for token in response.response_gen:
    print(token, end="", flush=True)
print("\n")

The response_gen attribute exists on StreamingResponse — enable it by passing streaming=True to the query engine constructor:

query_engine = RetrieverQueryEngine(
    retriever=retriever,
    response_synthesizer=synthesizer,
    streaming=True,
)

Step 5: Hybrid retrieval (BM25 + vector)

Pure vector search misses exact keyword matches. Add a BM25 retriever and fuse results with QueryFusionRetriever:

# hybrid_query.py
from llama_index.core.retrievers import QueryFusionRetriever, BM25Retriever
from llama_index.core import VectorStoreIndex, StorageContext, load_index_from_storage
from settings import Settings

def build_hybrid_engine(persist_dir="storage"):
    storage_context = StorageContext.from_defaults(persist_dir=persist_dir)
    index = load_index_from_storage(storage_context)

    vector_retriever = VectorIndexRetriever(index=index, similarity_top_k=6)
    bm25_retriever = BM25Retriever.from_defaults(
        index=index,
        similarity_top_k=6,
    )

    hybrid = QueryFusionRetriever(
        retrievers=[vector_retriever, bm25_retriever],
        similarity_top_k=6,
        num_queries=1,  # set >1 for query rewriting
        mode="reciprocal_rerank",
        use_async=True,
        verbose=True,
    )

    return RetrieverQueryEngine.from_args(
        retriever=hybrid,
        llm=Settings.llm,
        streaming=True,
    )

Run with the same CLI. The fusion retriever often surfaces documents that vector-only misses — especially for proper nouns, error codes, or acronyms.

Step 6: Persistent vector store with Chroma

The default in-memory store works for prototypes. For production, swap in Chroma:

pip install chromadb llama-index-vector-stores-chroma
# ingest_chroma.py
import chromadb
from llama_index.core import VectorStoreIndex, StorageContext
from llama_index.vector_stores.chroma import ChromaVectorStore
from settings import Settings

chroma_client = chromadb.PersistentClient(path="./chroma_db")
chroma_collection = chroma_client.get_or_create_collection("docs")

vector_store = ChromaVectorStore(chroma_collection=chroma_collection)
storage_context = StorageContext.from_defaults(vector_store=vector_store)

index = VectorStoreIndex(nodes, storage_context=storage_context, show_progress=True)
# No need to call persist() — Chroma writes automatically

Querying uses the same load_index_from_storage pattern with the Chroma-backed storage context.

Tuning knobs worth knowing

Parameter Location Effect
chunk_size Settings.chunk_size Larger = more context per node, fewer nodes. 512–1536 typical.
chunk_overlap Settings.chunk_overlap Prevents boundary loss. 10–20% of chunk_size.
similarity_top_k Retriever How many nodes to retrieve. 4–10 typical.
temperature Settings.llm 0.0–0.3 for factual QA; higher for creative synthesis.
context_window Settings.llm Must match model. DeepSeek-V3 = 128k.
num_queries QueryFusionRetriever >1 enables query rewriting (costs extra LLM calls).

Common failure modes

Ollama connection refused — Ensure ollama serve is running and base_url matches (default http://localhost:11434). Check curl http://localhost:11434/api/tags.

DeepSeek-V3 returns 401/404 — Verify DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL. The model slug must match the provider’s catalog (deepseek/deepseek-v3 on OpenRouter, deepseek-v3 on n4n.ai).

Out of memory on embeddingnomic-embed-text fits in 2–4 GB VRAM. If OOM, try all-minilm:33m (smaller, lower quality) or batch embedding: Settings.embed_model.embed_batch_size = 16.

PDF extraction yields garbagepypdf struggles with scanned PDFs. Add pdfplumber or marker for complex layouts:

pip install pdfplumber
from llama_index.readers.file import PDFReader
reader = PDFReader(pdf_reader="pdfplumber")
documents = reader.load_data(file=Path("data/doc2.pdf"))

What this gives you

  • Local embeddings: Document content never leaves your machine for vectorization.
  • Frontier generation: DeepSeek-V3 handles synthesis via an OpenAI-compatible endpoint — swap providers without code changes.
  • Citations: Every answer includes source nodes with scores and metadata.
  • Extensible: Add rerankers (Cohere, Jina, local bge-reranker), hybrid search, or agentic workflows on top.

The same pattern scales: swap nomic-embed-text for bge-m3 (multilingual), point the LLM at a local llama3.1:70b via Ollama for fully air-gapped operation, or route through a gateway that handles fallback and metering across providers. The LlamaIndex abstractions stay the same.

Tagsllamaindexollamadeepseekdocument-indexing

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All open-source & local models in frameworks (llama 4, mistral, deepseek, qwen) posts →