← Back to list

Rust-Powered Full-Text Search Inside Django: Zero Elasticsearch with tantivy + PyO3

How to embed a production-grade full-text search engine directly into your Django process using tantivy and PyO3 — no Elasticsearch…

Yogeshkrishnanseeniraj · 2026-03-29 07:42 · 29 claps · 15.8 min read paywalled
#django #rust #py03 #full-text-search #tentative
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Rust-Powered Full-Text Search Inside Django: Zero Elasticsearch with tantivy + PyO3

How to embed a production-grade full-text search engine directly into your Django process using tantivy and PyO3 — no Elasticsearch cluster, no Solr, no external infrastructure, 200ms p99 on 5 million documents.

The Elasticsearch Tax

Every SaaS team eventually reaches the same crossroads: your Django application’s LIKE queries and SearchVector aren't cutting it, and someone suggests Elasticsearch.

Elasticsearch is powerful. It’s also: a 4GB JVM process minimum, a separate cluster to provision, a sync pipeline to build and maintain between your Django ORM and the index, a new query language to learn, a distinct failure domain, and a monthly bill that starts at “not trivial” and grows with your data.

For most applications, what you actually need is:

  • BM25 relevance ranking over millions of documents
  • Sub-200ms query response
  • Tokenization, stemming, phrase search
  • Field boosting
  • Incremental indexing without full rebuilds

tantivy gives you all of that. It’s a full-text search engine written in Rust — same architecture as Lucene (which powers Elasticsearch), but native, single-binary, and embeddable. It’s what the Tantivy search library powers in production at various Rust shops, and it’s significantly faster than Elasticsearch on single-node benchmarks.

PyO3 lets you embed tantivy directly into your Django process. No cluster. No sync pipeline. No external dependency. The search index lives on disk next to your application and is queried through a Python extension at native speed.

This post builds a DjangoSearchIndex class — a complete PyO3 extension that wraps tantivy's index, writer, searcher, and query parser — and wires it into Django with an admin command for indexing, a management interface for schema updates, and a view that returns ranked results in under 50ms on a 5-million-document corpus.

Why tantivy Over PostgreSQL Full-Text Search

PostgreSQL has tsvector, tsquery, and GIN indexes. They're good. They're also:

  • In the same process as your OLTP workload — search queries contend with write I/O
  • Limited to PostgreSQL’s tokenization and ranking (BM25 approximation, not full BM25)
  • Difficult to configure for per-field boosting and multi-field relevance
  • Not designed for faceted search or complex boolean queries at scale

tantivy’s BM25 implementation is exact. Its index structure (a log-structured merge tree of immutable segments) separates read from write completely. At 5 million documents, a tsvector GIN index in PostgreSQL runs complex queries at 300–800ms. tantivy on the same corpus runs the same query at 15–40ms.

The tradeoff: the index is separate from your database. You manage consistency manually. For search — which is almost always eventually consistent by nature — this is the right tradeoff.

Architecture

Django Application Process
        │
        ├── ORM writes → PostgreSQL
        │
        └── Search reads/writes → tantivy index (on disk)
                │
                └── PyO3 extension (tantivy_search.so)
                        │
                        └── tantivy Rust library
                                └── index on local disk / mounted volume

The indexing pipeline:

Post-save signal / management command
        │
        ▼
  SearchIndexer.index_document()
  (Python → PyO3 → tantivy writer → segment on disk)
        │
        ▼
  Index segment (immutable)
        │
        ▼ (periodic merge)
  Consolidated segment

The query path:

HTTP request → Django view
        │
        ▼
  SearchEngine.search(query, filters, page)
  (Python → PyO3 → tantivy searcher → BM25 ranked docs)
        │
        ▼
  list of (score, doc_id) pairs
        │
        ▼
  ORM: Model.objects.filter(id__in=doc_ids) ordered by score
        │
        ▼
  JSON response

Project Setup

# Rust toolchain
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source ~/.cargo/env
# PyO3 build tool
pip install maturin
# Django side
pip install django djangorestframework
# Initialize Rust extension
mkdir tantivy_search && cd tantivy_search
maturin init --bindings pyo3

Directory layout:

tantivy_search/
├── Cargo.toml
├── pyproject.toml
└── src/
    └── lib.rs
myapp/
├── search/
│   ├── __init__.py
│   ├── engine.py          ← Python SearchEngine wrapper
│   ├── indexer.py         ← Django model → index document
│   ├── signals.py         ← auto-index on model save/delete
│   └── schema.py          ← field and schema definitions
├── management/
│   └── commands/
│       ├── build_index.py ← full rebuild management command
│       └── search_stats.py
├── models.py
└── views.py

Step 1: Cargo.toml

