All Articles
Backend

Searching Tabeer.ai's Question Bank With Postgres Full-Text Search — No Elasticsearch Needed

Shahid MalikBy Shahid MalikSeptember 4, 20266 min read

Content staff needed to search thousands of exam questions by text. Rather than standing up a separate search service, I used Postgres's built-in full-text search — with a SQLite fallback for local tests and a feature flag gating the rollout.

Content staff on Tabeer.ai manage thousands of exam questions and had no way to search them by text — only filtering by subject and chapter. The reflex reach for "add search" is often Elasticsearch or a hosted search service, but a single Postgres instance already holding the data, with django.contrib.postgres's built-in search support, covers this without adding a second system to run, monitor, and keep in sync.

SearchVector, SearchQuery, SearchRank

Postgres full-text search works by converting text into a tsvector (a normalized, stemmed, searchable representation) and matching it against a tsquery. Django's ORM wraps this directly:

from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector

vector = SearchVector("text", weight="A") + SearchVector("explanation", weight="B")
query = SearchQuery(q)
results = (
    Question.objects.select_related("subject", "chapter")
    .annotate(rank=SearchRank(vector, query))
    .filter(rank__gt=0)
    .order_by("-rank")[:50]
)

Weighting the question's own text field higher ("A") than its explanation ("B") means a match in the actual question wording ranks above a match that only appears in the explanation — Postgres's weight system supports four tiers (A–D) specifically for this kind of relevance tuning without needing a separate scoring engine.

Enabling it needed exactly one settings change:

INSTALLED_APPS = [
    ...
    "django.contrib.postgres",  # SearchVector/SearchQuery/SearchRank for question-bank search
    ...
]

The SQLite Problem

Tabeer.ai's local development and test suite both run on SQLite — Postgres full-text search simply doesn't exist there. Rather than forcing every contributor to run Postgres locally just to touch this one endpoint, the view checks the actual database backend and falls back to a plain icontains search on SQLite:

if connection.vendor == "postgresql":
    vector = SearchVector("text", weight="A") + SearchVector("explanation", weight="B")
    query = SearchQuery(q)
    return (
        queryset.annotate(rank=SearchRank(vector, query))
        .filter(rank__gt=0)
        .order_by("-rank")[:50]
    )

return queryset.filter(
    Q(text__icontains=q) | Q(explanation__icontains=q)
).order_by("-created_at")[:50]

This is an honest tradeoff, not a hidden one: the SQLite path is a correctness fallback so local tests exercise real matching behavior, not a claim that icontains and Postgres's ranked, stemmed full-text search behave identically. Production — where it actually matters — always runs the real Postgres path.

Gated Behind a Feature Flag, Not a Big-Bang Launch

This endpoint went out behind a server-side PostHog feature flag, staff-question-search, defaulting to off:

def get_queryset(self):
    if not feature_flags.is_enabled("staff-question-search", self.request.user, default=False):
        return Question.objects.none()
    ...

That means the endpoint can be deployed, tested against real production data by hitting it directly as a flagged-in user, and rolled out to content staff gradually — without a deploy being the same moment as the feature going live to everyone with access.

A Pagination Bug the Tests Caught

The project's DRF setup has global pagination (PageNumberPagination), so this view — a plain ListAPIView — returns {"count": ..., "results": [...]}, not a bare list. My first pass at the tests checked len(response.data), which quietly counted the keys of that response dict (always some small fixed number) instead of the number of matched questions. It took writing a test that actually asserted specific question IDs came back — not just a count — to notice the assertion was checking the wrong shape entirely. Fixed by reading response.data["results"] throughout, which is also a small reminder that a passing test suite only means what its assertions actually check.

The Result

Seven tests cover the real behavior: staff-only access (403 for non-staff, 401 unauthenticated), hidden by default when the flag is off (the one test that deliberately runs without mocking the flag, to prove the fail-closed default is real and not just documented), empty-query handling, and matches against both the question text and its explanation field.

If your app's data already lives in Postgres and you're about to reach for a separate search service, it's worth checking whether django.contrib.postgres.search already covers what you need. Get in touch.

Related Articles

Security

Rate Limiting, Token Blacklisting, and Admin MFA — Auth Hardening for Tabeer.ai

Part two of the production-readiness checklist: authentication and account security. Five of fifteen items were genuinely missing — rate limiting, real logout, admin MFA, consent records, and self-service data export/deletion — and here's exactly how each got fixed without a new dependency for most of them.

10 min read
Security

The Encryption Checklist for a Small Django Production Stack

Thirteen encryption checks, framed as 'encryption at multiple layers, not just HTTPS.' Most of the layers already existed — TLS to Postgres, client-side-encrypted backups — one item doesn't apply to this architecture at all, and two needed an AWS console check I couldn't run myself.

7 min read
Shahid Malik - AI-First Odoo Consultant

Shahid Malik

AI-First Odoo ERP Specialist

Shahid Malik is an AI-first Odoo consultant helping businesses solve complex ERP and business process challenges. His work combines Odoo consulting, process optimization, automation, integrations, migrations, and practical AI solutions to build scalable and reliable business systems.

Book a consultation for your Odoo project
Discuss Your Odoo Project