Skip to main content

SQL Server Full-Text Search: an overview

Although we have ElasticSearch running in our environment, our most important search functionality is still handled by SQL Server. And no, it is not by writing a statement like WHERE Name LIKE '%term%'. That would probably work on a small table, but we have a lot of data to dig through, so we need something smarter than substring matching.

SQL Server has a built-in answer for this: Full-Text Search.

In this post I walk through the building blocks and the main features. In the next post I'll show what happened when a customer ran into a surprising side effect.

Why do we need this?

A LIKE with a leading wildcard can't use an index seek. SQL Server has to scan every row and compare strings. On top of that, LIKE knows nothing about language: searching for 'ride' won't find 'riding', and there is no concept of relevance.

Full-text search works differently. It builds an inverted index: a list of words (tokens) and, for every word, the rows where it occurs. A language-specific word breaker decides what a word is. A stemmer knows that 'ride', 'rides', 'rode' and 'riding' belong together.

Is it installed?

On-premises, Full-Text Search is an optional feature during setup. You can check if it's available with this query:

SELECT SERVERPROPERTY('IsFullTextInstalled') AS IsInstalled;

Remark: Azure SQL Database supports full-text search as well, nothing to install there.

The building blocks

A few concepts you need to know:

  • Full-text catalog: a logical container for one or more full-text indexes.
  • Full-text index: one per table, built over one or more columns. The columns can be text (char, varchar, nvarchar, xml) or binary (varbinary(max)) with documents like Word or PDF files.
  • Unique key index: the full-text index needs a unique, single-column, non-nullable index on the table to map its entries back to rows. In most cases this is your primary key.
  • Word breaker and stemmer: language-specific components that split text into words and find the different forms of a word.
  • Stoplist: a list of words that are not indexed. More about this later.

Remark: If you like to go in-depth on the full text architecture, have a look at the official documentation: Full-Text Search - SQL Server | Microsoft Learn

Creating a full-text index

Let's use a simple product table as the example:

CREATE TABLE dbo.Products
(
    ProductId   int IDENTITY(1,1) NOT NULL,
    Name        nvarchar(200)     NOT NULL,
    Description nvarchar(max)     NULL,
    CONSTRAINT PK_Products PRIMARY KEY CLUSTERED (ProductId)
);

First create the catalog, then the index:

CREATE FULLTEXT CATALOG ProductCatalog AS DEFAULT;

CREATE FULLTEXT INDEX ON dbo.Products
(
    Name        LANGUAGE 1033,
    Description LANGUAGE 1033
)
KEY INDEX PK_Products ON ProductCatalog
WITH CHANGE_TRACKING = AUTO;

The LANGUAGE 1033 is the locale identifier (LCID) for English. It decides which word breaker, stemmer, thesaurus and stopwords are used for that column. For Dutch you would use 1043.

The CHANGE_TRACKING option controls how the index is kept up to date:

  • AUTO: changes are picked up automatically in the background.
  • MANUAL: SQL Server tracks the changes but you decide when to apply them.
  • OFF: nothing is tracked, you have to repopulate the index yourself.

Remark: the index is updated asynchronously. A row you just inserted can take a moment before it shows up in the search results.

Querying: CONTAINS

CONTAINS is the predicate you will use most. It supports a rich search syntax. A simple word search:

SELECT ProductId, Name
FROM dbo.Products
WHERE CONTAINS(Name, 'bike');

Prefix search

SELECT ProductId, Name
FROM dbo.Products
WHERE CONTAINS(Name, '"mount*"');

Remark: the double quotes are required. Without them the asterisk is treated as a normal character and you get no prefix matching.

Phrase search

SELECT ProductId, Name
FROM dbo.Products
WHERE CONTAINS(Description, '"carbon fiber frame"');

Boolean operators

Combine terms with AND, OR and AND NOT:

SELECT ProductId, Name
FROM dbo.Products
WHERE CONTAINS(Description, 'lightweight AND (aluminum OR carbon) AND NOT steel');

Proximity search

Find words that are close to each other with NEAR. Here at most 5 words apart:

SELECT ProductId, Name
FROM dbo.Products
WHERE CONTAINS(Description, 'NEAR((lightweight, frame), 5)');

Inflectional forms

This is where full-text search starts to beat LIKE. FORMSOF(INFLECTIONAL, ...) matches the other forms of a word:

SELECT ProductId, Name
FROM dbo.Products
WHERE CONTAINS(Description, 'FORMSOF(INFLECTIONAL, ride)');

This finds 'ride', 'rides', 'riding', 'rode' and 'ridden'.

Thesaurus

With FORMSOF(THESAURUS, ...) you can match synonyms:

SELECT ProductId, Name
FROM dbo.Products
WHERE CONTAINS(Description, 'FORMSOF(THESAURUS, bike)');

Remark: out of the box the thesaurus files are empty. You have to fill the XML file for your language (for English tsenu.xml) and load it with EXEC sys.sp_fulltext_load_thesaurus_file 1033;.

Querying: FREETEXT

FREETEXT is the less strict alternative. You give it a piece of natural language and SQL Server breaks it into words, looks at the different word forms and synonyms, and matches on meaning instead of on exact terms:

SELECT ProductId, Name
FROM dbo.Products
WHERE FREETEXT(Description, 'comfortable bike for long rides');

Simple to use, but you have less control over what is matched.