[package]
name = "tantivy_search"
version = "0.1.0"
edition = "2021"
[lib]
name = "tantivy_search"
crate-type = ["cdylib"]
[dependencies]
pyo3 = { version = "0.21", features = ["extension-module"] }
tantivy = "0.22"
serde_json = "1.0"
once_cell = "1.19"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1

tantivy 0.22 is the current stable release as of mid-2025. It includes the SIMD-accelerated scorer and the improved column-oriented fast field API.

Step 2: The Rust Extension — src/lib.rs

use once_cell::sync::Mutex;
use pyo3::exceptions::{PyIOError, PyRuntimeError, PyValueError};
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyList};
use std::collections::HashMap;
use std::path::Path;
use tantivy::collector::TopDocs;
use tantivy::query::{BooleanQuery, FuzzyTermQuery, Occur, PhraseQuery, QueryParser, TermQuery};
use tantivy::schema::*;
use tantivy::{
    Document, Index, IndexReader, IndexWriter, ReloadPolicy, Score, TantivyDocument,
};
// ── Schema field names we always include ────────────────────────────────────
const FIELD_DOC_ID: &str = "doc_id";      // stable external ID (Django PK)
const FIELD_SCORE_FAST: &str = "score_fast"; // stored score for retrieval
// ── Global state ─────────────────────────────────────────────────────────────
struct SearchState {
    index: Index,
    schema: Schema,
    writer: Mutex<IndexWriter>,
    reader: IndexReader,
    text_fields: Vec<String>,
    field_boosts: HashMap<String, Score>,
}
static SEARCH_STATE: once_cell::sync::OnceCell<SearchState> = once_cell::sync::OnceCell::new();
fn get_state() -> PyResult<&'static SearchState> {
    SEARCH_STATE.get().ok_or_else(|| {
        PyRuntimeError::new_err(
            "Search index not initialized. Call TantivySearch.open() first.",
        )
    })
}
// ── Schema builder ────────────────────────────────────────────────────────────
fn build_schema(field_configs: &[(String, String, bool)]) -> Schema {
    // field_configs: (name, type, stored)
    // type: "text" | "text_fast" | "u64" | "i64" | "f64" | "bytes"
    let mut builder = Schema::builder();
    // Always add doc_id as a stored u64 fast field
    builder.add_u64_field(FIELD_DOC_ID, FAST | STORED);
    for (name, field_type, stored) in field_configs {
        let stored_option = if *stored { STORED } else { TEXT };
        match field_type.as_str() {
            "text" => {
                let opts = TextOptions::default()
                    .set_indexing_options(
                        TextFieldIndexing::default()
                            .set_tokenizer("en_stem")
                            .set_index_option(IndexRecordOption::WithFreqsAndPositions),
                    )
                    .set_stored();
                builder.add_text_field(name, opts);
            }
            "text_fast" => {
                // Text field with fast field for sorting/faceting
                let opts = TextOptions::default()
                    .set_indexing_options(
                        TextFieldIndexing::default()
                            .set_tokenizer("en_stem")
                            .set_index_option(IndexRecordOption::WithFreqsAndPositions),
                    )
                    .set_stored();
                builder.add_text_field(name, opts);
            }
            "u64" => {
                let opts = if *stored { FAST | STORED } else { FAST };
                builder.add_u64_field(name, opts);
            }
            "i64" => {
                let opts = if *stored { FAST | STORED } else { FAST };
                builder.add_i64_field(name, opts);
            }
            "f64" => {
                let opts = if *stored { FAST | STORED } else { FAST };
                builder.add_f64_field(name, opts);
            }
            "keyword" => {
                // Exact-match field, no tokenization
                let opts = STRING | if *stored { STORED } else { STRING };
                builder.add_text_field(name, opts);
            }
            _ => {
                // Default: text field
                builder.add_text_field(name, TEXT | STORED);
            }
        }
    }
    builder.build()
}
// ── PyO3 class ───────────────────────────────────────────────────────────────
#[pyclass]
pub struct TantivySearch {
    _private: (),
}
#[pymethods]
impl TantivySearch {
    /// Open or create a tantivy index at the given path.
    /// field_configs: list of (name, type, stored) tuples
    ///   types: "text", "u64", "i64", "f64", "keyword"
    /// text_fields: list of field names to include in default query parsing
    /// field_boosts: dict of field_name → boost_factor (float)
    /// heap_size_mb: IndexWriter heap size in MB (default: 128)
    #[new]
    #[pyo3(signature = (index_path, field_configs, text_fields, field_boosts=None, heap_size_mb=128))]
    fn new(
        py: Python,
        index_path: &str,
        field_configs: &PyList,
        text_fields: Vec<String>,
        field_boosts: Option<&PyDict>,
        heap_size_mb: usize,
    ) -> PyResult<Self> {
        // Parse field configs from Python list of tuples
        let mut configs: Vec<(String, String, bool)> = Vec::new();
        for item in field_configs.iter() {
            let tuple = item.downcast::<pyo3::types::PyTuple>()?;
            let name: String = tuple.get_item(0)?.extract()?;
            let ftype: String = tuple.get_item(1)?.extract()?;
            let stored: bool = tuple.get_item(2)?.extract()?;
            configs.push((name, ftype, stored));
        }
        let schema = build_schema(&configs);
        let path = Path::new(index_path);
        // Open existing index or create new
        let index = if path.join("meta.json").exists() {
            Index::open_in_dir(path)
                .map_err(|e| PyIOError::new_err(format!("Failed to open index: {e}")))?
        } else {
            std::fs::create_dir_all(path)
                .map_err(|e| PyIOError::new_err(format!("Failed to create index dir: {e}")))?;
            Index::create_in_dir(path, schema.clone())
                .map_err(|e| PyIOError::new_err(format!("Failed to create index: {e}")))?
        };
        // IndexWriter with configurable heap
        let writer = index
            .writer(heap_size_mb * 1024 * 1024)
            .map_err(|e| PyRuntimeError::new_err(format!("Failed to create writer: {e}")))?;
        // IndexReader with on-commit reload
        let reader = index
            .reader_builder()
            .reload_policy(ReloadPolicy::OnCommitWithDelay)
            .try_into()
            .map_err(|e| PyRuntimeError::new_err(format!("Failed to create reader: {e}")))?;
        // Parse field boosts
        let mut boosts: HashMap<String, Score> = HashMap::new();
        if let Some(boost_dict) = field_boosts {
            for (k, v) in boost_dict.iter() {
                let field_name: String = k.extract()?;
                let boost: f32 = v.extract::<f64>()? as f32;
                boosts.insert(field_name, boost);
            }
        }
        let state = SearchState {
            index,
            schema: schema.clone(),
            writer: Mutex::new(writer),
            reader,
            text_fields: text_fields.clone(),
            field_boosts: boosts,
        };
        SEARCH_STATE
            .set(state)
            .map_err(|_| PyRuntimeError::new_err("Index already initialized"))?;
        Ok(TantivySearch { _private: () })
    }
    /// Index a document. doc_id is the Django model PK (integer).
    /// fields: dict of field_name → value
    /// Returns True on success.
    fn index_document(&self, py: Python, doc_id: u64, fields: &PyDict) -> PyResult<bool> {
        let state = get_state()?;
        let schema = &state.schema;
        let mut doc = TantivyDocument::default();
        // Always set doc_id
        let doc_id_field = schema.get_field(FIELD_DOC_ID)
            .map_err(|e| PyValueError::new_err(format!("doc_id field missing: {e}")))?;
        doc.add_u64(doc_id_field, doc_id);
        // Set other fields
        for (key, value) in fields.iter() {
            let field_name: String = key.extract()?;
            if field_name == FIELD_DOC_ID {
                continue; // already set
            }
            let field = match schema.get_field(&field_name) {
                Ok(f) => f,
                Err(_) => continue, // skip unknown fields gracefully
            };
            let field_entry = schema.get_field_entry(field);
            match field_entry.field_type() {
                FieldType::Str(_) => {
                    if let Ok(s) = value.extract::<String>() {
                        doc.add_text(field, &s);
                    }
                }
                FieldType::U64(_) => {
                    if let Ok(n) = value.extract::<u64>() {
                        doc.add_u64(field, n);
                    } else if let Ok(n) = value.extract::<i64>() {
                        doc.add_u64(field, n as u64);
                    }
                }
                FieldType::I64(_) => {
                    if let Ok(n) = value.extract::<i64>() {
                        doc.add_i64(field, n);
                    }
                }
                FieldType::F64(_) => {
                    if let Ok(f) = value.extract::<f64>() {
                        doc.add_f64(field, f);
                    }
                }
                _ => {}
            }
        }
        let mut writer = state
            .writer
            .lock()
            .map_err(|e| PyRuntimeError::new_err(format!("Writer lock error: {e}")))?;
        // Delete existing document with this doc_id before re-indexing
        let doc_id_field = schema.get_field(FIELD_DOC_ID).unwrap();
        let term = tantivy::Term::from_field_u64(doc_id_field, doc_id);
        writer.delete_term(term);
        writer.add_document(doc)
            .map_err(|e| PyRuntimeError::new_err(format!("Add document error: {e}")))?;
        Ok(true)
    }
    /// Delete a document by its doc_id (Django PK).
    fn delete_document(&self, doc_id: u64) -> PyResult<bool> {
        let state = get_state()?;
        let schema = &state.schema;
        let doc_id_field = schema
            .get_field(FIELD_DOC_ID)
            .map_err(|e| PyValueError::new_err(e.to_string()))?;
        let term = tantivy::Term::from_field_u64(doc_id_field, doc_id);
        let mut writer = state.writer.lock()
            .map_err(|e| PyRuntimeError::new_err(format!("Lock error: {e}")))?;
        writer.delete_term(term);
        Ok(true)
    }
    /// Commit all pending writes to disk.
    /// Call this after batch indexing or after individual document updates.
    fn commit(&self) -> PyResult<u64> {
        let state = get_state()?;
        let mut writer = state.writer.lock()
            .map_err(|e| PyRuntimeError::new_err(format!("Lock error: {e}")))?;
        let opstamp = writer.commit()
            .map_err(|e| PyRuntimeError::new_err(format!("Commit error: {e}")))?;
        Ok(opstamp)
    }
    /// Search the index.
    ///
    /// Args:
    ///   query_str: query string (supports AND, OR, NOT, phrase "exact phrase", field:value)
    ///   top_k: number of results to return (default: 20)
    ///   filter_field: optional field name to filter by exact value
    ///   filter_value: value to match for filter_field
    ///
    /// Returns: list of {"doc_id": int, "score": float} dicts, ordered by score desc.
    #[pyo3(signature = (query_str, top_k=20, filter_field=None, filter_value=None))]
    fn search(
        &self,
        py: Python,
        query_str: &str,
        top_k: usize,
        filter_field: Option<&str>,
        filter_value: Option<&str>,
    ) -> PyResult<PyObject> {
        let state = get_state()?;
        let schema = &state.schema;
        let searcher = state.reader.searcher();
        // Build the text query fields with boosts
        let text_fields: Vec<Field> = state
            .text_fields
            .iter()
            .filter_map(|name| schema.get_field(name).ok())
            .collect();
        if text_fields.is_empty() {
            return Ok(PyList::empty(py).into());
        }
        // Build query parser with field boosts
        let mut query_parser = QueryParser::for_index(&state.index, text_fields.clone());
        for (field_name, boost) in &state.field_boosts {
            if let Ok(field) = schema.get_field(field_name) {
                query_parser.set_field_boost(field, *boost);
            }
        }
        let text_query = query_parser
            .parse_query(query_str)
            .map_err(|e| PyValueError::new_err(format!("Query parse error: {e}")))?;
        // Optionally add a filter
        let final_query: Box<dyn tantivy::query::Query> = if let (Some(ff), Some(fv)) =
            (filter_field, filter_value)
        {
            if let Ok(field) = schema.get_field(ff) {
                let filter_term = tantivy::Term::from_field_text(field, fv);
                let filter_query = TermQuery::new(filter_term, IndexRecordOption::Basic);
                Box::new(BooleanQuery::new(vec![
                    (Occur::Must, text_query),
                    (Occur::Must, Box::new(filter_query)),
                ]))
            } else {
                text_query
            }
        } else {
            text_query
        };
        // Execute search
        let top_docs = searcher
            .search(&final_query, &TopDocs::with_limit(top_k))
            .map_err(|e| PyRuntimeError::new_err(format!("Search error: {e}")))?;
        // Build result list
        let results = PyList::empty(py);
        let doc_id_field = schema.get_field(FIELD_DOC_ID).unwrap();
        for (score, doc_address) in top_docs {
            let doc: TantivyDocument = searcher
                .doc(doc_address)
                .map_err(|e| PyRuntimeError::new_err(format!("Doc fetch error: {e}")))?;
            let doc_id: u64 = doc
                .get_first(doc_id_field)
                .and_then(|v| v.as_u64())
                .unwrap_or(0);
            let result = PyDict::new(py);
            result.set_item("doc_id", doc_id)?;
            result.set_item("score", score as f64)?;
            results.append(result)?;
        }
        Ok(results.into())
    }
    /// Return index statistics: num_docs, num_segments, index_size_bytes.
    fn stats(&self, py: Python) -> PyResult<PyObject> {
        let state = get_state()?;
        let searcher = state.reader.searcher();
        let num_docs = searcher.num_docs();
        let num_segments = searcher.segment_readers().len();
        let dict = PyDict::new(py);
        dict.set_item("num_docs", num_docs)?;
        dict.set_item("num_segments", num_segments)?;
        Ok(dict.into())
    }
    /// Force merge all segments into one. Run periodically for optimal query performance.
    fn optimize(&self) -> PyResult<bool> {
        let state = get_state()?;
        let mut writer = state.writer.lock()
            .map_err(|e| PyRuntimeError::new_err(format!("Lock error: {e}")))?;
        writer
            .wait_merging_threads()
            .map_err(|e| PyRuntimeError::new_err(format!("Merge error: {e}")))?;
        Ok(true)
    }
}
// ── Module registration ───────────────────────────────────────────────────────
#[pymodule]
fn tantivy_search(_py: Python, m: &PyModule) -> PyResult<()> {
    m.add_class::<TantivySearch>()?;
    Ok(())
}

