37signals Unveils Active Search to Standardize Rails Full-Text Queries

Active Search - Donal McBreen — Ruby on Rails

Ruby on Rails

The framework eliminates search backend lock-in by returning hydrated Active Record models across databases and Elasticsearch, while intentionally eschewing automated migrations to avoid cross-engine schema complexity.

AI-assisted summary of Active Search - Donal McBreen — original source. Check the original for full context.

Source published:

Key ideas

  • 37signals has introduced Active Search, an alpha-stage full-text search framework for Ruby on Rails that provides a single unified API across relational databases (SQLite, PostgreSQL, MySQL) and dedicated search engines like Elasticsearch. (0:00)
  • Already running in production at 37signals, Active Search powers Basecamp on Elasticsearch and allows the open-source Kanban app Fizzy to dynamically toggle between MySQL and SQLite depending on the deployment environment. (0:00)
  • Unlike raw search clients that return JSON hashes, Active Search queries hydrate matching document IDs directly into native Active Record model instances while preserving engine-level metadata like relevance scores and highlighted snippets via a `.hit` method. (6:40)
  • When using relational database adapters, Active Search generates dedicated, denormalized Active Record document models and migrations—such as SQLite FTS virtual tables or PostgreSQL `tsvector` columns—that support standard multi-database routing and read replicas. (22:45)

Detailed notes

## Background Published on YouTube by the Ruby on Rails channel on October 1, 2026, this presentation features Donal McBreen, a principal programmer at 37signals, introducing Active Search. According to the video description, the talk covers a new full-text search framework for Rails featuring a unified API with pluggable adapters for SQLite, Postgres, MySQL, and Elasticsearch, demonstrating its production usage in Basecamp and Fizzy alongside an end-to-end demo.

## TL;DR In this presentation, Donal McBreen introduces Active Search, a unified full-text search framework for Ruby on Rails that mirrors Active Record by decoupling a single search API from pluggable backend adapters like Elasticsearch, SQLite, PostgreSQL, and MySQL. He walks through index configuration in `config/search.rb`, model integration via the `has_search` macro, and demonstrating how search queries automatically hydrate back into native Active Record instances. The talk also explores polymorphic indexing across multiple models, denormalized SQLite FTS document models, and chainable query filtering. Finally, McBreen details low-level escape hatches like `to_native_query` and backend capability flags, inviting community experimentation with the alpha-stage gem currently running in production at 37signals.

## Atmosphere & style The presentation carries a grounded, pragmatic, and unpretentious tone, opening with a lighthearted remark about delivering an 'AI-free talk' palate cleanser before settling into a developer-focused architectural walkthrough. McBreen maintains a calm, transparent delivery, openly discussing intentional trade-offs—such as skipping automatic cross-engine schema migrations—and acknowledging rough edges in what he candidly frames as alpha software. Watching the talk feels like an insider look into the design of a core Rails primitive, driven by real-world production pragmatism and clean developer ergonomics.

## Bulletin - [00:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=0s) 37signals has introduced Active Search, an alpha-stage full-text search framework for Ruby on Rails that provides a single unified API across relational databases (SQLite, PostgreSQL, MySQL) and dedicated search engines like Elasticsearch. - [00:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=0s) Already running in production at 37signals, Active Search powers Basecamp on Elasticsearch and allows the open-source Kanban app Fizzy to dynamically toggle between MySQL and SQLite depending on the deployment environment. - [06:40](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=400s) Unlike raw search clients that return JSON hashes, Active Search queries hydrate matching document IDs directly into native Active Record model instances while preserving engine-level metadata like relevance scores and highlighted snippets via a `.hit` method. - [22:45](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=1365s) When using relational database adapters, Active Search generates dedicated, denormalized Active Record document models and migrations—such as SQLite FTS virtual tables or PostgreSQL `tsvector` columns—that support standard multi-database routing and read replicas.

## Key takeaways - [00:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=0s) Active Search provides a unified Rails search API with pluggable store adapters, sparing developers from writing backend-specific search logic across SQLite, PostgreSQL, MySQL, and Elasticsearch. - [00:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=0s) 37signals uses Active Search in production to power Basecamp's Elasticsearch infrastructure and to allow their open-source Kanban app, Fizzy, to switch seamlessly between MySQL and SQLite. - [02:37](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=157s) Framework configuration is divided between `config/search.yml` for environment-specific adapter connection settings and `config/search.rb` for defining index schemas with full-text (`text`) and filterable (`string`) fields. - [04:47](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=287s) Active Search deliberately avoids an automated schema migration system for standalone search engines due to cross-engine complexity, meaning schema modifications must be managed manually or by rebuilding the index. - [06:40](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=400s) Querying via `.search` on a model equipped with `has_search` fetches matching document IDs from the search backend and automatically hydrates them into native Active Record model instances rather than raw document hashes. - [08:35](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=515s) Active Search operates through a three-tier pipeline—the Index layer, the Store Adapter, and the underlying Search Engine or Database—while also allowing developers to bypass Active Record to index arbitrary Ruby hashes directly. - [10:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=600s) By default, model lifecycle callbacks dispatch asynchronous Active Job tasks (`ActiveSearch::ReindexJob` and `ActiveSearch::RemoveJob`) to maintain the index, though passing `has_search async: false` runs indexing inline. - [10:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=600s) Custom serializers via methods or inline procs allow developers to transform fields (such as truncating body text), synthesize dynamic attributes, or extract data from associations before indexing. - [15:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=900s) The query API supports chaining `.filter`, `.reject`, `.filter_any`, and custom sort orders, explicitly deferring execution until `.results` is called so hits and metadata are retrieved in a single round-trip. - [15:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=900s) Hydrated model instances returned from a search are equipped with a `.hit` method that exposes engine-level details including relevance scores, match diagnostics, and highlighted text snippets. - [18:22](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=1102s) Polymorphic indices add `record_type` and `record_id` fields to aggregate multiple models into a shared index, returning mixed model results when queried via `ActiveSearch.index` or implicitly filtering to a single class when called on an Active Record model. - [22:45](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=1365s) Database adapters generate a denormalized Active Record document model (such as `ArticleDocument`) and migration to store serialized filter attributes alongside backend-specific structures like SQLite `_fts` virtual tables, PostgreSQL `tsvector` columns, or MySQL `FULLTEXT` indexes. - [25:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=1500s) For low-level customization, `.native` injects engine-specific ranking or scoring parameters into a query, while `to_native_query` exposes the underlying Elasticsearch hash or joinable `ActiveRecord::Relation`. - [25:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=1500s) Because features vary across engines—for example, native result highlighting is supported in Elasticsearch, PostgreSQL, and SQLite, but unsupported in MySQL—adapters expose a `capabilities` system to signal when applications must handle features manually.

## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=0s) Introducing Active Search: A Unified Search Framework for Rails

Donal McBreen, a principal programmer at 37signals, opens with a lighthearted observation about the tech climate: with how "exhausting" it has become to talk to AI tools all day and talk about AI whenever one isn't using them, this presentation serves as a welcome, "AI free talk" palate cleanser. The focus is entirely on Active Search, a new full-text search framework built for Ruby on Rails. Much like core Rails components such as Active Record or Active Job, Active Search provides developers with a single, uniform API backed by pluggable adapters, sparing developers from writing backend-specific search logic across their applications.

The framework supports two distinct categories of storage backends: 1. **Relational databases with built-in full-text search capabilities:** SQLite, PostgreSQL, and MySQL. 2. **Dedicated standalone search engines:** Modern engines such as Elasticsearch and others.

Within 37signals, Active Search is already battle-tested in production. Basecamp relies on it paired with Elasticsearch to power its full-text search infrastructure. Additionally, 37signals uses Active Search inside Fizzy, their free and open-source Kanban application. Because Fizzy ships both as a hosted SaaS product and as a self-hostable standalone application, Active Search allows the app to dynamically toggle between MySQL and SQLite depending on the environment. Developers interested in viewing a real-world integration can inspect Active Search directly within the open-source Fizzy codebase.

```mermaid flowchart TD accTitle: Active Search Architecture Overview accDescr: Active Search sits between Rails models and pluggable storage backends divided into databases and dedicated search engines.

subgraph RailsApp["Rails Application Layer"] Model["Active Record Model\n(e.g., Article)"] ASAPI["Active Search API\n(has_search, search)"] end

subgraph Adapters["Active Search Store Adapters"] DBAdapters["Database Adapters\n(SQLite, Postgres, MySQL)"] EngineAdapters["Engine Adapters\n(Elasticsearch, etc.)"] end

Model --> ASAPI ASAPI --> DBAdapters ASAPI --> EngineAdapters ```

[02:37](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=157s) Installation and Configuring the Search Engine

To illustrate how Active Search operates, McBreen walks through a demo scenario built around a classic schema: an `articles` table associated with a `comments` table. In typical search implementations, attributes fall into two buckets: full-text searchable fields where users search for arbitrary words (here, `title` and `body`), and structured metadata fields used to narrow down matches (such as publication dates or authorship).

Getting started requires installing the Active Search gem and running its installation generator:

```bash bin/rails active_search:install ```

For the initial walkthrough, McBreen configures the Elasticsearch adapter (noting that database adapters function similarly with minor differences explained later). The install generator creates two core configuration files: - `config/search.yml`: Defines environment-specific settings (development, test, production), specifying the target adapter along with connection parameters. While production deployments specify hostnames and custom ports, it defaults out of the box to `localhost:9200` for Elasticsearch. - `config/search.rb`: The central DSL file where search index schemas are defined. It starts essentially empty, containing only commented boilerplate illustrating expected declarations.

[04:47](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=287s) Defining Index Schemas and Model Integration

To index the `Article` model, Active Search provides a dedicated generator that accepts model attributes and their intended search roles. Fields passed without an explicit type (such as `title` and `body`) default to full-text search attributes, whereas filterable attributes are explicitly designated (for instance, allowing users to restrict queries to articles published within the last month).

Running the generator modifies both the configuration and the model: - In `config/search.rb`, the generator appends an index definition. The schema differentiates between Active Search types: `text` fields are indexed for full-text token matching, while `string` fields are preserved as exact values for filtering. - In `app/models/article.rb`, the generator injects the `has_search` macro.

The `has_search` declaration equips the Active Record model with essential search machinery: it adds class-level querying methods, instance helper methods, and model lifecycle callbacks (`after_save`, `after_destroy`) to automatically push record changes to the search index.

Once declared, the physical index must be provisioned on the backend using a rake task:

```bash bin/rails active_search:index:create index=articles ```

The output confirms the schema fields and the active adapter. Querying the Elasticsearch HTTP mapping API directly reveals the generated Elasticsearch schema matching the Rails declaration. McBreen highlights an intentional design tradeoff: Active Search deliberately avoids providing an automated schema migration system analogous to Active Record migrations. Supporting cross-engine schema modifications across diverse search backends would introduce immense complexity. If an index definition changes later, altering existing fields must currently be managed manually or by rebuilding the index.

[06:40](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=400s) Indexing Records, Querying, and Result Hydration

With the index created, creating sample records in Rails automatically triggers Active Search callbacks, formatting the records and transmitting them to the configured backend.

Searching the index is invoked directly on the model via the class method injected by `has_search`:

```ruby results = Article.search("rails") ```

The returned `results` object is an enumerable collection with a crucial architectural property: **it yields native Active Record model instances, not raw search engine JSON or document hashes**. Under the hood, Active Search executes the query against Elasticsearch to identify matching document IDs, fetches the corresponding rows from the database, and hydrates them into standard `Article` model objects. Iterating over the collection (e.g., mapping article titles) yields standard Rails models containing all database attributes, retaining typical Rails behavior throughout application views and controllers.

[08:35](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=515s) Architecture: Indexes, Store Adapters, and Engine Communication

The operational architecture of Active Search is organized around a clean three-tier pipeline:

```mermaid flowchart LR accTitle: Active Search Data Flow accDescr: Demonstrates index writing and querying flows between the model, Active Search index, store adapter, and search engine.

subgraph Rails["Rails Application"] Model["Article Record"] end

subgraph ActiveSearchLayer["Active Search Framework"] Idx["Index\n(:articles)"] Store["Store Adapter\n(Elasticsearch Adapter)"] end

subgraph Backend["External Store"] Engine["Search Engine\n(Elasticsearch 9200)"] end

Model -->|"1. Save Callback / Query"| Idx Idx -->|"2. Transform"| Store Store -->|"3. HTTP / SQL"| Engine Engine -->|"4. Raw Results"| Store Store -->|"5. IDs / Hits"| Idx Idx -->|"6. Hydrated Models"| Model ```

1. **The Index Layer:** By default, named after the model's underlying database table (e.g., `:articles`). It defines what fields exist and acts as the entry point. 2. **The Store Adapter:** A dedicated bridge adapter tailored to the specific backend (such as Elasticsearch, PostgreSQL, or SQLite). 3. **The Search Engine / Database:** The actual underlying storage handling the inverted index or database full-text catalog.

