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.
By Shahid Malik