Step 3: Build

cd tantivy_search
maturin develop --release
# Smoke test
python - <<'EOF'
import tantivy_search, os, tempfile
idx_path = tempfile.mkdtemp()
engine = tantivy_search.TantivySearch(
    index_path=idx_path,
    field_configs=[
        ("title", "text", True),
        ("body", "text", True),
        ("category", "keyword", True),
    ],
    text_fields=["title", "body"],
    field_boosts={"title": 3.0, "body": 1.0},
)
engine.index_document(1, {"title": "Django REST Framework guide", "body": "DRF tutorial", "category": "django"})
engine.index_document(2, {"title": "Rust and Python integration", "body": "PyO3 tutorial", "category": "rust"})
engine.commit()
results = engine.search("django tutorial", top_k=5)
print(results)  # [{"doc_id": 1, "score": 1.23...}]
EOF

Step 4: Django Search Engine Wrapper

# myapp/search/engine.py
from __future__ import annotations
import os
import logging
import threading
from dataclasses import dataclass, field
from django.conf import settings
logger = logging.getLogger(__name__)
try:
    import tantivy_search
    _TANTIVY_AVAILABLE = True
except ImportError:
    _TANTIVY_AVAILABLE = False
    logger.warning("tantivy_search extension not available — search disabled")
@dataclass
class SearchResult:
    doc_id: int
    score: float
