Full-text search using Graph
How to implement full-text search in Optimizely Graph using GraphQL queries.
You can use Optimizely Graph to perform powerful full-text searches across all your content types.
Basic full-text search
Use this query to search across multiple content types using a search phrase. You can filter by content type and retrieve relevant fields with semantic ranking.
What this query does:
- Searches for content that matches your search phrase.
- Filters results by specified content types.
- Returns paginated results with semantic relevance scoring.
You can test the query in the GraphQL Explorer.
query FullTextSearch(
$searchPhrase: String!,
$contentTypes: [String!],
$skip: Int = 0,
$limit: Int = 10
) {
SitePageData(
where: {
_and: [
{ _fulltext: { match: $searchPhrase } }
{ _concreteType: { in: $contentTypes } }
]
}
skip: $skip
limit: $limit
orderBy: { _ranking: SEMANTIC }
) {
total
items {
_score
_concreteType
Name
RelativePath
... on ArticlePage {
TeaserText
StartPublish
PageImage {
Url
}
Category {
Name
}
}
... on ProductPage {
Category {
Name
}
}
... on StandardPage {
TeaserText
MetaDescription
}
}
}
}Advanced full-text search with boosting
Enhance your search by boosting specific fields like Name and TeaserText to influence ranking.
What this query does:
- Combines full-text search with field-specific boosting.
- Prioritizes matches in Name and TeaserText.
- Includes facets for content types and categories.
query AdvancedFullTextSearch(
$searchPhrase: String!,
$contentTypes: [String!],
$categories: [String!],
$skip: Int = 0,
$limit: Int = 10
) {
SitePageData(
where: {
_and: [
{
_or: [
{ _fulltext: { match: $searchPhrase } }
{ Name: { match: $searchPhrase, boost: 8 } }
{ TeaserText: { match: $searchPhrase, boost: 5 } }
]
}
]
}
skip: $skip
limit: $limit
orderBy: { _ranking: SEMANTIC }
) {
total
facets {
_concreteType(filters: $contentTypes) {
name
count
}
Category {
Name(filters: $categories) {
name
count
}
}
}
items {
_score
_concreteType
Name
RelativePath
... on ArticlePage {
TeaserText
MainBody
StartPublish
PageImage { Url }
Category { Name }
}
... on ProductPage {
Category { Name }
}
... on StandardPage {
TeaserText
MainBody
MetaDescription
}
}
}
}Search with highlighting and snippets
Highlight matched terms in your search results to improve readability and user experience.
What this query does:
- Highlights matched terms in the full text and specific fields.
- Uses custom HTML tags for styling highlights.
- Supports localization with the locale parameter.
query SearchWithHighlighting(
$searchPhrase: String!,
$locale: [Locales!] = [en],
$skip: Int = 0,
$limit: Int = 10
) {
SitePageData(
locale: $locale
where: { _and: [{ _fulltext: { match: $searchPhrase } }] }
skip: $skip
limit: $limit
orderBy: { _ranking: SEMANTIC }
) {
total
items {
_score
_concreteType
Name
RelativePath
_fulltext(
highlight: { enabled: true, startToken: "<mark>", endToken: "</mark>" }
)
... on ArticlePage {
TeaserText(highlight: { enabled: true, startToken: "<em>", endToken: "</em>" })
MainBody(highlight: { enabled: true, startToken: "<strong>", endToken: "</strong>" })
StartPublish
PageImage { Url }
}
... on ProductPage {
PageImage { Url }
}
}
}
}Implement full-text search in React
Integrate full-text search into your React app using Apollo Client and GraphQL.
What this code does:
- Sets up a debounced search input.
- Executes the full-text search query with boosting and filtering.
- Displays results with pagination, highlighting, and facet filtering.
import { gql, useQuery } from '@apollo/client';
import { useState, useEffect } from 'react';
const FULL_TEXT_SEARCH = gql`
query FullTextSearch($searchPhrase: String!, $contentTypes: [String!], $categories: [String!], $skip: Int = 0, $limit: Int = 10) {
SitePageData(
where: {
_and: [
{
_or: [
{ _fulltext: { match: $searchPhrase } }
{ Name: { match: $searchPhrase, boost: 8 } }
{ TeaserText: { match: $searchPhrase, boost: 5 } }
]
}
]
}
skip: $skip
limit: $limit
orderBy: { _ranking: SEMANTIC }
) {
total
facets {
_concreteType(filters: $contentTypes) {
name
count
}
Category {
Name(filters: $categories) {
name
count
}
}
}
items {
_score
_concreteType
Name
RelativePath
... on ArticlePage {
TeaserText
StartPublish
PageImage {
Url
}
Category {
Name
}
}
... on ProductPage {
PageImage {
Url
}
}
... on StandardPage {
TeaserText
MetaDescription
}
}
}
}
`;
interface SearchResult {
_score: number;
_concreteType: string;
Name: string;
RelativePath: string;
TeaserText?: string;
StartPublish?: string;
ProductDescription?: string;
Price?: number;
MetaDescription?: string;
PageImage?: {
Url: string;
};
ProductImage?: {
Url: string;
};
Category?: {
Name: string;
};
}
interface Facet {
name: string;
count: number;
}
export function FullTextSearch() {
const [searchPhrase, setSearchPhrase] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [selectedTypes, setSelectedTypes] = useState<string[]>([
'ArticlePage', 'ProductPage', 'StandardPage'
]);
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10;
// Debounce search input
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(searchPhrase);
setCurrentPage(1);
}, 300);
return () => clearTimeout(timer);
}, [searchPhrase]);
const { loading, error, data } = useQuery(FULL_TEXT_SEARCH, {
variables: {
searchPhrase: debouncedSearch,
contentTypes: selectedTypes,
skip: (currentPage - 1) * pageSize,
limit: pageSize
},
skip: !debouncedSearch.trim(),
errorPolicy: 'partial'
});
const results: SearchResult[] = data?.SitePageData?.items || [];
const typeFacets: Facet[] = data?.SitePageData?.facets?._concreteType || [];
const categoryFacets: Facet[] = data?.SitePageData?.facets?.Category || [];
const totalResults = data?.SitePageData?.total || 0;
const handleTypeChange = (type: string, checked: boolean) => {
setSelectedTypes(prev =>
checked
? [...prev, type]
: prev.filter(t => t !== type)
);
setCurrentPage(1);
};
const getTypeDisplayName = (typeName: string): string => {
const typeMap: Record<string, string> = {
'ArticlePage': 'Articles',
'ProductPage': 'Products',
'StandardPage': 'Pages'
};
return typeMap[typeName] || typeName;
};
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const highlightText = (text: string, searchTerm: string): string => {
if (!searchTerm.trim()) return text;
const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<mark>$1</mark>');
};
return (
<div className="full-text-search">
<div className="search-header">
<div className="search-input-container">
<input
type="text"
placeholder="Search content..."
value={searchPhrase}
onChange={(e) => setSearchPhrase(e.target.value)}
className="search-input"
/>
{loading && <div className="search-loading">Searching...</div>}
</div>
{debouncedSearch && (
<div className="search-info">
<p>
Found {totalResults} results for "{debouncedSearch}"
{totalResults > 0 && (
<span> in {(data?.Content?.items?.[0]?._score || 0) > 0 ? 'ranked' : 'filtered'} order</span>
)}
</p>
</div>
)}
</div>
{debouncedSearch && (
<div className="search-layout">
<div className="search-filters">
<div className="filter-section">
<h3>Content Types</h3>
<div className="type-filters">
{['ArticlePage', 'ProductPage', 'StandardPage'].map(type => (
<label key={type} className="filter-checkbox">
<input
type="checkbox"
checked={selectedTypes.includes(type)}
onChange={(e) => handleTypeChange(type, e.target.checked)}
/>
{getTypeDisplayName(type)}
</label>
))}
</div>
{typeFacets.length > 0 && (
<div className="type-facets">
<h4>Results by Type</h4>
{typeFacets.map(facet => (
<div key={facet.name} className="facet-item">
{getTypeDisplayName(facet.name)}: {facet.count}
</div>
))}
</div>
)}
</div>
{categoryFacets.length > 0 && (
<div className="filter-section">
<h3>Categories</h3>
<div className="category-facets">
{categoryFacets.map(facet => (
<div key={facet.name} className="facet-item">
{facet.name}: {facet.count}
</div>
))}
</div>
</div>
)}
</div>
<div className="search-results">
{error && (
<div className="error-state">
<h3>Search failed</h3>
<p>{error.message}</p>
</div>
)}
{results.length === 0 && !loading && debouncedSearch && (
<div className="no-results">
<h3>No results found</h3>
<p>Try different keywords or check your spelling.</p>
</div>
)}
{results.map((result, index) => (
<article key={`${result.RelativePath}-${index}`} className="search-result">
<div className="result-header">
<h3>
<a href={result.RelativePath}>
<span
dangerouslySetInnerHTML={{
__html: highlightText(result.Name, debouncedSearch)
}}
/>
</a>
</h3>
<div className="result-meta">
<span className="result-type">
{getTypeDisplayName(result._concreteType)}
</span>
<span className="relevance-score">
{(result._score * 100).toFixed(1)}% relevant
</span>
{result.StartPublish && (
<time dateTime={result.StartPublish}>
{formatDate(result.StartPublish)}
</time>
)}
</div>
</div>
<div className="result-content">
{(result.PageImage || result.ProductImage) && (
<div className="result-image">
<img
src={(result.PageImage || result.ProductImage)!.Url}
alt={result.Name}
/>
</div>
)}
<div className="result-text">
{(result.TeaserText || result.ProductDescription || result.MetaDescription) && (
<p
className="result-description"
dangerouslySetInnerHTML={{
__html: highlightText(
result.TeaserText || result.ProductDescription || result.MetaDescription || '',
debouncedSearch
)
}}
/>
)}
{result.Price && (
<div className="result-price">
${result.Price.toFixed(2)}
</div>
)}
{result.Category && (
<div className="result-category">
{result.Category.Name}
</div>
)}
</div>
</div>
</article>
))}
{/* Pagination */}
{totalResults > pageSize && (
<div className="search-pagination">
<button
disabled={currentPage === 1}
onClick={() => setCurrentPage(prev => prev - 1)}
>
Previous
</button>
<span className="pagination-info">
Page {currentPage} of {Math.ceil(totalResults / pageSize)}
</span>
<button
disabled={currentPage * pageSize >= totalResults}
onClick={() => setCurrentPage(prev => prev + 1)}
>
Next
</button>
</div>
)}
</div>
</div>
)}
</div>
);
}Updated 16 days ago
