Disclaimer: This website requires Please enable JavaScript in your browser settings for the best experience.

Dev guideRecipesAPI ReferenceChangelog
Dev guideRecipesUser GuidesNuGetDev CommunityOptimizely AcademySubmit a ticketLog In
Dev guide

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>
  );
}