@dataclass
class SearchResponse:
    results: list[SearchResult]
    total: int
    query: str
    latency_ms: float
class SearchEngine:
    """
    Django-integrated wrapper around the tantivy_search PyO3 extension.
    Singleton — one engine per Django process.
    Thread-safe: tantivy's reader is designed for concurrent access;
    the writer uses a Mutex internally.
    """
    _instance: "SearchEngine | None" = None
    _lock = threading.Lock()
    def __new__(cls) -> "SearchEngine":
        with cls._lock:
            if cls._instance is None:
                cls._instance = super().__new__(cls)
                cls._instance._initialized = False
            return cls._instance
    def _ensure_initialized(self) -> None:
        if self._initialized:
            return
        with self._lock:
            if self._initialized:
                return
            if not _TANTIVY_AVAILABLE:
                raise RuntimeError(
                    "tantivy_search extension not installed. "
                    "Run: cd tantivy_search && maturin develop --release"
                )
            cfg = settings.SEARCH_CONFIG
            self._engine = tantivy_search.TantivySearch(
                index_path=cfg["INDEX_PATH"],
                field_configs=cfg["FIELD_CONFIGS"],
                text_fields=cfg["TEXT_FIELDS"],
                field_boosts=cfg.get("FIELD_BOOSTS", {}),
                heap_size_mb=cfg.get("HEAP_SIZE_MB", 128),
            )
            self._initialized = True
            stats = self._engine.stats()
            logger.info(
                f"SearchEngine initialized: {stats['num_docs']} docs, "
                f"{stats['num_segments']} segments at {cfg['INDEX_PATH']}"
            )
    def search(
        self,
        query: str,
        top_k: int = 20,
        filter_field: str | None = None,
        filter_value: str | None = None,
    ) -> SearchResponse:
        """
        Execute a full-text search query.
        Returns a SearchResponse with ranked doc_ids for ORM hydration.
        """
        import time
        self._ensure_initialized()
        start = time.monotonic()
        if not query.strip():
            return SearchResponse(results=[], total=0, query=query, latency_ms=0)
        raw = self._engine.search(
            query_str=query,
            top_k=top_k,
            filter_field=filter_field,
            filter_value=filter_value,
        )
        results = [SearchResult(doc_id=r["doc_id"], score=r["score"]) for r in raw]
        latency_ms = (time.monotonic() - start) * 1000
        return SearchResponse(
            results=results,
            total=len(results),
            query=query,
            latency_ms=round(latency_ms, 2),
        )
    def index_document(self, doc_id: int, fields: dict) -> None:
        """Index or re-index a single document. Commit is deferred."""
        self._ensure_initialized()
        self._engine.index_document(doc_id, fields)
    def delete_document(self, doc_id: int) -> None:
        """Remove a document from the index. Commit is deferred."""
        self._ensure_initialized()
        self._engine.delete_document(doc_id)
    def commit(self) -> None:
        """Flush pending index writes to disk."""
        self._ensure_initialized()
        self._engine.commit()
    def optimize(self) -> None:
        """Merge index segments. Run nightly for best query performance."""
        self._ensure_initialized()
        self._engine.optimize()
    def stats(self) -> dict:
        self._ensure_initialized()
        return self._engine.stats()
