David Gaitán
← Back to blog
Python

Building fiction-scout: full-text search that syncs itself

· 5 min read

I've been building fiction-scout, a Python package that adds full-text search to your models without turning your codebase into a search-infrastructure project. It's inspired by Laravel Scout — the same idea, ported to a language that doesn't have one dominant ORM to design around.

What it does

Add a Searchable mixin to a model, and fiction-scout keeps a search index in sync with it automatically — using each ORM's own change-tracking mechanism. Django gets signal-based syncing, SQLAlchemy gets after_commit session events. No polling, no cron job re-scanning tables, no manual "don't forget to reindex" step in your deploy checklist.

Everything routes through one seam: the SearchableAdapter protocol. The core engine, the fluent query Builder, and the CLI never talk to Django or SQLAlchemy directly — they talk to the protocol. Two adapters ship today; a third doesn't require touching core code, just implementing the same interface.

Why I'm building it

Laravel developers get Scout for free — install it, add a trait, done. Python has never had an equivalent for this specific problem: keep-index-in-sync-automatically, with a real driver abstraction underneath it, that isn't tied to one ORM. Most Python search setups I've worked with are ad hoc — a signal handler here, a management command there, a search client wired up differently in every project. The goal is to make that a solved problem: swap in database, collection, or eventually a hosted engine like Algolia or Meilisearch, without rewriting how your models talk to search.

Sample usage

# settings.py
INSTALLED_APPS = [..., "fiction_scout.adapters.django"]
FICTION_SCOUT = {"driver": "database"}
# models.py
from django.db import models
from fiction_scout.adapters.django.mixin import SearchableMixin


class Post(SearchableMixin, models.Model):
    title = models.CharField(max_length=255)
    body = models.TextField()


# Saving a Post automatically syncs it to the configured search index.
Post.objects.create(title="Star Trek II", body="The Wrath of Khan")

results = Post.search("Star Trek").get()

The Builder returned by .search() also supports .where(), .where_in(), .within() for targeting a specific index, and .with_trashed() for soft-deleted rows — enough to cover the filtering people actually reach for before they need a dedicated search engine.

Adding it to a project that already has data

Auto-sync only covers rows created or changed after a model starts using SearchableMixin — it doesn't retroactively know about the 50,000 posts already in your table. That's what the batch import command is for:

python manage.py fiction_scout import myapp.models.Post

It chunks through your existing rows (500 at a time by default) and pushes each batch into the configured index. For a table too large to block on, queue-import does the same walk but routes each batch through your configured dispatcher — Celery, for instance — instead of running inline:

python manage.py fiction_scout queue-import myapp.models.Post

Same two commands are available outside Django via the standalone CLI (fiction-scout import ... / fiction-scout queue-import ...), which the management command bridge wraps rather than reimplements.

Connecting Algolia in minutes

The database driver is the default and needs nothing beyond your existing database, but swapping to Algolia doesn't touch your models at all — it's a config change:

pip install "fiction-scout[algolia]"
FICTION_SCOUT = {
    "driver": "algolia",
    "algolia_app_id": "...",   # or ALGOLIA_APP_ID env var
    "algolia_api_key": "...",  # or ALGOLIA_API_KEY env var
}

Run the batch import once to backfill, and every save from then on syncs to Algolia automatically:

python manage.py fiction_scout import myapp.models.Post

If you want to control ranking or which fields are searchable, that's also just config, pushed with a dedicated command:

FICTION_SCOUT = {
    "driver": "algolia",
    "algolia_app_id": "...",
    "algolia_api_key": "...",
    "searchable_attributes": ["title", "body"],
    "custom_ranking": ["desc(views)"],
}
fiction-scout sync-index-settings myapp.models.Post

That's the whole migration path from "no search" to "records already indexed and staying in sync" — no reindex scripts to write, no separate sync service to run.

Where it stands

This is pre-release, early development. The core engine, the database and collection drivers, Django and SQLAlchemy adapters, the Algolia and Meilisearch engines, and the standalone CLI (fiction-scout import|queue-import|flush|sync-index-settings) are all implemented and tested. Async support and additional ORM adapters are deliberately deferred — not missing by accident, but scoped out of v1 with the extension points already built for them.

I'm working toward a first tagged v1 release soon. If you're building something in Django or SQLAlchemy and want search that doesn't require standing up Elasticsearch for a "find this by title" feature, keep an eye on the repo.

Have a project in mind?

I'm always open to hearing about interesting backend work.