Full-Text Search with MySQL and Doctrine

Posted on 26. December 2023

MySQL has supported Full-Text Search (FTS) functionality for quite some time. The FTS capability was already introduced in MySQL version 3.23.23, which was released in September 2001, and yet I have never used it - until now.

1.What is a Full-Text Search

A Full-Text Search is an engine designed to search and retrieve information from a database based on the content of its documents. Unlike traditional databases that rely on exact matches of keywords or phrases, a FTS allows for more flexible and natural language searching.

In other words: You do not need to add complex SQL queries with a combination of AND, OR and LIKE.

2.MySQL Full-Text Search

In MySQL, a Full-Text Search is performed by using the function MATCH(x) AGAINST(y) where x is a comma separated list of the columns and y is the actual search term. The comma separated list of columns must also have an index with the type of FULLTEXT.

MySQL has different modes for the search: The Natural Language Mode and the Boolean Mode.

2.1. Natural Language Mode

In the Natural Language Mode, which is the default mode in MySQL, you can just type in a free text and get the result. There are no special operators and the result will be ordered automatically by the relevance of the rows.

mysql> SELECT -> name, -> city, -> MATCH(name, city) AGAINST ('Eagle Small Los') as score -> FROM -> location -> WHERE -> MATCH(name, city) AGAINST ('Eagle Small Los'); +-------------+-------------+--------------------+ | name | city | score | +-------------+-------------+--------------------+ | Small Eagle | Los Angeles | 14.405424118041992 | | Eagle | Los Banos | 8.705794334411621 | +-------------+-------------+--------------------+ 2 rows in set (0.00 sec)

As you notice, it also matches rows where the word small is not present in but the one with it gets a higher score which will automatically be ordered before.

2.2. Boolean Mode

The Boolean Mode is much more flexible. You can use different operators on the search string before each word:

  • + stands for AND so the word must be included
  • - stands for NOT so the word must NOT be included
  • > stands for increasing of relevance
  • < stands for decreasing of relevance
  • ( ) group
  • * stands for a wildcard (can only be appended)

In the following example, all words must be present:

mysql> SELECT -> name, -> city -> FROM -> location -> WHERE -> MATCH(name, city) AGAINST ('+Eagle +Small +Los' in BOOLEAN MODE); +-------------+-------------+ | name | city | +-------------+-------------+ | Small Eagle | Los Angeles | +-------------+-------------+ 1 row in set (0.00 sec)

3.How to implement a Full-Text Search with Doctrine

In the following tutorial, I will create a Full-Text search for the entity Location based on the name and city of a location.

3.1. Install and enable DoctrineExtensions

Since the MySQL functions used for a Full-Text Search are not integrated into the Doctrine Core, you need to install the package beberlei/doctrineextensions first which adds support for a lot of database functions for MySQL, PostgreSQL, Oracle and SQLite.

composer require beberlei/doctrineextensions

Now register the required function MATCH in the doctrine.yml configuration.

doctrine: orm: dql: string_functions: MATCH: DoctrineExtensions\Query\Mysql\MatchAgainst

3.2. Create Full-Text Index on the Entity

Add the following attribute to the entity Location to create the composite full-text index with the name FULLTEXT__CITY_NAME consisting of the two columns name and city.

... #[Index(fields: ["name","city"], name: "FULLTEXT__CITY_NAME", flags: ["fulltext"])] Class Location ...

Afterward, create the migration and run it. The migration should look like this:

public function up(Schema $schema): void { // this up() migration is auto-generated, please modify it to your needs $this->addSql('CREATE FULLTEXT INDEX FULLTEXT__CITY_NAME ON location (name, city)'); }

3.3. How to use in a query

Now I want to search for all locations which contain both strings Eagle and Los.

The SQL query looks like this...

SELECT name, city FROM location WHERE MATCH(name, city) AGAINST ('+Eagle* +Los*' IN BOOLEAN MODE);

... and will return following result:

+-------------+-------------+ | name | city | +-------------+-------------+ | Eagle | Los Banos | | Small Eagle | Los Angeles | +-------------+-------------+ 2 rows in set (0.00 sec)

Now the PHP code for the Doctrine Query Builder looks like this:

$qb->andWhere('MATCH (l.name, l.city) AGAINST (:search IN BOOLEAN MODE)'); $qb->setParameter('search', '+Eagle* +Los*'));

Since I use the + sign before each word, they must be present. The BOOLEAN MODE does not sort the results in order of their relevance. If you want order the result, you must add the MATCH as SELECT and an order by score DESC.

$qb->addSelect('MATCH (l.name, l.city) AGAINST (:search IN BOOLEAN MODE) as SCORE'); $qb->orderBy('SCORE', 'DESC');

If you want to add a higher search weight based on a column, you will have to add another index just for that column.

3.3. Fine-Tuning

Since the Full-Text Search functionality is very basic, only a few options for fine-tuning are available like adding stop words or configuring the minimum and maximum word length.

The tokenizer can't be adjusted and maybe does not work as expected, since it breaks words with a hyphen since it is not treated as word character.

Also, you cannot search across different tables by one index since you cannot add an index across different tables and a view cannot have an index.

See the full list for fine-tuning.

4. Conclusion

I have built Full-Text Searches with SOLR, OpenSearch and Meilisearch - and each one of them is much more powerful than the MySQL. I mean hopefully they are - because all of them are separate products specialized on full-text search.

But if you are currently using complex and slow queries with MySQL as a workaround, you really should check out the MySQL
Full-Text search since it can be a really low-hanging fruit to improve the performance of your search and also the developer experience without adding a new service as a dependency.

Made with ♥️ and Gatsby © 2024