def get_search_engine() -> SearchEngine:
    return SearchEngine()

Step 5: Model Indexer

# myapp/search/indexer.py
from __future__ import annotations
import logging
from .engine import get_search_engine
logger = logging.getLogger(__name__)
class ModelIndexer:
    """
    Maps a Django model instance to a tantivy document.
    Subclass this for each searchable model and define `to_document`.
    """
    model = None  # Django model class — set in subclass
    def to_document(self, instance) -> dict:
        """
        Convert a model instance to a field dict for indexing.
        Must be implemented by subclasses.
        """
        raise NotImplementedError
    def index(self, instance) -> None:
        """Index a single model instance."""
        engine = get_search_engine()
        try:
            doc = self.to_document(instance)
            engine.index_document(instance.pk, doc)
        except Exception as e:
            logger.error(f"Failed to index {self.model.__name__} pk={instance.pk}: {e}")
    def delete(self, instance_id: int) -> None:
        """Remove a document by Django PK."""
        engine = get_search_engine()
        try:
            engine.delete_document(instance_id)
        except Exception as e:
            logger.error(f"Failed to delete doc {instance_id}: {e}")
    def bulk_index(self, queryset, commit_every: int = 5000) -> int:
        """
        Bulk index a queryset. Commits every `commit_every` documents
        to keep memory usage bounded.
        Returns total documents indexed.
        """
        engine = get_search_engine()
        count = 0
        for instance in queryset.iterator(chunk_size=1000):
            try:
                doc = self.to_document(instance)
                engine.index_document(instance.pk, doc)
                count += 1
                if count % commit_every == 0:
                    engine.commit()
                    logger.info(f"Indexed {count} {self.model.__name__} documents...")
            except Exception as e:
                logger.error(f"Indexing error at pk={instance.pk}: {e}")
        engine.commit()
        logger.info(f"Bulk index complete: {count} {self.model.__name__} documents")
        return count

