Сегодня мы рассмотрим серьезную часть работы с Lucene.Net (высокоскоростной движок поиска по
большому объему текстовых данных), которая плохо освещена в интернете и на ознакомление с которой я потратил достаточно много времени, – это тема с
обновлением полей в документе и как обновить индекс. Если вы не знаете, что
такое Lucene.Net и как его
использовать, для вас более интересной может быть вводная статья: "Используем Lucene.Net для высокоточного полнотекстового поиска", которая рассказывает о том, как построить полноценную модель
поиска для своего приложения. Мы создадим пример, в котором и реализуем
наш поиск и обновление полей. Для этого давайте создадим новое консольное
приложение, которое назовем "LuceneUpdateSample".
Затем через NuGet давайте поставим
себе Lucene.NET.
А теперь приступим к самой реализации. Мы постараемся реализовать пример, который
максимально приближен в реальному использованию Lucene.NET в своих приложениях. Существует два
способа обновить Document в индексе. Первый работает по принципу удаления
документа и вставки нового, второй – по принципу удаления старого поля и добавления нового поля (field). Ниже на рисунке показаны две функции, которые реализуют оба способа.
Ниже два этих варианта и будут рассмотрены. Первый – для добавления новых полей, второй – для их удаления. Как говорят, меньше слов – больше дела. Каждый шаг я буду комментировать. Первым делом давайте представим, что у нас
есть класс Order, подобный
приведенному ниже.
Подобный класс мы
используем для реальных данных с намного большим количеством полей, но для
теста я специально их убрал, чтобы оставить самое необходимое.
public class Order
{
public long Id { get; set; }
public Dictionary<string, string> CustomFields { get; set; }
}
Затем вынесем
базовую логику по созданию документа в базовый класс, который назовем ADocument.
Теперь создадим
индекс, в котором будем сохранять информацию о наших документах, связанных с
полями класса Order. Назовем этот класс OrderDocument.
public class OrderDocument : ADocument
{
private Dictionary<string, string> _customFields;
public Dictionary<string, string> CustomFields
{
get { return _customFields; }
set
{
_customFields = value;
foreach (var cf in _customFields)
{
var cfvalue = ReferenceEquals(null, cf.Value) ? string.Empty : cf.Value;
AddParameterToDocumentStoreParameter(cf.Key, cfvalue);
}
}
}
public static explicit operator OrderDocument(Order order)
{
var orderDocument = new OrderDocument
{
Id = order.Id,
CustomFields = order.CustomFields
};
return orderDocument;
}
}
Нам также нужно создать сервис, с помощью которого мы будем искать старые документы по Id. Для этого добавим
новый интерфейс ISearchService, реализацию которого вы можете посмотреть ниже.
public interface ISearchService
{
string DataFolder { get; }
FSDirectory LuceneDirectory { get; }
Document SearchById(long id);
}
Имплементация этого
сервиса приведена ниже в классе SearchService.
public class SearchService : ISearchService
{
private const string LuceneIndexFolder = "LuceneIndex";
private FSDirectory _luceneDirectory;
private string _dataFolder;
private const int HitsLimit = 1000;
public SearchService()
{
InitializeLucene();
}
public string DataFolder
{
get { return _dataFolder; }
}
public FSDirectory LuceneDirectory
{
get { return _luceneDirectory; }
}
private void InitializeLucene()
{
_dataFolder = Path.Combine(Environment.CurrentDirectory, LuceneIndexFolder);
var di = new DirectoryInfo(_dataFolder);
if (!di.Exists)
{
di.Create();
}
_luceneDirectory = FSDirectory.Open(di.FullName);
}
public Document SearchById(long id)
{
Document resultDoc = null;
using (var searcher = new IndexSearcher(LuceneDirectory))
{
var analyzer = new StandardAnalyzer(Lucene.Net.Util.Version.LUCENE_30);
var parser = new QueryParser(Lucene.Net.Util.Version.LUCENE_30, "Id", analyzer);
var query = ParseQuery(id.ToString(), parser);
ScoreDoc[] hits = searcher.Search(query, HitsLimit).ScoreDocs;
if (hits != null)
{
resultDoc = searcher.Doc(hits[0].Doc);
}
analyzer.Close();
searcher.Dispose();
}
return resultDoc;
}
/// <summary>
/// Parse the given query string to a Lucene Query object
/// </summary>
/// <param name="searchQuery">The query string</param>
/// <param name="parser">The Lucense QueryParser</param>
/// <returns>A Lucene Query object</returns>
private static Query ParseQuery(string searchQuery, QueryParser parser)
{
parser.AllowLeadingWildcard = true;
Query q;
try
{
q = parser.Parse(searchQuery);
}
catch (ParseException e)
{
q = null;
}
if (q == null || string.IsNullOrEmpty(q.ToString()))
{
string cooked = Regex.Replace(searchQuery, @"[^\w\.@-]", " ");
q = parser.Parse(cooked);
}
return q;
}
}
В данном классе
реализован метод SearchById, позволяющий нам искать документы, которые
нужно обновить по Id. При инициализации данного сервиса первым делом
проверяется, существует ли каталог для хранения индекса; если же его нет, то он
будет создан. Это делает функция InitializeLucene(). Теперь настало время реализовать класс, в котором мы реализуем логику добавления новых документов в индекс, а также
обновление существующих (также добавлена логика по удалению документов).
Назовем этот класс BaseWriter и посмотрим, как он реализован ниже.
public class BaseWriter
{
private readonly ISearchService _luceneService;
public BaseWriter(ISearchService luceneService)
{
_luceneService = luceneService;
}
public ISearchService LuceneServiceItem
{
get { return _luceneService; }
}
protected void AddUpdateItemsToIndex(IEnumerable<ADocument> docs)
{
var standardAnalyzer = new StandardAnalyzer(Lucene.Net.Util.Version.LUCENE_30);
using (var writer = new IndexWriter(_luceneService.LuceneDirectory, standardAnalyzer, IndexWriter.MaxFieldLength.UNLIMITED))
{
foreach (var doc in docs)
{
AddItemToIndex(doc, writer);
}
standardAnalyzer.Close();
writer.Dispose();
}
}
protected void UpdateItemsToIndex(IEnumerable<ADocument> docs)
{
var standardAnalyzer = new StandardAnalyzer(Lucene.Net.Util.Version.LUCENE_30);
using (var writer = new IndexWriter(_luceneService.LuceneDirectory, standardAnalyzer, IndexWriter.MaxFieldLength.UNLIMITED))
{
foreach (var doc in docs)
{
UpdateItemToIndex(doc, writer);
}
standardAnalyzer.Close();
writer.Dispose();
}
}
private void UpdateItemToIndex(ADocument updateDoc, IndexWriter writer)
{
var docId = updateDoc.Id;
//retrieve the old document
Document searchDoc = _luceneService.SearchById(docId);
var replacementFields = updateDoc.Document.GetFields().ToList();
foreach (var field in replacementFields)
{
var name = field.Name;
var currentValue = searchDoc.GetValues(name);
if (currentValue != null)
{
//replacement field value
//remove all occurrences of the old field
searchDoc.RemoveField(name);
//insert the replacement
searchDoc.Add(field);
}
else
{
//new field
searchDoc.Add(field);
}
}
var query = new TermQuery(new Term("Id", updateDoc.Id.ToString()));
writer.UpdateDocument(query.Term, searchDoc);
}
private void AddItemToIndex(ADocument doc, IndexWriter writer)
{
var query = new TermQuery(new Term("Id", doc.Id.ToString()));
writer.DeleteDocuments(query);
writer.AddDocument(doc.Document);
}
private void DeleteItemFromIndex(ADocument doc, IndexWriter writer)
{
var query = new TermQuery(new Term("Id", doc.Id.ToString()));
writer.DeleteDocuments(query);
}
protected void DeleteItemsFromIndex(IEnumerable<ADocument> docs)
{
var standardAnalyzer = new StandardAnalyzer(Lucene.Net.Util.Version.LUCENE_30);
using (var writer = new IndexWriter(_luceneService.LuceneDirectory, standardAnalyzer, IndexWriter.MaxFieldLength.UNLIMITED))
{
foreach (var doc in docs)
{
DeleteItemFromIndex(doc, writer);
}
standardAnalyzer.Close();
writer.Dispose();
}
}
protected void Optimize()
{
var standardAnalyzer = new StandardAnalyzer(Lucene.Net.Util.Version.LUCENE_30);
using (var writer = new IndexWriter(_luceneService.LuceneDirectory, standardAnalyzer, IndexWriter.MaxFieldLength.UNLIMITED))
{
standardAnalyzer.Close();
writer.Optimize();
writer.Dispose();
}
}
}
Для обновления
документов у нас используются, по сути, два метода.
- AddItemToIndex для удаления старых документов и добавления новых;
- UpdateItemToIndex для обновления полей в текущем документе.
Теперь давайте
реализуем простой враппер для нашего класса OrderDocument, который запишет все необходимые поля в
индекс. Назовем
этот класс OrderWriter и наследуем его от
класса BaseWriter. Этот класс очень
простой и, по сути, является всего лишь врапером для добавления документов в
индекс.
public class OrderWriter : BaseWriter
{
public OrderWriter(ISearchService luceneService) : base(luceneService)
{
}
public void AddUpdateOrderToIndex(Order order)
{
AddUpdateItemsToIndex(new List<OrderDocument> { (OrderDocument)order });
}
public void UpdateCustomFieldsToIndex(long id, Dictionary<string, string> customFields)
{
UpdateItemsToIndex(new List<OrderDocument> { new OrderDocument
{
Id = id,
CustomFields = customFields
} });
}
public void AddUpdateOrderToIndex(List<Order> orders)
{
AddUpdateItemsToIndex(orders.Select(p => (OrderDocument)p).ToList());
}
public void DeleteOrderFromIndex(Order order)
{
DeleteItemsFromIndex(new List<OrderDocument> { (OrderDocument)order });
}
public void DeleteOrderFromIndex(long id)
{
DeleteItemsFromIndex(new List<OrderDocument> { new OrderDocument { Id = id } });
}
}
Теперь реализуем тестовый пример, в котором это все проверим. Для того чтобы
посмотреть, как построился ваш индекс, вам понадобится тулза под названием Luke.
Она позволяет просматривать индексы Lucene. Написана она на Java и предназначена
для чтения индекса Lucene, но также успешно работает с Lucene.NET. Рекомендую вам
ее загрузить, если вы хотите посмотреть, как будет построен ваш индекс. В конце
статьи мы посмотрим с помощью Luke на то, что у нас получилось.
Для начала реализуем функцию, которая для нас сгенерирует 1000 ордеров, которые мы запишем
в наш индекс в Lucene.NET.
public static List<Order> GenerateOrders()
{
var customFields = new Dictionary<string, string>();
for (int i = 0; i < 20; i++)
{
var key = "Field" + i;
var value = BigTestData();
customFields.Add(key, value);
}
var orders = new List<Order>();
for (int i = 1; i <= 1000; i++)
{
orders.Add(new Order
{
Id = i,
CustomFields = customFields
});
}
return orders;
}
public static string BigTestData()
{
return @"Hi I'm creating a simple library system for charity. Now I have a form that allows the users to search for books. To do this work I get a text write in a imput text and use inside a book => book.Name.Contains(text) expression (LINQ+EF).
But I want to go a little further. I'm thinking in mix it up a little big and use the same text to find book title, subtitle, author, publishing house, and serie.
Now I have 3 problems:
Search all columns
Disregarding the order of the input text words
Sort the results for the book that most fit into the input text.
I know these topics are a little vague. But I don't know how or where to started. Someone can get me some help?";
}
Сам тест рассмотрен ниже в функции
TestLuceneUpdateFields().
private static void TestLuceneUpdateFields()
{
ISearchService luceneService = new SearchService();
var writer = new OrderWriter(luceneService);
var orders = GenerateOrders();
writer.AddUpdateOrderToIndex(orders);
var random = new Random();
var position = random.Next(orders.Count);
var order = orders[position];
Console.WriteLine("Order Id = {0}", order.Id);
var customFields = new Dictionary<string, string>
{
{"Field1", "My test changes"},
{"Field2", "This is test data"}
};
writer.UpdateCustomFieldsToIndex(order.Id, customFields);
var document = luceneService.SearchById(order.Id);
var field1 = document.Get("Field1");
var field2 = document.Get("Field2");
Debug.Assert(field1 == "My test changes");
Debug.Assert(field2 == "This is test data");
}
Использование этой
функции приведено в методе Main.
static void Main(string[] args)
{
TestLuceneUpdateFields();
Console.Read();
}
Теперь запустим наш
пример и посмотрим на результат.
Затем запускаем нашу
тулзу Luke и указываем ей нашу директорию.
Кстати, ваша
директория будет иметь подобный вид, потому что в таком виде хранятся индексы в
Lucene.NET.
После того как вы
нажмете OK в редакторе Luke вы увидите
результат, как показано ниже.
Как видите, по
каждому нашему полю есть нужная информация. Теперь нам нужно найти наш документ
с индексом 688. Для этого мы переходим на вкладку Documents в Luke и переходим на последний документ (в нашем
примере это 1000), поскольку Lucene сначала удаляет старый документ, затем
создает новый.
Как видите, Id у
него 688. Именно тот, который мы искали. В целом, как видите, работа с Lucene
особой сложности не представляет.
Итоги
В этой статье мы рассмотрели, как работает процесс обновления документов в Lucene.NET на практике.
Надеюсь, если у вас возникали с этим определенные сложности, то они останутся
далеко позади. Lucene.NET предоставляет нам огромное количество возможностей,
которые, к сожалению, не очень хорошо документированы, а если говорить начистоту,
то они документированы очень плохо. Но
это же дает возможность где развиваться и где можно оттачивать свое мастерство
и умения. Обещаю, что в следующих статьях мы с вами разберем еще что-либо
интересное с мира Lucene.Net. Тем более, задач, которые можно решать с Lucene.NET, – просто огромное количество.