When an article record is saved, the model delegates to the index, the index hands structured data to the adapter, and the adapter issues the appropriate driver command or HTTP payload to the search engine. When querying, the process reverses: a search term travels down through the adapter to the search engine, raw hits return up the pipeline, and Active Search queries the primary database to hydrate those IDs into full Active Record instances.

Developers can also inspect the index directly in the Rails console via `ActiveSearch.index(:articles)`, exposing methods to interrogate the index name, underlying search engine naming conventions, and the active store adapter instance.

[10:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=600s) Direct Index Access, Async Callbacks, and Document Serialization

Active Search models expose direct index access, meaning developers can bypass ActiveRecord integration entirely if needed. One can talk directly to an index object to add articles, remove articles, or call `remove_by_id` (especially useful when a model instance has already been destroyed in the database and is no longer available in memory). Raw searches can also be executed directly against the index. While the standard Rails pattern relies on model integration handling communication behind the scenes, using Active Search standalone allows developers to index arbitrary data structures—such as standalone Ruby hashes—without backing ActiveRecord tables.

When models are integrated via the `has_search` macro, lifecycle callbacks are hooked into model creation, updates, and destruction. These callbacks dispatch background jobs (such as `ActiveSearch::ReindexJob` and `ActiveSearch::RemoveJob`) to perform asynchronous indexing. For workflows requiring immediate consistency, passing `has_search async: false` bypasses the background queues and runs the indexing inline within the application process.

```mermaid flowchart TD accTitle: Active Search Indexing Pipeline accDescr: Demonstrates how an article moves from ActiveRecord save to jobs, index conversion, and final storage engine. subgraph App["Application Layer"] A["Save Article"] --> B["ReindexJob"] end subgraph IndexLayer["Active Search"] B --> C["Index"] C --> D["Document (Hash: id + data)"] end subgraph Storage["Search Engines"] D --> E["Elasticsearch (JSON blob)"] D --> F["SQLite / MySQL (SQL query)"] end ```

Under the hood, the index serializes an ActiveRecord model into an internal `Document` format by calling the `document_for` method. This simple data payload contains an identifier alongside a hash of mapped fields matching the index definition. By default, Active Search expects model methods corresponding to field names (e.g., calling `article.title` and `article.body`). If field names diverge, or if data must be pulled from associations, developers can define a custom serializer via a dedicated method or an inline proc:

- Custom serializers allow transforming fields before indexing, such as truncating long body text to prevent index bloat. - Serializers can synthesize fields dynamically (e.g., inferring a `status` field from a `published_at` timestamp). - The generated document hash is forwarded directly to the backend store, transforming into an Elasticsearch JSON blob or an insert/upsert query in SQLite or MySQL.

---

[15:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=900s) Chainable Search API, the `results` Method, and Hit Metadata

Searching Active Search indices mimics the chainable design of ActiveRecord relations. Starting a query with `Article.search("rails")` returns a composable query object. Additional constraints like `.filter(...)` or custom sort orders return new query objects containing all cumulative criteria, defaulting to relevance ordering when unspecified:

- Queries remain chainable across multiple criteria and return query objects at each step until evaluated. - Filtering supports multi-field criteria, multiple values, and both open-ended and closed ranges (ideal for filtering by publication windows like recently published articles).

``` # Example of the chainable search API flow Article.search("rails") .filter(status::published, published_at: 1.month.ago..) .results ```

Execution is triggered explicitly by calling `.results`. Rather than immediately iterating via `article.search.each`, Active Search separates query construction from execution to prevent accidental repeated searches. Evaluating the search only via `.results` ensures that metadata—such as total result counts, partial execution warnings, and execution duration—is fetched in a single round-trip without running redundant performance-degrading queries. The returned results object is fully `Enumerable`, converting smoothly to an array of model instances.

Calling `has_search` on a model also equips instances with a `hit` method to store search-specific context. When results return from the underlying search engine, the associated `hit` object exposes engine-level details that do not exist on the database record:

- Underlying relevance scores calculated by the engine. - Raw engine fields and match diagnostics. - Highlighted snippets indicating exact word matches within the document text.

---

[18:22](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=1102s) Polymorphic Searching Across Multiple Models

When an application contains diverse searchable content—such as searching both articles and comments simultaneously—Active Search provides polymorphic indices. Rather than constraining an index to a single record type, a generic index (such as `records`) can be defined with universal search fields like `title` and `body`.

Declaring an index as polymorphic automatically adds `record_type` and `record_id` fields to the search schema. These composite fields serve two primary functions: 1. They map search hits back to their original ActiveRecord models across different tables. 2. They allow queries to be scoped or filtered by model type directly within the search backend.

Models are not restricted to a single search destination. An article model can declare multiple `has_search` definitions simultaneously. By default, an index name is inferred from the model name (e.g., routing an article to an `articles` index), but additional declarations can explicitly target shared indexes like `records`, enabling records to exist in both specialized single-table indices and aggregated multi-model search pools.

[20:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=1200s) Polymorphic Indexing and Searching Across Models

When configuring callbacks across multiple indices, saving or updating a model can fan out across different search pipelines. In the polymorphic setup Donal demonstrates, creating or updating an article triggers callbacks that generate two separate indexing jobs: one to populate the dedicated `articles` index and another to push the record into the broader `records` index. Comments follow a similar pattern, but with a twist: because comments do not have their own standalone title attribute, they utilize a custom serializer that extracts and synthesizes a title directly from the parent article. Donal notes a practical edge case in production: developers must ensure that updating an article also triggers a re-index of all its associated comments, though he skips the exact re-indexing implementation details for the demo.

Once a polymorphic index exists, there are two distinct ways to query it:

```mermaid flowchart TD accTitle: Polymorphic Search Query Paths accDescr: Diagram showing how a polymorphic index returns mixed records via ActiveSearch directly versus single-model records via the model search method.

subgraph Query["Query Methods"] Q1["ActiveSearch.index(:records).search('...')"] Q2["Article.search('...', index::records)"] end

subgraph Index["Polymorphic Index (:records)"] R1["Articles"] R2["Comments"] end

subgraph Results["Search Results"] Res1["Mixed Results: Articles & Comments"] Res2["Filtered Results: Articles Only<br/>(Hidden filter: record_type = 'Article')"] end

Q1 --> Index Index --> Res1

Q2 --> Index Index -->|"implicit filter"| Res2 ```

