Search text content with GROQ
A guide to searching Sanity text content using GROQ, from basic text matching to advanced hybrid search that combines keyword and semantic strategies.
Overview
Search in GROQ is built out of a small set of pieces that combine. Two operators do the actual work:
matchchecks whether a field's tokens contain the search terms. Used inside*[...]it filters; used insidescore(...)it produces a BM25 relevance score.| score(...)ranks results by one or more expressions and exposes_score.
The remaining pieces produce values that those operators consume:
text::query("...")parses a search string with phrase, exclusion, and wildcard syntax into a value you pass tomatch.text::semanticSimilarity("...")produces a similarity score for use insidescore(...).boost(expr, weight)reweights any expression insidescore(...).text::highlight()(experimental,vX) annotates which parts of a document matched.
A complete example
Here's a production-grade hybrid search query that combines many of the techniques covered in this guide. Don't worry if it looks dense, each piece is explained in the sections that follow.
// Search articles using both keyword and semantic relevance,
// with extra weight for editorial quality and recency.
*[_type == "article"] // 1. Filter: articles only
| score(
// 2. Keyword relevance on title, weighted 2x for precision.
boost([title] match text::query($searchQuery), 2),
// 3. Semantic similarity across all fields, weighted 1x.
// Returns conceptually related content even without exact keyword overlap.
boost(text::semanticSimilarity($searchQuery), 1),
// 4. Recency boost: articles from the last 30 days score 1.5x higher.
boost(_updatedAt > now() - 60*60*24*30, 1.5)
)
| order(_score desc) // 5. Most relevant first
[0...$pageSize] // 6. Paginate
{
_id,
title,
slug,
excerpt,
_updatedAt,
_score, // The computed relevance score
}Call it with parameters:
{
"$searchQuery": "summer fashion trends",
"$pageSize": 20
}The rest of this guide unpacks each technique used here.
Basic text matching with match <str>
The match operator performs tokenized text matching on specific fields. It breaks both the field value and the search term into tokens, then checks that all search tokens appear in the field. Reach for match when you need to filter documents, build a faceted search, or power autocomplete with a prefix wildcard.
Simple field match
*[_type == "article" && title match "summer dresses"]
This finds articles where the title field contains both "summer" AND "dresses" (in any order).
Multiple field match
*[_type == "article" && (title match "summer" || body match "summer")]
Each match clause still requires all of its tokens to appear within the same field. The || combines results across fields, so this query returns articles where "summer" appears in either title or body.
Array field match
*[_type == "article" && tags[] match "fashion"]
The match operator works with both string fields and arrays of strings.
Portable Text match
To search within Portable Text (rich text) fields, use pt::text():
*[_type == "article" && pt::text(body) match "summer collection"]
How match works
The match operator:
- Tokenizes both the search term and the field value.
- Requires all tokens to be present (AND logic).
- Is case-insensitive (uses standard text analysis).
- Supports wildcards within tokens (see Wildcards and prefix search).
Richer search syntax with text::query()
text::query() parses a search string into a structured query (supporting phrases, term exclusion, and wildcards) that you pass to match on the right-hand side. The match operator does the searching; text::query() is what lets you express a richer query than a plain string can.
Plain strings work for the simple cases:
*[_type == "article" && title match "summer dresses"]
Reach for text::query() when you need phrase matching, exclusion, or other search-syntax features:
*[_type == "article" && title match text::query('"summer collection" -discontinued')]text::query() also gives a higher score when your search terms appear adjacent in the matched field; a plain string scores each word independently (see "Scoring note" under Phrase matching).
Basic usage
*[_type == "article"] | score([title] match text::query("summer fashion trends"))When using text::query(), what goes before match can be more than a single attribute:
// Single attribute
*[_type == "article" && title match text::query("summer")]
// Array of attributes — searches both title and body
*[_type == "article" && [title, body] match text::query("summer")]
// Nested object — searches every string field under "details"
*[_type == "article" && details match text::query("summer")]
// Entire document — searches every string field
*[_type == "article" && @ match text::query("summer")]Phrase matching
Use double quotes to search for exact phrases:
*[_type == "article"] | score([title] match text::query('"summer dress collection"'))This finds documents containing the exact phrase "summer dress collection," not just documents with those words scattered throughout. Quoted phrases are also more precise than unquoted words, so prefer them when you want tight matches.
When your search string contains double quotes, wrap the GROQ argument in single quotes:
*[] | score([title] match text::query('"exact phrase" -excluded'))Scoring note: When you search for individual words (without quotes), text::query() automatically boosts documents where those words appear as a phrase. Searching for summer dress collection will rank documents containing the exact phrase higher while still returning documents that contain the words separately.
Plus-minus syntax
The text::query() function supports a rich query syntax for including and excluding terms.
Required context (default)
By default, at least one term must match:
// Documents matching "summer" OR "fashion" (or both)
*[] | score([title] match text::query("summer fashion"))Excluding terms
Prefix a term with - to exclude documents containing it:
// Documents about "summer" but NOT containing "winter"
*[] | score([title] match text::query("summer -winter"))A standalone - (not followed by a term) is treated as a regular character, not an exclusion.
Excluding phrases
Combine - with quotes to exclude exact phrases:
// Documents about "dresses" but not "winter collection"
*[] | score([title] match text::query('dresses -"winter collection"'))Complex queries
Combine multiple operators:
// Find "summer fashion" excluding "winter" and "sale"
*[] | score([title] match text::query('summer fashion -winter -sale'))
// Find exact phrase, exclude a term
*[] | score([title] match text::query('"summer collection" -discontinued'))
// Prefix search excluding a term
*[] | score([title] match text::query('fash* -"fast fashion"'))Syntax summary
| Syntax | Meaning | Example |
|---|---|---|
| word | Match this word | summer |
| "phrase" | Match exact phrase | "summer dress" |
| -word | Exclude this word | -winter |
| -"phrase" | Exclude exact phrase | -"out of stock" |
| word* | Prefix match | fash* matches "fashion", "fashionable" |
| "phrase*" | Phrase prefix match | "summer dr*" |
| *word* | Wildcard match | *dress* |
Wildcards and prefix search
Prefix search (trailing wildcard)
Add * at the end of a word to match any word starting with that prefix:
// Matches "fashion", "fashionable", "fashionista"
*[] | score([title] match text::query("fash*"))Prefix phrase search
Combine quotes with a trailing wildcard:
// Matches "summer dress", "summer dresses", "summer dressing"
*[] | score([title] match text::query('"summer dress*"'))General wildcards
Use * anywhere in a word for flexible matching:
// Matches "underdressed", "overdressed", etc.
*[] | score([title] match text::query("*dressed"))Performance warning: Wildcards at the beginning of a word (like *dressed) can be slow because they must scan many terms. Use prefix wildcards (dressed*) whenever possible.
Wildcards in match
The match operator also supports wildcards:
*[_type == "article" && title match "fash*"]
Semantic search with text::semanticSimilarity()
Semantic search finds documents by meaning rather than exact keyword matches. It uses vector embeddings generated by a language model to understand the intent behind your query. Use it for "find similar" features, content discovery, or any time the user phrases a query in natural language.
Basic usage
*[_type == "article"] | score(text::semanticSimilarity("comfortable clothes for hot weather"))This finds articles about summer dresses, lightweight fabrics, and breathable clothing, even if they don't contain the exact words "comfortable," "clothes," or "hot weather."
When to use semantic search
- Natural language queries: "What should I wear to a beach wedding?"
- Conceptual search: finding content about a topic without knowing the exact terminology.
- Cross-language concepts: finding related content across different phrasings.
- Exploratory search: when users don't know exactly what they're looking for.
Requirements
Semantic search requires embeddings to be enabled for your dataset. The system automatically:
- Extracts text from your documents.
- Splits long texts into chunks (with overlap for context).
- Generates vector embeddings for each chunk.
- Indexes embeddings for fast similarity search.
text::embedding() is deprecated
Use text::semanticSimilarity() instead. It has the same functionality with a clearer name.
Scoring and ranking with | score()
The | score() pipeline operator ranks documents by relevance. It takes one or more scoring expressions and assigns each document a _score value.
Basic scoring
*[_type == "article"] | score([title] match text::query("summer fashion")) | order(_score desc)How scoring works
- All documents from the filter pass through the score pipeline.
- Each scoring expression contributes to the document's
_score. - Results can be sorted by
_score(descending means most relevant first). - By default, results are sorted by
_score descwhen using text search.
Filter before scoring
Reduce the candidate set with filters before scoring. Scoring runs over every document the filter returns, so a narrower filter means faster, more relevant results.
// Good: filter first, then score
*[_type == "article" && category == "fashion"] | score([title] match text::query("summer"))
// Less efficient: scoring everything
*[_type == "article"] | score([title] match text::query("summer"), boost(category == "fashion", 5))Paginate with slicing
Always cap the number of results with a slice. This keeps response sizes predictable and pagination cursors meaningful.
*[_type == "article" && publishedAt > "2024-01-01"]
| score([title] match text::query("summer fashion"))
| order(_score desc)
[0...10]Multiple scoring signals
You can combine multiple expressions in score():
*[_type == "article"] | score(
text::query("summer fashion"),
boost(category == "featured", 2)
) | order(_score desc)Accessing the score
The _score field is available in projections:
*[_type == "article"] | score([title] match text::query("summer fashion")) {
title,
_score,
"relevance": _score
} | order(_score desc)Boosting with boost()
The boost() function lets you weight different scoring signals to fine-tune relevance.
Syntax
boost(expression, weight)
expression: any boolean expression or search function.weight: a numeric multiplier (higher means more important).
Boosting field matches
*[_type == "article"] | score(
text::query("summer fashion"),
boost(category == "editorial", 3),
boost(featured == true, 5)
) | order(_score desc)This query:
- Scores all articles by text relevance to "summer fashion."
- Triples the score for editorial articles.
- Quintuples the score for featured articles.
Boosting specific fields
*[_type == "article"] | score( boost(title match "summer fashion", 3), boost(body match "summer fashion", 1) ) | order(_score desc)
Title matches are weighted 3x more than body matches.
Boosting recency
*[_type == "article"] | score(
text::query("summer fashion"),
boost(publishedAt > "2024-06-01", 2)
) | order(_score desc)Recent articles get a 2x score boost.
Hybrid search (text and semantic)
Hybrid search combines BM25 text search with semantic search. Use it when you want relevance ranking informed by both exact-match and meaning-based signals, typically the right choice for production search.
Basic hybrid search
*[_type == "article"] | score(
[title] match text::query("summer fashion trends"),
text::semanticSimilarity("summer fashion trends")
) | order(_score desc)This combines:
- BM25 scoring: exact keyword matches, phrase matches.
- Semantic scoring: conceptual similarity, meaning-based matching.
Weighted hybrid search
Use boost() to control the balance between text and semantic signals:
*[_type == "article"] | score(
boost([title] match text::query("summer fashion"), 2),
boost(text::semanticSimilarity("summer fashion"), 1)
) | order(_score desc)This weights keyword matches 2x more than semantic similarity.
Full hybrid with business logic
*[_type == "article" && !(_id in path("drafts.**"))] | score(
boost([title] match text::query("summer fashion"), 2),
boost(text::semanticSimilarity("comfortable warm weather clothing"), 1),
boost(category == "editorial", 1.5),
boost(publishedAt > "2024-01-01", 1.2)
) | order(_score desc) [0...20] {
title,
slug,
excerpt,
_score,
"highlights": text::highlight()
}This query:
- Filters to published articles only.
- Scores by keyword relevance (2x weight).
- Scores by semantic similarity to a natural language concept (1x weight).
- Boosts editorial content (1.5x).
- Boosts recent content (1.2x).
- Returns the top 20 results with highlights.
Highlighting with text::highlight()
text::highlight() returns information about which parts of a document matched the search query, enabling you to show relevant snippets in search results.
Experimental
The text::highlight() function is experimental and only supported on API version vX at this time.
Basic usage
*[_type == "article"] | score([title] match text::query("summer fashion")) {
title,
_score,
"highlights": text::highlight(@, text::query("summer fashion"))
}What highlighting returns
text::highlight() returns a map of field paths to highlight information:
{
"highlights": {
"body": {
"fragments": [{
"end": 9,
"matches": [[0,9]],
"start": 0
}],
"matchLevel": "full",
"matchedWords": ["fashion"],
"score": 1
}
}
}You can then use this information to highlight or annotate the matched words in your apps.
How it works
Highlighting is performed after the search query returns results:
- The system walks through every string field in each result document.
- For each field, it checks if the search terms appear in the text.
- Fields with matches are included in the highlights map.
- The match level indicates how well the field matched (full, partial, or no match).
Requirements
- Highlighting only works with
text::query(). It uses the parsed query to identify matches. - When highlighting is enabled, the full document is fetched (even if you only project specific fields).