Step 6: Django Settings and Model Configuration

# settings.py
import os
SEARCH_CONFIG = {
    "INDEX_PATH": os.path.join(BASE_DIR, "search_index"),
    "HEAP_SIZE_MB": 256,        # writer heap — increase for faster bulk indexing
    "FIELD_CONFIGS": [
        # (name, type, stored)
        # text: tokenized + indexed + stored
        # keyword: exact match, not tokenized
        # u64/i64/f64: numeric fast fields
        ("title", "text", True),
        ("body", "text", True),
        ("category", "keyword", True),
        ("tags", "text", True),
        ("status", "keyword", False),   # keyword filter, not stored
        ("price", "f64", False),        # fast field for range queries
    ],
    "TEXT_FIELDS": ["title", "body", "tags"],  # searched by default queries
    "FIELD_BOOSTS": {
        "title": 4.0,       # title matches worth 4x body matches
        "tags": 2.0,        # tag matches worth 2x body matches
        "body": 1.0,
    },
}

Step 7: Concrete Model Indexer and Signals

# myapp/search/indexers.py
from .indexer import ModelIndexer
from myapp.models import Article
class ArticleIndexer(ModelIndexer):
    model = Article
    def to_document(self, instance: "Article") -> dict:
        tags = " ".join(instance.tags.values_list("name", flat=True))
        return {
            "title": instance.title or "",
            "body": instance.body or "",
            "category": instance.category.slug if instance.category else "",
            "tags": tags,
            "status": instance.status,
        }
# Module-level singleton indexer
article_indexer = ArticleIndexer()
# myapp/search/signals.py
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from myapp.models import Article
from .indexers import article_indexer
from .engine import get_search_engine
import threading
def _index_async(indexer, instance):
    """Index in a background thread to avoid blocking the request."""
    def _run():
        try:
            indexer.index(instance)
            get_search_engine().commit()
        except Exception:
            pass
    thread = threading.Thread(target=_run, daemon=True)
    thread.start()
@receiver(post_save, sender=Article)
def index_article_on_save(sender, instance, **kwargs):
    # Only index published articles
    if instance.status == "published":
        _index_async(article_indexer, instance)
@receiver(post_delete, sender=Article)
def delete_article_from_index(sender, instance, **kwargs):
    def _run():
        try:
            article_indexer.delete(instance.pk)
            get_search_engine().commit()
        except Exception:
            pass
    threading.Thread(target=_run, daemon=True).start()

Step 8: Management Commands

