> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pylonsync.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Search

> Native faceted full-text search — declarative, in-process, no separate index server.

Pylon ships a built-in **faceted full-text search** layer. Add a `search:` block to an entity, get BM25 ranking + live facet counts + sort across millions of rows — without running Meilisearch, ElasticSearch, or any external index. The index is maintained inside the same SQLite/Postgres database, in the same transaction as your writes.

## Declaring a searchable entity

```ts theme={null}
import { entity, field } from "@pylonsync/sdk";

const Product = entity(
  "Product",
  {
    name: field.string(),
    description: field.richtext(),
    brand: field.string(),
    category: field.string(),
    color: field.string(),
    price: field.float(),
    rating: field.float(),
    stock: field.int(),
    createdAt: field.datetime(),
  },
  {
    search: {
      // BM25 match across these columns. Order is the weighting hint.
      text: ["name", "description"],
      // Facet counts maintained for these columns.
      facets: ["brand", "category", "color"],
      // Allowed sort keys. Anything outside this list is rejected.
      sortable: ["price", "rating", "createdAt"],
    },
  },
);
```

That's the whole config. On schema push Pylon creates an FTS5 shadow table and a roaring-bitmap facet index; every insert/update/delete maintains both **inside the same transaction** as the row write. There is never an indexing lag.

## Searching from the client

`db.useSearch` is the React hook. Returns ranked hits, per-facet counts, total, and timing.

```tsx theme={null}
import { db } from "@pylonsync/react";

function Catalog() {
  const [query, setQuery] = useState("");
  const [filters, setFilters] = useState<Record<string, string>>({});
  const [page, setPage] = useState(0);

  const { hits, facetCounts, total, tookMs, loading } =
    db.useSearch<Product>("Product", {
      query,
      filters,
      facets: ["brand", "category", "color"],
      sort: ["price", "asc"],
      page,
      pageSize: 24,
    });

  // facetCounts is { brand: { Atlas: 1000, Orbit: 800, … }, … }
  // Counts reflect the live result set after applying every filter
  // *except* the one being counted — Algolia / Meilisearch semantics.
}
```

The hook re-runs automatically when matching rows in the entity change, so facet counts and result lists stay in lockstep with writes. No invalidation, no refetch button.

## Searching from a server function

```ts theme={null}
import { mutation } from "@pylonsync/functions";

export default mutation({
  async handler(ctx, args) {
    const result = await ctx.db.search("Product", {
      query: args.q,
      filters: { brand: "Atlas" },
      facets: ["category", "color"],
      page: 0,
      pageSize: 50,
    });
    return result;
  },
});
```

The same query shape works server-side via `ctx.db.search`. Useful when you need to search inside a transaction or pre-aggregate before returning.

## How it works

* **Text matching**: SQLite FTS5 / Postgres `tsvector`. BM25 ranking by default, configurable per field.
* **Facets**: roaring bitmaps stored in a `_facet_bitmap` table, one bitmap per `(entity, column, value)`. Intersection across active filters is bit-AND, which is faster than `WHERE` clauses by 10–100× at scale.
* **Sort + paginate**: when the planner needs to sort across the entire match set, it materializes hit ids into a temp table, joins back to the row table, applies `ORDER BY` + `LIMIT`/`OFFSET`. This is the only way to paginate consistently across a sorted projection.
* **Aggregation safety**: faceted search refuses to run on entities whose read policy depends on per-row data. Otherwise, facet counts would leak the existence of rows the caller can't read. To opt in, make your read policy row-independent (e.g. `auth.userId != null`) or scope reads with a server function.

## API

`POST /api/search/:entity` accepts:

```json theme={null}
{
  "query": "red sneakers",
  "filters": { "category": "shoes", "brand": "Atlas" },
  "facets": ["brand", "color", "size"],
  "sort": ["price", "asc"],
  "page": 0,
  "pageSize": 24
}
```

Returns:

```json theme={null}
{
  "hits": [/* matching rows, fully populated */],
  "total": 142,
  "facetCounts": { "brand": { "Atlas": 12, "Orbit": 8 }, "color": { "red": 142 } },
  "tookMs": 3
}
```

## Performance

Native search trades the operational cost of a separate index server for slightly higher per-query latency on enormous datasets (>10M rows). For many B2B SaaS workloads, it can be faster end-to-end because there's no network hop, no async indexing lag, and no second system to monitor.

The `examples/store` example runs a 10,000-product catalog with 3-facet search at sub-5ms p95 on a \$5 VPS.

## Next

<CardGroup cols={2}>
  <Card title="Faceted search example" icon="store" href="/examples/store">
    Walk through the store with code highlights.
  </Card>

  <Card title="Live queries" icon="bolt" href="/concepts/live-queries">
    How search results stay up to date.
  </Card>
</CardGroup>