Querying the polymorphic index directly via `ActiveSearch.index(:records).search(...)` reaches into the shared store and returns a heterogeneous collection mixing both articles and comments. Alternatively, calling `.search` directly on an Active Record model—such as `Article.search("...", index::records)`—defaults to scoping results to that specific class. Under the hood, Active Search injects an invisible filter equivalent to `.filter(record_type: "Article")`. This allows developers to query against a massive multi-model index while letting the caller "basically pretend there's just articles in our index."

- **Dual-job callbacks**: Saving a record can dispatch distinct Active Job tasks for each target index simultaneously. - **Custom serializers**: Useful for indexing calculated attributes or values belonging to associations, such as pulling an article's title into a comment search record. - **Direct index access**: `ActiveSearch.index(:records)` returns the underlying search index handle. - **Implicit model scoping**: Searching through a model wrapper transparently narrows the query so caller code only ever receives instances of that model back.

---

[22:45](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=1365s) Database Adapters: SQLite Full-Text Search and Denormalized Document Models

Switching from dedicated search engines like Elasticsearch to database-backed adapters involves a different architectural approach. When running the installer configured for SQLite, Active Search creates a minimal `search.yml` declaring `adapter: sqlite`, which references standard connection definitions from `database.yml`. However, generating an index for SQLite does something extra beyond updating `config/search.rb` and injecting `has_search` into the model: it generates a dedicated Active Record document model (such as `ArticleDocument`) and a companion database migration.

```mermaid flowchart LR accTitle: SQLite Active Search Document Model Architecture accDescr: Diagram showing how Article maps to an ArticleDocument backed by a regular table and an FTS virtual table.

A["Article Model"] -->|"has_search"| AD["ArticleDocument Model<br/>(Subclasses ApplicationRecord)"]

subgraph Storage["SQLite Database"] T1["article_documents Table<br/>(article_id, status, views, published_at)"] T2["article_documents_fts<br/>(Virtual Table: title, body)"] end

AD --> T1 AD --> T2 ```

In the database adapter paradigm, the `ArticleDocument` model represents the search index itself. Because it inherits directly from `ApplicationRecord`, developers can leverage all standard Active Record functionality, including custom connection routing, read replicas, or moving search indexes to dedicated secondary databases. In SQLite, the migration creates an `article_documents` table to store metadata and filtering attributes (`article_id`, `status`, `views`, `published_at`), alongside a companion `article_documents_fts` virtual table specifically for full-text search on `title` and `body`. Donal explains that this requires denormalizing data: because Active Search allows arbitrary transformations and custom serializers, the framework cannot work backwards to inspect original source tables when filtering, making a dedicated denormalized document table necessary.

- **SQLite configuration**: Minimal declaration in `search.yml` automatically ties into Rails' existing `database.yml`. - **Generated document model**: Named by convention with a `Document` suffix (e.g., `ArticleDocument`) and acts as the interface to the underlying database index. - **Full Active Record power**: Document models can utilize replicas and multi-database configurations out of the box. - **SQLite virtual tables**: Uses SQLite's FTS engine (`_fts` virtual table) for high-performance text matching on unstructured text fields like titles and bodies. - **Necessity of denormalization**: Since indexed data may be calculated, formatted, or derived from arbitrary serializers, the search table must duplicate those values to allow indexing and filtering.

---

[25:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=1500s) Engine Differences, Escape Hatches, Capabilities, and Open-Source Roadmap

Database search implementations vary significantly across backends. While SQLite relies on dedicated FTS virtual tables, PostgreSQL retains search columns in the main table and supplements them with `tsvector` columns and specialized indexes. MySQL represents the simplest pattern, maintaining standard columns with native `FULLTEXT` indexing.

Beyond core text search, Active Search provides rich querying primitives such as `.reject` (negated filtering) and `.filter_any` (OR-style conjunctions). For power users needing access to raw features, the framework includes two critical escape hatches:

```mermaid flowchart TD accTitle: Active Search Escape Hatches accDescr: Diagram illustrating the escape hatches to_native_query and native for low-level search engine integration.

Query["Active Search Query"]

Query -->|"to_native_query"| RawQuery["Inspect Raw Query"] Query -->|"native"| ModifyEngine["Low-Level Engine Modification"]

RawQuery --> ES_Q["Elasticsearch: Raw Query Hash / JSON"] RawQuery --> SQL_Q["SQLite: ActiveRecord Relation (joinable!)"]

ModifyEngine --> LowLevel["Custom scoring, boosts, custom ranking logic"] ```

Calling `to_native_query` exposes the internal query payload—returning a JSON-ready Ruby hash when running on Elasticsearch, or an actual `ActiveRecord::Relation` when backed by SQLite, enabling developers to "do crazy things like just join to that or just plug it into Active Record as you want." Meanwhile, the `.native` method allows passing low-level engine parameters directly into the query, such as platform-specific custom ranking or relevance scoring.

To handle disparities across engines without breaking compatibility, Active Search introduces a `capabilities` system. Unlike traditional relational databases where Active Record features are relatively uniform across engines, search backends differ wildly. A prime example is result highlighting: marking matching search keywords directly within returned text is supported out of the box in Elasticsearch, PostgreSQL, and SQLite, but is unsupported in MySQL. Active Search adapters use capabilities flags to declare whether a given feature is supported natively, signaling to developers when they need to handle tasks (such as string highlighting) manually in application code.

Donal wraps up by reiterating how simple the core API remains: declare `has_search` in the model and call `.search` to fetch results. He notes that the gem is currently hosted at `github.com/basecamp/rails/active_search` and can be seen operating in production within the open-source Fizzy Kanban application (`Basecamp/fizzy`). He warns the audience that the library is still "sort of alpha-ish software" whose API is not yet set in stone, and invites the Rails community to try it out and share their feedback.

- **PostgreSQL implementation**: Generates `tsvector` columns paired with GIN/GiST indexes. - **MySQL implementation**: Uses native `FULLTEXT` indexes directly over existing text columns. - **Escape hatch `to_native_query`**: - Elasticsearch returns the raw hash used to build search JSON. - SQLite returns an `ActiveRecord::Relation`, allowing direct SQL joins and scopes. - **Escape hatch `native`**: Injects custom engine-specific configuration, scoring algorithms, and sorting logic into the query. - **Capabilities matrix**: Declares backend feature availability; highlighting is natively supported on SQLite, PostgreSQL, and Elasticsearch, but unsupported on MySQL. - **Project status**: Alpha-stage gem under active development in the Basecamp GitHub organization, running in production on Fizzy.