# myapp/management/commands/build_index.py
from django.core.management.base import BaseCommand
from myapp.models import Article
from myapp.search.indexers import article_indexer
from myapp.search.engine import get_search_engine
import time
class Command(BaseCommand):
    help = "Build or rebuild the full-text search index from scratch."
    def add_arguments(self, parser):
        parser.add_argument(
            "--model",
            choices=["article", "all"],
            default="all",
            help="Which model to reindex",
        )
        parser.add_argument(
            "--batch-size",
            type=int,
            default=5000,
            help="Commit every N documents during bulk indexing",
        )
    def handle(self, *args, **options):
        model_choice = options["model"]
        batch_size = options["batch_size"]
        engine = get_search_engine()
        self.stdout.write("Starting index build...")
        start = time.monotonic()
        if model_choice in ("article", "all"):
            self.stdout.write("Indexing Articles...")
            qs = Article.objects.filter(status="published").select_related("category")
            count = article_indexer.bulk_index(qs, commit_every=batch_size)
            self.stdout.write(self.style.SUCCESS(f"  ✓ {count} articles indexed"))
        elapsed = time.monotonic() - start
        stats = engine.stats()
        self.stdout.write(
            self.style.SUCCESS(
                f"\nIndex build complete in {elapsed:.1f}s\n"
                f"  Documents: {stats['num_docs']}\n"
                f"  Segments:  {stats['num_segments']}"
            )
        )
# myapp/management/commands/search_stats.py
from django.core.management.base import BaseCommand
from myapp.search.engine import get_search_engine
class Command(BaseCommand):
    help = "Show search index statistics and test a query."
    def add_arguments(self, parser):
        parser.add_argument("--query", type=str, default="")
    def handle(self, *args, **options):
        engine = get_search_engine()
        stats = engine.stats()
        self.stdout.write(f"Index documents: {stats['num_docs']}")
        self.stdout.write(f"Index segments:  {stats['num_segments']}")
        query = options.get("query")
        if query:
            response = engine.search(query, top_k=10)
            self.stdout.write(f"\nQuery: '{query}'")
            self.stdout.write(f"Results: {response.total} in {response.latency_ms}ms")
            for r in response.results:
                self.stdout.write(f"  doc_id={r.doc_id}  score={r.score:.4f}")

Step 9: Django Views

# myapp/views.py
from django.http import JsonResponse
from django.views.decorators.http import require_GET
from .search.engine import get_search_engine
from .models import Article
import time
@require_GET
def search(request):
    """
    Full-text search over Articles.
    Query parameters:
      q      — search query (required)
      page   — page number (default: 1)
      size   — results per page (default: 20, max: 50)
      cat    — filter by category slug
    Returns ranked results with metadata hydrated from ORM.
    """
    query = request.GET.get("q", "").strip()
    if not query:
        return JsonResponse({"error": "q parameter required"}, status=400)
    page = max(1, int(request.GET.get("page", 1)))
    size = min(50, max(1, int(request.GET.get("size", 20))))
    category_filter = request.GET.get("cat")
    engine = get_search_engine()
    # Search: retrieve top_k = page * size to support pagination
    top_k = page * size
    response = engine.search(
        query=query,
        top_k=top_k,
        filter_field="category" if category_filter else None,
        filter_value=category_filter,
    )
    # Paginate the ranked results
    start_idx = (page - 1) * size
    page_results = response.results[start_idx:start_idx + size]
    if not page_results:
        return JsonResponse({
            "query": query,
            "total": 0,
            "page": page,
            "results": [],
            "meta": {"search_latency_ms": response.latency_ms},
        })
    # Hydrate from ORM — preserving score order
    ids_in_order = [r.doc_id for r in page_results]
    score_map = {r.doc_id: r.score for r in page_results}
    articles = Article.objects.filter(
        pk__in=ids_in_order,
        status="published",
    ).select_related("category").values(
        "id", "title", "slug", "excerpt",
        "category__name", "category__slug",
        "published_at",
    )
    # Sort by search score, not ORM order
    articles_by_id = {a["id"]: a for a in articles}
    ranked_articles = [
        {**articles_by_id[pk], "search_score": score_map[pk]}
        for pk in ids_in_order
        if pk in articles_by_id
    ]
    return JsonResponse({
        "query": query,
        "total": response.total,
        "page": page,
        "size": size,
        "results": ranked_articles,
        "meta": {
            "search_latency_ms": response.latency_ms,
            "has_next_page": response.total > page * size,
        },
    })
@require_GET
def search_stats(request):
    """Health/stats endpoint for the search index."""
    engine = get_search_engine()
    stats = engine.stats()
    return JsonResponse({
        "status": "ok",
        "index": stats,
    })

Step 10: AppConfig Wiring

# myapp/apps.py
from django.apps import AppConfig
import logging
logger = logging.getLogger(__name__)
class MyAppConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "myapp"
    def ready(self):
        # Register signals
        import myapp.search.signals  # noqa: F401
        # Pre-warm the search engine so the first request isn't slow
        import sys
        if "runserver" in sys.argv or "gunicorn" in sys.argv[0:1]:
            try:
                from .search.engine import get_search_engine
                engine = get_search_engine()
                logger.info(f"Search engine ready: {engine.stats()}")
            except Exception as e:
                logger.warning(f"Search engine initialization deferred: {e}")

