Изначально я опубликовал эту статью в своем блоге. Она является частью сотрудничества с Alachisoft, создателями NCache.
Наверняка вы использовали оператор SQL LIKE для поиска ключевого слова в текстовом поле. Для больших объемов текста это было бы медленно. Давайте узнаем, как реализовать полнотекстовый поиск с помощью Lucene и NCache.
Что такое полнотекстовый поиск?
Полнотекстовый поиск — это метод поиска не только точных совпадений ключевого слова в тексте, но и шаблонов текста, синонимов или близких слов в больших объемах текста.
Для поддержки больших объемов текста поиск делится на две фазы: индексирование и поиск. На этапе индексирования анализатор обрабатывает текст для создания индексов, основанных на правилах разговорного языка, например английского, для удаления стоп-слов и записи синонимов и склонений слов. Затем на этапе поиска вместо исходного текста используются только индексы.
Полнотекстовый поиск с помощью Lucene и NCache
1. Почему Lucene и NCache?
С официальной страницы: «Apache Lucene.NET — это высокопроизводительная библиотека поиска для .NET». Это C#-порт Apache Lucene на Java, «чрезвычайно мощной» и быстрой поисковой библиотеки, оптимизированной для полнотекстового поиска.
NCache предоставляет распределенные возможности Lucene, реализуя API Lucene поверх своего In-Memory Distributed cache. Таким образом, NCache делает Lucene линейно масштабируемым решением полнотекстового поиска для .NET. Более подробную информацию о возможностях распределенного Lucene можно найти на странице NCache Distributed Lucene.
2. Создание кэша Lucene в NCache
Мы уже установили и использовали NCache в качестве провайдера IDistributedCache. На этот раз давайте воспользуемся NCache версии 5.3 для поиска фильмов по названию или имени режиссера с помощью полнотекстового поиска Lucene.
Lucene хранит данные в неизменяемых «сегментах», которые состоят из нескольких файлов. Мы можем хранить эти сегменты в локальной файловой системе или в оперативной памяти. Но поскольку мы используем Lucene с NCache, мы храним эти сегменты в NCache.
Перед индексированием и поиском чего-либо, сначала нам нужно создать распределенный кэш Lucene. Давайте перейдем по адресу http://localhost:8251
, чтобы открыть NCache Web Manager и добавить новый распределенный кэш.
Выберем «Distributed Lucene» в Типе хранилища и дадим ему имя. Затем добавим нашу собственную машину и второй узел. Для операций записи нам нужно как минимум два узла. Для остальных параметров мы можем придерживаться значений по умолчанию.
По умолчанию NCache хранит индексы Lucene в C:ProgramDatancachelucene-index
.
Более подробную информацию об этих опциях установки можно найти в официальных документах NCache.
3. Проиндексируйте несколько фильмов
После создания распределенного кэша Lucene, давайте заполним наши индексы Lucene некоторыми фильмами из приложения Console. Позже мы будем искать их из другого приложения Console.
Сначала создадим консольное приложение для загрузки некоторых фильмов в кэш Lucene. Также установим пакет Lucene.Net.NCache
NuGet.
В файле Program.cs
мы можем загрузить все фильмы, которые хотим проиндексировать, из базы данных или другого хранилища. Например, давайте воспользуемся списком фильмов из IMDb. Что-то вроде этого,
using SearchMovies.Shared;
using SearchMovies.Shared.Entities;
using SearchMovies.Shared.Services;
var searchService = new SearchService(Config.CacheName);
searchService.LoadMovies(SomeMoviesFromImdb());
Console.WriteLine("Press any key to continue...");
Console.ReadKey();
// This list of movies was taken from IMDb dump
// See: https://www.imdb.com/interfaces/
static IEnumerable<Movie> SomeMoviesFromImdb()
{
return new List<Movie>
{
new Movie("Caged Fury", 1983, 3.8f, 89, new Director("Maurizio Angeloni", 1959), new []{ Genre.Crime,Genre.Drama }),
new Movie("Bad Posture", 2011, 6.5f, 93, new Director("Jack Smith", 1932), new []{ Genre.Drama,Genre.Romance }),
new Movie("My Flying Wife", 1991, 5.5f, 91, new Director("Franz Bi", 1899), new []{ Genre.Action,Genre.Comedy,Genre.Fantasy }),
new Movie("Modern Love", 1990, 5.2f, 105, new Director("Sophie Carlhian", 1962), new []{ Genre.Comedy }),
new Movie("Sins", 2012, 2.3f, 84, new Director("Pierre Huyghe", 1962), new []{ Genre.Action, Genre.Thriller })
// Some other movies here...
};
}
Обратите внимание, что мы использовали SearchService
для обработки создания индекса в методе LoadMovies()
. Давайте посмотрим на него.
using Lucene.Net.Analysis.Standard;
using Lucene.Net.Index;
using Lucene.Net.Store;
using Lucene.Net.Util;
using SearchMovies.Shared.Entities;
using SearchMovies.Shared.Extensions;
namespace SearchMovies.Shared.Services;
public class SearchService
{
private const string IndexName = "movies";
private const LuceneVersion luceneVersion = LuceneVersion.LUCENE_48;
private readonly string _cacheName;
public SearchService(string cacheName)
{
_cacheName = cacheName;
}
public void LoadMovies(IEnumerable<Movie> movies)
{
using var indexDirectory = NCacheDirectory.Open(_cacheName, IndexName);
// 1. Opening directory ^^^
var standardAnalyzer = new StandardAnalyzer(luceneVersion);
var indexConfig = new IndexWriterConfig(luceneVersion, standardAnalyzer)
{
OpenMode = OpenMode.CREATE
};
using var writer = new IndexWriter(indexDirectory, indexConfig);
// 2. Creating a writer ^^^
foreach (var movie in movies)
{
var doc = movie.MapToLuceneDocument();
writer.AddDocument(doc);
// 3. Adding a document
}
writer.Commit();
// 4. Writing documents
}
}
Сначала немного предыстории: Lucene использует документы в качестве единицы поиска и индекса. Документы могут иметь множество полей, и нам не нужна схема для их хранения.
Мы можем искать документы по любому полю. Lucene вернет только те документы, которые содержат это поле и соответствующие данные. Для получения более подробной информации о некоторых внутренних компонентах Lucene ознакомьтесь с руководством по быстрому запуску Lucene.
Обратите внимание, что мы начали нашу LoadMovies
с открытия каталога NCache. Нам понадобилось то же имя кэша, которое мы настроили ранее, и имя индекса. Затем мы создали IndexWriter
с нашим каталогом и некоторыми конфигурациями, такими как версия Lucene, анализатор и режим открытия.
Затем мы просмотрели наши фильмы и создали документ Lucene для каждого из них с помощью метода расширения MapToLuceneDocument()
. Вот он,
using Lucene.Net.Documents;
using SearchMovies.Shared.Entities;
namespace SearchMovies.Shared.Extensions;
public static class MoviesExtensions
{
public static Document MapToLuceneDocument(this Movie self)
{
return new Document
{
new TextField("name", self.Name, Field.Store.YES),
new TextField("directorName", self.Director.Name, Field.Store.YES)
};
}
}
Для создания документов Lucene мы использовали два поля типа TextField
: название фильма и имя режиссера. Для каждого поля нам нужны имя и значение для индексации. Имена полей мы будем использовать позже для создания объекта ответа из результатов поиска.
Существует два основных типа полей для документов Lucene: TextField
и StringField
. Первое из них поддерживает полнотекстовый поиск, а второе — поиск точных совпадений.
Как только мы вызвали метод Commit()
, NCache сохранил наши фильмы в распределенном индексе.
4. Полнотекстовый поиск фильмов
Теперь, когда мы заполнили наш индекс фильмами, для их поиска давайте создадим еще одно приложение Console для чтения запроса Lucene.
Снова используем тот же SearchService
, на этот раз с методом SearchByNames()
, передающим запрос Lucene.
using Lucene.Net.Analysis.Standard;
using Lucene.Net.Index;
using Lucene.Net.QueryParsers.Classic;
using Lucene.Net.Search;
using Lucene.Net.Store;
using Lucene.Net.Util;
using SearchMovies.Shared.Entities;
using SearchMovies.Shared.Extensions;
using SearchMovies.Shared.Responses;
namespace SearchMovies.Shared.Services;
public class SearchService
{
// Same SearchService as before...
public IEnumerable<MovieResponse> SearchByNames(string searchQuery)
{
using var indexDirectory = NCacheDirectory.Open(_cacheName, IndexName);
using var reader = DirectoryReader.Open(indexDirectory);
// ^^^^^^^^^^^^^^^
// 1. Creating a reader
var searcher = new IndexSearcher(reader);
var analyzer = new StandardAnalyzer(luceneVersion);
var parser = new QueryParser(luceneVersion, "name", analyzer);
var query = parser.Parse(searchQuery);
// ^^^^^^
// 2. Parsing a Lucene query
var documents = searcher.Search(query, 10);
// 3. Searching documents
var result = new List<MovieResponse>();
for (int i = 0; i < documents.TotalHits; i++)
{
var document = searcher.Doc(documents.ScoreDocs[i].Doc);
result.Add(document.MapToMovieResponse());
// 4. Populating a result object
}
return result;
}
}
На этот раз вместо создания IndexWriter
мы использовали DirectoryReader
и парсер запросов с той же версией Lucene и анализатором. Затем мы использовали метод Search()
с разобранным запросом и подсчетом результатов. Следующим шагом был перебор результатов и создание объекта ответа.
Для создания объекта ответа из документа Lucene мы использовали метод MapToMovieResponse()
. Вот он,
public static MovieResponse MapToMovieResponse(this Document self)
{
return new MovieResponse(self.Get("name"), self.Get("directorName"));
}
На этот раз для получения полей из документов мы использовали метод Get()
с теми же именами полей, что и раньше.
Например, давайте найдем все фильмы, имя режиссера которых содержит «ca», с помощью запроса directorName:ca*
,
Конечно, в Lucene Query Syntaxt есть больше ключевых слов.
Вуаля! Вот как использовать Distributed Lucene с NCache. Если у нас уже есть реализация с Lucene.NET, нам потребуется немного изменений в коде, чтобы перенести ее на Lucene с NCache. Также обратите внимание, что NCache реализует не все методы Lucene.
Чтобы проследить за кодом, который мы написали в этом посте, ознакомьтесь с моим репозиторием Ncache Demo на GitHub.
Чтобы прочитать больше материалов, ознакомьтесь с моей статьей Работа с ASP.NET Core IDistributedCache Provider для NCache, чтобы узнать о кэшировании с помощью NCache и ASP.NET Core.
Счастливого кодинга!