## Notable quotes > [00:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=0s) exhausting — Donal McBreen

> [00:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=0s) AI free talk — Donal McBreen

> [04:47](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=287s) has_search — Donal McBreen

> [06:40](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=400s) results — Donal McBreen

> [10:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=600s) has_search async: false — Donal McBreen

> [15:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=900s) hit — Donal McBreen

> [20:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=1200s) basically pretend there's just articles in our index. — Donal McBreen

> [25:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=1500s) do crazy things like just join to that or just plug it into Active Record as you want. — Donal McBreen

> [25:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=1500s) to_native_query — Donal McBreen

> [25:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=1500s) native — Donal McBreen

> [25:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=1500s) capabilities — Donal McBreen

> [25:00](https://www.youtube.com/watch?v=OlzIyVzmMgI&t=1500s) sort of alpha-ish software — Donal McBreen

## Mentioned Donal McBreen, 37signals, Active Search, Ruby on Rails, Active Record, Active Job, SQLite, Postgres, MySQL, Elasticsearch, Basecamp, Fizzy, Shopify, GitHub

## Video description Donal McBreen, principal programmer at 37signals, introduces Active Search, a new full-text search framework for Rails. Like Active Record or Active Job, it gives you a single API with pluggable adapters. It supports databases with full-text features (SQLite, Postgres, and MySQL) as well as dedicated search engines such as Elasticsearch.

Donal shows how 37signals uses Active Search in production. Basecamp runs it on Elasticsearch, while Fizzy, the free and open-source Kanban app, uses it to switch between MySQL and SQLite. He then walks through a demo app to show how Active Search works, from installation and configuration to searching full-text fields and filtering results.

#RailsWorld #RubyOnRails #Rails #ActiveSearch #FullTextSearch #37signals

Thank you Shopify for sponsoring the editing and post-production of these videos. Check out insights from the Engineering team at: https://shopify.engineering/

[Source](https://www.youtube.com/watch?v=OlzIyVzmMgI)

中文简报

37signals 推出 Active Search,标准化 Rails 全文检索查询 该框架通过跨数据库和 Elasticsearch 返回实例化的 Active Record 模型,消除了搜索后端锁定,同时刻意规避自动迁移,以避免跨引擎架构的复杂性。 在本次演讲中,Donal McBreen 介绍了面向 Ruby on Rails 的统一全文检索框架 Active Search。该框架借鉴了 Active Record 的设计,将单一搜索 API 与 Elasticsearch、SQLite、PostgreSQL 和 MySQL 等可插拔后端适配器解耦。他演示了 `config/search.rb` 中的索引配置、通过 `has_search` 宏进行的模型集成,以及搜索查询如何自动加载为原生的 Active Record 实例。演讲还探讨了跨多个模型的多态索引、反规范化的 SQLite FTS 文档模型以及链式查询过滤。最后,McBreen 详细介绍了 `to_native_query` 和后端功能标志等底层逃生舱口机制,并邀请社区试用这个目前已在 37signals 生产环境中运行的 Alpha 阶段 gem。 37signals 推出了处于 Alpha 阶段的 Ruby on Rails 全文检索框架 Active Search,在关系型数据库(SQLite、PostgreSQL、MySQL)与 Elasticsearch 等专用搜索引擎之间提供单一的统一 API。 (0:00) Active Search 已在 37signals 的生产环境中运行,基于 Elasticsearch 为 Basecamp 提供支持,并允许开源看板应用 Fizzy 根据部署环境在 MySQL 和 SQLite 之间动态切换。 (0:00) 与返回 JSON 哈希的原始搜索客户端不同,Active Search 查询会将匹配的文档 ID 直接加载为原生的 Active Record 模型实例,同时通过 `.hit` 方法保留相关性评分和高亮片段等引擎级元数据。 (6:40) 在使用关系型数据库适配器时,Active Search 会生成专用的反规范化 Active Record 文档模型及迁移文件(如 SQLite FTS 虚拟表或 PostgreSQL `tsvector` 列),以支持标准的多数据库路由和只读副本。 (22:45)

Reading summary

Donal McBreen presents Active Search, a Ruby on Rails framework that unifies full-text search across standalone search engines and relational databases through a consistent, model-driven API. Unified Architecture Across Diverse Backends (0:00) Active Search acts as a bridge between Rails application models and diverse search backends, supporting standalone engines like Elasticsearch as well as database-native search in SQLite, PostgreSQL, and MySQL. Much like Active Record or Active Job, the framework abstracts backend-specific querying behind a uniform interface. In 37signals' production environments, Basecamp pairs Active Search with Elasticsearch, while the open-source Kanban app Fizzy uses it to toggle dynamically between MySQL and SQLite depending on whether it is running as SaaS or a self-hosted installation. The framework functions as a three-tier pipeline: an index definition layer, a backend store adapter, and the underlying search engine. In basic configurations, adding the `has_search` macro to an Active Record model sets up lifecycle callbacks that push updates to the backend automatically. Executing a search directly returns native Active Record model instances rather than raw JSON or document hashes, because the engine retrieves matching IDs from the store and hydrates them against the primary database. Query Construction, Execution, and Serialization (10:00) Active Search uses a chainable query API modeled after Active Record relations, allowing developers to apply filters, sort orders, and negated criteria without executing the query immediately. Queries are evaluated explicitly by calling `.results`, which McBreen explains prevents unintended duplicate searches and gathers operational metadata—such as hit counts and execution runtimes—in a single round-trip. Models returned from queries are augmented with a `hit` method, which provides access to engine-level diagnostics, relevance scores, and highlighted snippets. By default, records are indexed by matching model attribute names to index fields, but custom serializers or inline procs can transform values, derive fields, or pull attributes from associated models. While indexing runs asynchronously by default through background tasks like `ActiveSearch::ReindexJob`, passing `async: false` enables inline execution. Developers can also bypass Active Record models altogether and interact directly with indices to index arbitrary Ruby hashes or execute raw searches. Polymorphic Indexing Across Models (18:22) To handle searches across distinct record types, such as articles and comments, Active Search supports polymorphic indices. Configuring a polymorphic index automatically provisions `record_type` and `record_id` fields, enabling the framework to track provenance and filter by model class inside the backend. Individual models can point to multiple indices simultaneously, fanning out separate indexing jobs on record saves to keep both dedicated and shared indices current. Querying a polymorphic index via `ActiveSearch.index(:records).search(...)` returns a mixed collection of model instances. Alternatively, calling `.search` on a specific model class targeting the polymorphic index transparently applies an implicit `record_type` filter, allowing callers to query a shared index while receiving only instances of that specific model. Database Adapters and Search Capabilities (22:45) When targeting relational databases rather than standalone search engines, Active Search generates dedicated document models (such as `ArticleDocument`) that subclass `ApplicationRecord`, along with corresponding database migrations. Because Active Search allows custom transformations that cannot be reversed by inspecting source tables, data must be denormalized into dedicated search tables. Backend implementations vary: SQLite utilizes companion full-text virtual tables (`_fts`), PostgreSQL generates `tsvector` columns with GIN or GiST indexes, and MySQL relies on native `FULLTEXT` indexes. To accommodate engine disparities, Active Search includes a `capabilities` matrix that signals whether features like keyword highlighting are natively supported (available in Elasticsearch, SQLite, and PostgreSQL, but absent in MySQL). For advanced usage, developers can access low-level engine primitives through escape hatches: `.native` injects engine-specific ranking parameters, while `to_native_query` returns raw JSON payload hashes on Elasticsearch or joinable `ActiveRecord::Relation` objects on SQLite. McBreen emphasizes that Active Search deliberately omits an automated cross-backend schema migration tool due to implementation complexity, requiring manual updates or index rebuilds when schemas change. He characterizes the framework as alpha-stage software hosted on GitHub, with ongoing refinements based on feedback from the Rails community.

Source quotes

[0:00] Donal McBreen: exhausting [0:00] Donal McBreen: AI free talk [4:47] Donal McBreen: has_search [6:40] Donal McBreen: results [10:00] Donal McBreen: has_search async: false [15:00] Donal McBreen: hit [20:00] Donal McBreen: basically pretend there's just articles in our index. [25:00] Donal McBreen: do crazy things like just join to that or just plug it into Active Record as you want. [25:00] Donal McBreen: to_native_query [25:00] Donal McBreen: native [25:00] Donal McBreen: capabilities [25:00] Donal McBreen: sort of alpha-ish software

Original transcript

[0:00] *music* Thank you. So, it can get quite exhausting. talking to AIs all day and talking about AIs when you're not talking to them So this is gonna be an AI free talk. So we can have a little interlude. Okay, so my name is Donald McBreen. I'm a principal programmer at 37signals. Yeah, today we're gonna talk about ActiveSearch, a new framework that is searched for Rails. OK. So this is a full text search framework with pluggable stores. So it's like a lot of Rails frameworks, like, say, Active Record or Active Job, where you have kind of a single API and then multiple adapters. that you can plug into that. And there's two separate types of store that we build this with-- databases, which do have full text features, and dedicated search engines. So, A little bit small there, but if we look at what the engines that are currently supported, we've got SQLite, Postgres, and MySQL are the databases. and then lots of different search engines on top of that as well. Okay, and where we use this at 37signals, we use Active Search with Elastic Search in Basecamp. And then we also have Fizzy, which is a a free Kanban app that we have that comes in two flavors. There's the kind of SaaS version that you can sign up for, but you can also run it yourself. And we use Active Search in that to switch between MySQL and SQLite. And Fizzy is open source, so if you want to go and look at it, you can see Active Search in use there. Thank you. OK, so I'm going to walk through how Active Search works, and we're going to use these models in order to do that. So we've got two tables. We've got an article table and a linked comments table. And we can see we've got a bunch of fields there. We've got two fields that we're going to want to search in our articles table, which are the title and the body. And then we've got some fields that we're going to want to filter our results on. So that's kind of typically how searches work. full text where you are looking for various words in it, and then the filters on top. And we're maybe going to focus on the articles in this talk. OK, so stepping through how it works. First of all, so ActiveSearch is a gem. You need to install the gem. But once you've done that, you can then install it into your application. You run this Rails ActiveSearch install task. And initially for the demo, we're going to use Elasticsearch as the adapter. The slight differences with the database adapters, we'll get to that a bit later, but we'll start with Elasticsearch, but it could be any of the dedicated search engines. And when you install it, it's going to create two new files, config search.yaml and config search.rb. And the YAML file looks like this. We're just going to set per environment what our adapter is. and then some adapter-specific config. If we wanted to set the host and the port of the Elasticsearch endpoint, we'd set that in here as well. but we're defaulting to local host and port 9200, which is the kind of defaults for Elasticsearch. for this demo. And then if we look in search.rb, it's basically empty. You've got a comment here showing what you might want to put in there, so a little sneak peek ahead of getting to that. But to start with, there's nothing in there. This is the file where we're going to define the search indexes that we're going to use. And to start with, we haven't got any. So first thing to do. is let's create an index. So we've got our article model. And we want to create an index for that. So we're going to call this rake generator and generate the files you need. okay so um you can see the different fields we've got title and body they don't have a type so they're the default, which is just full text that we're going to want to search. Then we've got three filters that we're going to be able to want to filter results on as well. You might only want to see articles published in the last month or something like that. And when we run this generator, it's going to append something into our config search.rb. And we'll also modify our model. Thank you. So here's... [5:00] what those two changes look like. So in config search.rb, we're defining our index. And you can see all those fields that we listed there are going to get put into this definition. And we've got text, and we've also got string. active search specific types. And a text field is one where we're going to want to search the text, whereas a string is kind of just a string that we might want to filter on. We'll see that later on. And then, If we look in our model, we see that adding this generator, so I added this hasSearch declaration in there. And that's going to do a bunch of stuff. It's going to add some methods into the model class methods and instance methods, and it's also going to add some callbacks that we're going to use to indexed our article. OK, so the next step is to run this task, which will create our index, and we pass index equals articles through to say that's the the particular index that we want to create here. And you can see here, just we got the output here showing the fields that it's created, and that we're using the Elasticsearch adapter here. So, When this runs, we will actually have our index. We can now have a look. So if we call-- the Elasticsearch HTTP API. We can just pass this through and get the resulting mapping back from Elasticsearch that shows we've created this index. Now, There isn't a migration system in here where if you want to add an extra field onto an index, it can do that for you the same way that ActiveRecord does. That would be probably quite complex to make work across all the different search engines. Something you could maybe do, but right now it doesn't do that. It's simply, if you've defined an index, you can create it. If you want to do things like adding fields, you'd have to do that manually. later on. And then we've got some topical news here in our articles, but we can create a bunch of articles and... and write those, and when we do that, that's actually going to trigger some callbacks that are going to put those articles into our index. After we've done that, we can then search. And searching looks like this. So by adding the hasSearch declaration into article, we've added this class method search. And we can go article.searchrails get the results back from that. And the results-- is a specific results object, but it's innumerable, so we can have a look at the first one. And what you can see here is that when you search, for articles, you get articles back. So it's not returning the representation that's in the search engine. It's getting the results from your search engine, but then grabbing the actual models from the database, kind of hydrating those into a result set. And if we just map through the titles, we'll see we found all the ones with Rails in there. So that's kind of the simple version of how it works. So we've defined this index, so let's have a closer look at what the index itself is. And there's kind of just three kind of steps here. We've got an index, and that talks to a store adapter. And the store adapter is-- there's different adapters for each of the engines we support. So in our case, it'll be the Elasticsearch. store adapter, and then that adapter talks to Elasticsearch. And when we want to put something into our index, I'm going to take the article, pass it to the index, I'll pass it on to the adapter and then into the specific search engine. that we're looking at. OK, and If we want to search, we'll pass a query in. That will go all the way through. The results will get passed all the way back up the chain and back out. So this is how that works with the index. and If you want to look here, we can actually go and have a look at the index itself. So on the active search module, you can call this index method, and pass in the name of your index. So for the article model, it's going to default to using the same name as the article, database table would use, so our index is called articles. And we can grab that and interrogate it for various things, the name, the underlying index name, you can have a look at the store itself, which is the adapter we've got to talk to our underlying index name. search engine. So this returns this active search [10:00] index model. We can talk to this index. directly. So we can add articles in, we can remove articles. We've got removed by ID if you've already destroyed an article, then you don't have it to remove it, so you can just remove it by the ID. And then you can run searches as well directly against the index. So when we looked before, we were kind of looking at how this integrated into our active record model, and that's kind of the kind of more standard way that you would do this, you would have your models and the integration would talk to the index for you. So you wouldn't actually need to look at the index and call methods on it, but it is something you can do directly. So you can use active search without active record. You can go and create an index and just add your own data. And it could just be some-- I don't know, just a hash that you had that you wanted to index things on. OK, so to look a bit more closely at the indexing part of this. Okay. So we saw earlier we created some articles. So what's happening when we create a model, or update it, or destroy it? is that behind the scenes, it's kicking off a job for us to run. When we added that hasSearch before, it inserted some callbacks, and these callbacks are going to create these jobs for us. Now, the full name, they're like activeSearch::reindexjob or removejob, just remove that from here for brevity. And then those jobs can run behind the scenes. Now, if you want to do your indexing inline, you can write hasSearch async false, and that will disable creating jobs. And instead, just when you update your model, it'll at the same time do the indexing. in process. Thank you. So here's kind of the steps that we go through when you create or save an article. So we've got some article, we save it. The article will get passed to our re-index job. When the job runs, it's going to pass the article again onto our index. And that's going to say, add this article into your index. Underneath, the index itself is going to convert our article into a document. So this is kind of the format that the different stores understand. And then the store will do whatever it needs to do to that document to add our... record into the underlying search engine. So in the case of Elasticsearch, that's a blob adjacent. If we were using SQLite, that would be a query, like an insert query or an up search or something like that. And those are kind of the steps you go through when saving an article. And just to look a bit more closely at this document, So the document We can see here we can call this method. So if we grab our index, we can call this documentFur method. and see what a document looks like for an article. This isn't something you would normally need to do. It's kind of just parts of the internals, but we can just look at it now to see what's in that document. And it's pretty simple, it's just got an ID and then a bunch of data. And you can see the data, it just matches up with the fields that we have added to our index. And by default, it's just going to assume that there's a method on the article that will pass these. So we'll just call article.title to grab the title and article.body and so on, and grab those values and just generate a hash. with those values, and this thing gets passed on to our store, which will pass it into the underlying engine. Now, you might not want to That might not work because maybe the method names are different on your article, or you need to grab some data from somewhere else, from some other... related model or something like that. So you can set a custom serializer to replace that default behavior. So you can pass it in like this. Here, we're using a separate method. You could just have an inline proc as well. And then what we do is we are going to and call this method instead when we want to generate the document. And so we see here, we've decided we want to truncate the body to avoid bloating or index and say we don't actually have a status field, we'll infer our status from the publish that date. OK, so that's indexing. We have these callbacks, they create jobs, the jobs run past the data to the index, and then it ends up in the underlying store. So the next part is searching. So how do we search this? Well, we're searching with this chainable API. [15:00] kind of an ActiveRecord relation style API, not exactly the same, but sort of similar idea. And we've got all these kind of methods on it. So we've got filter methods, you can, sort of force a sort order. By default, it would sort by relevance. But there's a bunch of methods in this API that we're not going to have time to go through here. but you can take a look at those later. and if we just have a look at what each of these methods is returning. You'll see the article.searchRails returns this query object. Then when we filter, it returns another query object and so on. So that's the chaining behavior where each level of the chain returns an object containing all the information from before with whatever you've just added to it. So we step through, query, query, query, query, query, and then we call the results method, which returns the results object that we saw briefly before. And since that's enumerable, we could convert that to an array, and we see we've got an array of articles. This is how searching works. Just one method that's worth looking at there is the filter method, because this is probably the most common thing that you'd want to do other than searching. So you can do various things. You can filter by fields, by multiple fields at once, multiple values, and you can do things with ranges, and open-ended and closed ranges, in order to do things like published, recently published articles. Thank you. Okay, so this results object, the results method looks a little bit weird, so why do we have that? Why don't we just do article.search.each and then just go through all our results? Well, the reason is that a search is not just like an array of your results. There's more to it, there's metadata about the search, there's a total number of... results you found, you know, was it a partial search, how much time it took. So there's a whole bunch of information that needs to be gathered together And if you don't have a method that actually triggers that and kind of terminates a query, you're in danger of-- kind of running the search over and over again. So you take your query and you do .each, and that runs the search and you get your results. And then you go article.search.total and it runs the search again. And we don't want that kind of performance hit. So you have to call the results method to actually trigger the search. and returns this results object, which has the metadata, but also we can enumerate through all the results. that we have on there. And then on the article itself, So when you added hasSearch to your article, it also added this hish instance method. So when you do search, you're also going to get metadata back about each result. And this is object is where that metadata is going to sit. So things like if there was a relevant score, the actual underlying fields that came back from the search engine, Another example might be if you'd asked your engine to highlight the results, to mark out the words that actually matched your search, that's where you go and find them, sort of in here. Okay, so back at the start, we had our article model, and we've been searching on articles, but we also had comments. So what we would do if we wanted to search both of the articles and the comments in a single index is we'd do some polymorphic searching. OK. So if we want to do this, what we're going to do is we're going to define an index, and mark it as polymorphic. So here's our index. We're calling it records, something suitably generic, because we're going to be shoving all different kinds of data in there. And for this index, we're just going to have a title and a body field that we can search. So there's no extra filters on here. But we want to be able to put our articles in here, and we want to put our comments in here. And you can see here that if we create this index, it's going to create the record type and record ID fields into our index, so we can use those to-- and linked. back to the original models and also to filter by them if needed. Thank you. Okay, and so next, if we go back and look at how this index, this polymorphic index is used, So we already had our has search in our article, but we've added a second has search here. So the article now is going into two different indexes. It's going into a dedicated article index, but it's also going to go into this records index. and The default name for the index is articles, because it's just generated from the model. So the first one doesn't need to specify the name of the index, but the second one [20:00] so that we can tie it up to that index we just... Create it. So when you create an article or update one, The callbacks that run are going to generate two jobs in this case: one job to put it in our articles index and one to put it in our records index. And then if we look underneath, we do a similar thing in our comments. In this case, we're passing in a custom serializer because our comments don't actually have their own title, so we generate one from the article itself and put that in here. Now, for this indexing to work in real life, you need to make sure that you have things in place so that when you updated the article, it triggers a new article. try to re-index the comments at the same time. which you can do, but I'm not going to cover that here. and then when we've got this polymorphic index we can search it and there's two different ways of searching it The first one is here, where we can grab the index directly. So we call the active search index method, which gives us the underlying index. and then we can call search on it. And you can see underneath that when we do that, we're going to get back a mixture of comments and articles, because that's what's in our index. Now, if instead we call the search method directly on the model, We're doing that here. Now, in this case, it's going to default to our articles index when we search, so we need to tell it, no, actually go and search the records index. You can see underneath here that we're just getting articles back. So it's attached a hidden filter that we can't see here, but when we search via this way in a polytymorphic index, it's going to be the equivalent of if on the first query we'd added .filter record type article. So we only get articles back, and when we search via the search method for the article, we can basically pretend there's just articles in our index. Thank you. Cool. OK, so initially, when we showed the first example, we were using Elasticsearch. So now, just look a little bit at how it works with the database adapters, because it's just a slight bit more to this. And OK, so we're going to run our installer again, but this time we're going to install with the SQLite adapter. So you can see here it's a pretty basic configuration in search.yml. All it's saying is we're going to use the SQLite adapter, and this is going to, under the hood, it's going to use the configuration in our database.yml file and use that to hook everything up. So yeah, by default it's just going to use whatever the default database was there. And this time, if we have a look what happens when we generate our article index, so this is the same thing we ran before to generate it with Elasticsearch. We're going to append our index definition into config search.rb, as before. We're also going to insert the has search into a model, but at the same time, we're creating another model and a migration for the model. So this is kind of the mechanism for indexing in a database. We're going to use database migrations to create our index. So this article document model, the underlying table, is effectively our database index. If you have a look at that model, there's nothing much to it. By default, the name of the model is just whatever the model we're indexing is with a document appended to it. and it just subclasses application record. And this means that you've got the place to do any... custom routing, replicas. If you want to put your documents in a separate database, you can just use the standard active record mechanisms for handling multiple databases. And if we look at the migrations, this is specifically for SQLite, which we're using. We're going to create the article documents table. And in there, we're adding these fields, the article ID, the status, and the views. and publish that. And we also, at the bottom here, we're creating virtual table, which is just SQLite's mechanism for full text search. So we're creating this article, documents underscore FTS, virtual table, and in there we're putting the title and body. And that's just SQLite's mechanism for and doing Full text searching. So we've created this separate document, so documents table. So it does mean we're going to be denormalizing the data, which is probably already in the database somewhere else, into this table. By default, we have to do that because we don't know where the data is coming from. There could be a custom serializer. It could be pulled from anywhere. There's no way. we can run things backwards and filter it from wherever it came from. So we need to denormalize it in there. So that's why we have this. separate table. [25:00] Just to look through the different adapters that are supported, they all have their own mechanisms for doing full text search. So we saw the virtual table with SQLite. Postgres adds the columns directly into the table, but then it also adds these vector columns an index on them MySQL is kind of the simplest version. It just adds some and creates a full text index on top of those. Okay, so sorry it's a bit small to read there, but that's kind of the full sort of set of stuff I wanted to look through here. There are some other features that you can go and look at, there isn't really time to go through here. Sort of on the query, there's all these various different methods. So we've got reject and filter any, they're kind of the negative and the R version of filtering. I think the most interesting ones here are two-native query and native, so they're a kind of escape hatch. So if you want to look at the underlying query, you can call to native query. If you did that on Elasticsearch, you'd get back the kind of the hash that you're going to generate the JSON from. If you do it in SQLite, you'll actually get back an active record relation. So you can do crazy things like just join to that or just plug it into active record as you want. And then the native field is a kind of an escape hatch. If there's something you want to do with your search engine that Active Search doesn't support, you can call the native method and use that to like alter the underlying thing. So maybe you've got some custom ranking mechanism that you want to plug in, and you can do that, view that. And then at the bottom, under the stores, we've got capabilities. So, Search engines, they're not as homogenous as databases. With ActiveRecord, most things are supported by all the databases. But with search engines, there's some features, some engines will support and other ones won't. So capabilities is kind of a way for the underlying store to define what things it supports. So a good example there is highlighting. So highlighting where you say, I want you to mark in the search text the words that you actually found. That works on all the engines except for MySQL. So MySQL you can't do highlighting with. If you want highlighting, you have to do it yourself on the results that you get back. And so those capabilities, they're kind of just a way to allow you to do that. Eh... Cool. Right, so just to go back, here's the kind of super simple version of how active search works. that this has search into model. and then you call search and get your results. And that's kind of how it works. uh okay just to finish up um there's a link to the gem at the moment tiny tiny link but if you uh you If you look really close, you might be able to see it. So it's github.com/basecamp/rails/active_search. And as I said, also, if you look at the Fizzy app, which is under Basecamp/Fizzy in GitHub, you can see this in action. And yeah. It's still kind of sort of alpha-ish software. It may-- you know, the API has not set it stone at all. So just be aware of that if you are planning to try it out. And yeah, be interested in any feedback you have. So thank you all very much. Cheers.