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…
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
- tantivy Rust crate docs
- tantivy GitHub
- PyO3 user guide
- maturin build tool
- tantivy query syntax
- Quickwit — tantivy-powered distributed search
- Our previous post: Rust connection pool for Django
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