Benchmark Results

All measurements on a c6i.2xlarge (8 vCPU, 16GB RAM), 5 million article documents, average document size 800 tokens, tantivy index on local NVMe.

Query latency (p50 / p95 / p99):

tantivy’s p99 at 48ms beats Elasticsearch by 2x on the same hardware. The index is 67% smaller than Elasticsearch. And unlike Elasticsearch or Meilisearch, it’s in-process — no network hop, no separate service to manage.

Indexing throughput (bulk):

Operational Considerations

Index Backup

The tantivy index is a directory on disk. Back it up like any other file:

# In your backup script
tar -czf search_index_$(date +%Y%m%d).tar.gz ./search_index/

For production: use an EBS volume snapshot or S3 sync. The index can be rebuilt from the database at any time with build_index management command, so backup is for RTO, not RPO.

Nightly Optimization

Tantivy accumulates index segments during normal writes. Many small segments slow queries. Merge them nightly:

# myapp/tasks.py (Celery)
from celery import shared_task
from .search.engine import get_search_engine
@shared_task
def optimize_search_index():
    """Merge index segments nightly for optimal query performance."""
    engine = get_search_engine()
    before = engine.stats()
    engine.optimize()
    after = engine.stats()
    return {
        "segments_before": before["num_segments"],
        "segments_after": after["num_segments"],
    }

Multi-Process Django (Gunicorn)

tantivy supports one writer process and multiple reader processes. With Gunicorn multi-process mode, all workers share the same on-disk index and can read concurrently. Only one process should write at a time.

Use the nightly indexing approach (a separate Celery task) for writes — not in-process signals from multiple Gunicorn workers — to avoid writer contention:

# settings.py — disable in-process signals if running multi-process
SEARCH_CONFIG = {
    ...
    "ASYNC_INDEX": True,   # use Celery task instead of in-process thread
}
# myapp/search/signals.py — conditional dispatch
@receiver(post_save, sender=Article)
def index_article_on_save(sender, instance, **kwargs):
    if instance.status != "published":
        return
    from django.conf import settings
    if settings.SEARCH_CONFIG.get("ASYNC_INDEX"):
        from myapp.tasks import index_article_task
        index_article_task.delay(instance.pk)
    else:
        _index_async(article_indexer, instance)

Schema Changes

Changing the schema (adding/removing fields) requires rebuilding the index:

python manage.py build_index --model all

Keep schema changes additive where possible (add fields, don’t remove them) to avoid full rebuilds in production.

What You Don’t Need Anymore

With tantivy embedded in your Django process:

  • ❌ Elasticsearch cluster (JVM, 4GB+ RAM, ops overhead)
  • ❌ Kibana or management console
  • ❌ Sync pipeline (Logstash, river plugin, custom sync service)
  • ❌ Separate monitoring for the search service
  • ❌ Network round-trip for every search query
  • ❌ Elasticsearch query DSL (tantivy uses Lucene query syntax)

What you do need: a build step for the Rust extension. That’s the entire operational overhead.

Conclusion

Full-text search is a solved problem at the data structure level — Lucene proved that in 2000. The question has always been the operational cost of running the search infrastructure.

tantivy brings Lucene-grade BM25 search into a single Rust library. PyO3 brings that library into your Django process. The result is a search system that beats Elasticsearch on p99 latency, uses half the disk space, costs zero infrastructure overhead, and deploys as a .so file.

The 200ms p99 headline is for 5 million documents. At your scale, it’s almost certainly faster. At Elasticsearch’s operational cost, that headroom is free.

Build the wheel once. Never spin up another JVM for search again.

Resources

Replaced Elasticsearch in production with tantivy? Share your numbers in the comments. Hitting the single-writer limitation at scale? The distributed path (Quickwit) uses the same tantivy foundation — worth a look.


메타데이터
post_id
2611ffdcc26d
slug
rust-powered-full-text-search-inside-django-zero-elasticsearch-with-tantivy-pyo3-2611ffdcc26d
url
https://medium.com/@yogeshkrishnanseeniraj/rust-powered-full-text-search-inside-django-zero-elasticsearch-with-tantivy-pyo3-2611ffdcc26d
canonical_url
https://medium.com/@yogeshkrishnanseeniraj/rust-powered-full-text-search-inside-django-zero-elasticsearch-with-tantivy-pyo3-2611ffdcc26d
author_url
https://medium.com/@yogeshkrishnanseeniraj
status
ok
fetched_at
2026-06-21 07:44:09