Ranking the results

CONTAINS and FREETEXT only tell you if a row matches. If you want to know how well it matches, use CONTAINSTABLE or FREETEXTTABLE. Both return a table with a KEY (the key of your row) and a RANK column:

SELECT p.ProductId, p.Name, ft.[RANK]
FROM CONTAINSTABLE(dbo.Products, (Name, Description),
     'ISABOUT(comfortable WEIGHT(.8), lightweight WEIGHT(.2))') AS ft
JOIN dbo.Products AS p ON p.ProductId = ft.[KEY]
ORDER BY ft.[RANK] DESC;

ISABOUT together with WEIGHT lets you tell SQL Server which terms matter most.

Language matters

The language you specify on the index is more important than it looks. It determines:

  • how text is split into words
  • which word forms belong together
  • which thesaurus is used
  • which words are considered stop words

You can also specify a language at query time:

SELECT ProductId, Name
FROM dbo.Products
WHERE CONTAINS(Description, 'fiets', LANGUAGE 1043);

Stop words

Full-text search doesn't index every word. Words that are considered too common to be useful, like 'the', 'and' or 'is', are stop words. They are listed in a stoplist and are skipped during indexing and querying.

The stoplist that is used by default is the system stoplist. You can look at what is in there for English:

SELECT stopword
FROM sys.fulltext_system_stopwords
WHERE language_id = 1033;

You have three options when creating or altering a full-text index:

  • STOPLIST = SYSTEM: use the system stoplist (the default)
  • STOPLIST = <name>: use your own custom stoplist
  • STOPLIST = OFF: don't use a stoplist, so every word is indexed

A custom stoplist can be created from the system one, after which you can add or remove words:

CREATE FULLTEXT STOPLIST ProductStoplist FROM SYSTEM STOPLIST;

ALTER FULLTEXT STOPLIST ProductStoplist DROP 'both' LANGUAGE 1033;

ALTER FULLTEXT INDEX ON dbo.Products SET STOPLIST ProductStoplist;

Stop words sound like a harmless optimization. In the next post I'll explain why they are not always harmless.

Troubleshooting helpers

When search results are not what you expect, these are useful:

  • sys.fulltext_indexes and sys.fulltext_catalogs: which indexes exist and how they are configured
  • sys.dm_fts_parser: shows how the word breaker and stoplist process a query string
  • sys.dm_fts_index_keywords: shows which words are actually in the index
-- How is this query string split up?
SELECT display_term, special_term
FROM sys.dm_fts_parser('"lightweight carbon frame"', 1033, 0, 0);

-- Which words are in the index?
SELECT TOP 20 display_term, document_count
FROM sys.dm_fts_index_keywords(DB_ID(), OBJECT_ID('dbo.Products'))
ORDER BY document_count DESC;

Wrapping up

Full-text search gives you fast, language-aware searching without adding another component to your architecture. That's it! Just keep an eye on those defaults, they can bite you.

More information

Popular posts from this blog

Podman– Command execution failed with exit code 125

After updating WSL on one of the developer machines, Podman failed to work. When we took a look through Podman Desktop, we noticed that Podman had stopped running and returned the following error message: Error: Command execution failed with exit code 125 Here are the steps we tried to fix the issue: We started by running podman info to get some extra details on what could be wrong: >podman info OS: windows/amd64 provider: wsl version: 5.3.1 Cannot connect to Podman. Please verify your connection to the Linux system using `podman system connection list`, or try `podman machine init` and `podman machine start` to manage a new Linux VM Error: unable to connect to Podman socket: failed to connect: dial tcp 127.0.0.1:2655: connectex: No connection could be made because the target machine actively refused it. That makes sense as the podman VM was not running. Let’s check the VM: >podman machine list NAME         ...

Cache stampede: when our cache turned against us

While investigating some performance issues, we ran into an ASP.NET Core API that cached a fairly expensive aggregation query for 60 seconds. Under normal load, that was fine: one request rebuilds the cache, everyone else reads from it. Under peak load, dozens of requests would arrive in that same expiry window, all see a cache miss, and all fire the same expensive query in parallel. The database didn't like that. That was the moment when our caching layer stopped helping and started hurting. A burst of requests comes in at the same time, all miss the cache, and all go hammer the database or the downstream API at once. That's a cache stampede . The cache was supposed to protect our backend, and for a few hundred milliseconds it did the opposite. Why this happens IMemoryCache.GetOrCreate (and its async sibling) looks like it protects you, but it doesn't add any locking on its own. Look at the naive version: public async Task<Report> GetReportAsync(string key) ...

VS Code Planning mode

After the introduction of Plan mode in Visual Studio , it now also found its way into VS Code. Planning mode, or as I like to call it 'Hannibal mode', extends GitHub Copilot's Agent Mode capabilities to handle larger, multi-step coding tasks with a structured approach. Instead of jumping straight into code generation, Planning mode creates a detailed execution plan. If you want more details, have a look at my previous post . Putting plan mode into action VS Code takes a different approach compared to Visual Studio when using plan mode. Instead of a configuration setting that you can activate but have limited control over, planning is available as a separate chat mode/agent: I like this approach better than how Visual Studio does it as you have explicit control when plan mode is activated. Instead of immediately diving into execution, the plan agent creates a plan and asks some follow up questions: You can further edit the plan by clicking on ‘Open in Editor’: ...