Firestore does not do text search. It does equality, ranges, array-contains, and in. Every "search box" built directly on Firestore is one of three things: a prefix range query, a tokenized array field, or a full collection read filtered in the client. The third one is a bill. The first two work until they don't.
This is the decision procedure we walk clients through, with the costs attached.
What Firestore can actually do
One useful query, and it is narrower than people remember:
// case-sensitive prefix match on a single field
const q = query(
collection(db, 'customers'),
where('nameLower', '>=', term),
where('nameLower', '<=', term + '\uf8ff'),
orderBy('nameLower'),
limit(20)
);
That \uf8ff is a high Unicode code point acting as an open upper bound. It gives you sta -> Stark Industries. It does not give you industries -> Stark Industries, it does not tolerate typos, it does not rank, and it is case- and accent-sensitive unless you denormalize a folded field (nameLower, nameFolded) on write.
Budget for that write. A Cloud Function that normalizes a field on every document write is an extra write per document — plus the index entries. If the collection takes 500,000 writes a month, you added 500,000 writes ($1.08 at $0.18/100k in us-central) plus storage. Cheap. Do it in the client or in the same transaction as the original write rather than in a trigger, and it is free.
The tokenized-array trick, and why it bites
The next step teams reach for is a keyword array:
// 'Stark Industries Q3 invoice' ->
tokens: ['stark', 'industries', 'q3', 'invoice']
query(collection(db, 'docs'), where('tokens', 'array-contains', 'invoice'))
This works, and it is the point where people start generating n-grams so that partial words match. That is where the index cost lands on the invoice.
Firestore writes one index entry per array element, per index covering that field. A 40-word document title tokenized into words is 40 entries. The same title exploded into 3-to-8 character n-grams is several hundred. Two composite indexes including tokens doubles it. Index entries are billed as storage and, more importantly, they count against the 40,000 index entries per document limit and the 1 MiB document size cap. We have seen teams hit the index-entry ceiling on a document whose body was 6 KB of text — writes start failing with INVALID_ARGUMENT, in production, on the documents with the most content.
If you go this route, cap it: tokenize words only, not n-grams; store at most ~100 tokens; add "tokens" to firestore.indexes.json as a single-field exemption everywhere you don't query it, so you aren't paying for index entries in indexes you never use.
{
"fieldOverrides": [
{
"collectionGroup": "docs",
"fieldPath": "tokens",
"indexes": [
{ "arrayConfig": "CONTAINS", "queryScope": "COLLECTION" }
]
}
]
}
Single-field exemptions are the most under-used cost lever in Firestore. Every field you never filter on is index entries you are buying for nothing.
When to move search out
Move search to a real search engine the moment you need any of: relevance ranking, typo tolerance, multi-field matching, faceting, or matching in the middle of a word. Firestore will never do those, and each workaround you stack on makes the write path more expensive and the data model harder to change.
The standard shape:
- Firestore stays the system of record.
- A Gen 2 Firestore trigger pushes changed documents into the search index.
- The client queries the search service, gets back document IDs, and reads only what it renders from Firestore — or renders straight from the search payload.
import { onDocumentWritten } from 'firebase-functions/v2/firestore';
export const syncSearchIndex = onDocumentWritten(
{ document: 'docs/{docId}', region: 'us-central1' },
async (event) => {
const { docId } = event.params;
const after = event.data?.after?.data();
if (!after) return searchClient.delete(docId);
return searchClient.upsert({
id: docId,
orgId: after.orgId,
title: after.title,
body: after.body,
updatedAt: after.updatedAt?.toMillis() ?? Date.now(),
});
}
);
Two things that go wrong here, both worth fixing before launch:
Authorization leaves Firestore with the data. Your security rules do not apply to the search index. If you let the browser query the search service directly with a public key, scoped filters are the only thing keeping tenant A out of tenant B's documents — and a client-supplied filter is not a permission. Either mint short-lived scoped search keys server-side (Algolia's secured API keys, Typesense's scoped keys), embedding the orgId filter in the key itself, or proxy search through a callable function that derives orgId from request.auth.token. Never both trust the client and skip the proxy.
Triggers are at-least-once and out of order. Covered in detail in our post on trigger idempotency — for search specifically, always write the document version or updatedAt alongside the record and drop stale upserts, or a retried old event will resurrect deleted content in search results.
Also: run a nightly reconciliation job that diffs Firestore against the index. Triggers miss events during outages and during any backfill done with the Admin SDK's bulk writer. A drifted search index is the bug users report as "search is broken sometimes," and without reconciliation you cannot even confirm it.
Where vector search fits
Firestore now supports K-nearest-neighbour vector search natively: store an embedding in a document, build a vector index, query with findNearest.
gcloud firestore indexes composite create \
--collection-group=docs \
--query-scope=COLLECTION \
--field-config=field-path=orgId,order=ASCENDING \
--field-config=field-path=embedding,vector-config='{"dimension":768,"flat":{}}'
const { getFirestore, FieldValue } = require('firebase-admin/firestore');
const results = await getFirestore()
.collection('docs')
.where('orgId', '==', orgId) // pre-filter, and it must be in the index
.findNearest({
vectorField: 'embedding',
queryVector: FieldValue.vector(queryEmbedding),
limit: 10,
distanceMeasure: 'COSINE',
})
.get();
Be clear about what this is and is not.
It is a good fit for semantic retrieval: "find documents about late payments" matching a doc that says "overdue balance," and for the retrieval half of a RAG feature where you are already calling an embedding model. It keeps one database instead of two, and the pre-filter runs in the same index, so tenant isolation is a normal Firestore where clause rather than a key-minting exercise.
It is not a replacement for keyword search. Users typing an invoice number or a customer name want exact matching, and an embedding model will happily return ten semantically adjacent documents and none of the right one. It also costs on both sides: an embedding call per document write (and per query), plus the vector index. Check the current per-dimension index limits before you pick a model — a 3072-dimension embedding may not be indexable where a 768-dimension one is, and truncating dimensions after the fact means re-embedding the corpus.
In practice the teams that are happy with it run both: keyword search in a search service for known-item lookup, vector search in Firestore for semantic retrieval and RAG context. The ones that are unhappy tried to serve a search box with embeddings alone.
The short version
- Prefix matching on one field, small collection, no ranking: stay in Firestore. Denormalize a folded field, use the
\uf8ffrange, move on. - Keyword tokens: acceptable for tags and short titles. Cap the token count, never generate n-grams, add single-field exemptions.
- Ranking, typos, facets, mid-word matching: external search index, synced by a Gen 2 trigger, authorized with scoped keys or a callable proxy, reconciled nightly.
- Semantic retrieval or RAG: Firestore vector search, pre-filtered by tenant, alongside — not instead of — keyword search.
The expensive mistake is none of these. It is fetching the collection and filtering in the client, which prices your search feature at one document read per row per keystroke. If that is what your app does today, that single line on the invoice is usually the cheapest thing we fix in an audit.