Xây dựng trải nghiệm tìm kiếm trong .NET với Lunr-Core

Tìm kiếm có thể là sự khác biệt quan trọng giữa một ứng dụng tốt và một ứng dụng tuyệt vời. Mặc dù công nghệ tìm kiếm như Elasticsearch, Solr, RedisSearch và các tùy chọn có sẵn khác, chúng vẫn đòi hỏi một lượng không nhỏ các nguồn lực để vận hành và duy trì. Cộng đồng web có một giải pháp tìm kiếm cho các nhà phát triển web là Lunr, và may mắn thay, cộng đồng .NET OSS đã chuyển thư viện này thành gói NuGet.

Bài viết này sẽ giúp bạn khám phá Lunr là gì và cách chúng ta có thể sử dụng Lunr-Core để cung cấp trải nghiệm tìm kiếm đơn giản nhưng mạnh mẽ cho người dùng của chúng ta.

Lunr là gì?

Lunr lấy cảm hứng từ Solr, một nền tảng công cụ tìm kiếm JAVA được xây dựng dựa trên Apache Lucene. Phải thừa nhận rằng Lunr không phải là sự thay thế cho SOLR; thay vào đó, những người sáng tạo Lunr đã thiết kế thư viện nhỏ và nhẹ và không có các phụ thuộc bên ngoài.

Triết lý thiết kế của Lunr cho phép các nhà phát triển sử dụng nó trong một loạt các tình huống không thể xảy ra đối với các giải pháp mạnh mẽ hơn.

Lunr tự hào có chức năng tìm kiếm tiêu chuẩn như index fields, tokenizers, stop words, scoring và đường dẫn xử lý tài liệu cho phép các tính năng như:

  • Hỗ trợ tìm kiếm toàn văn bản (full-text search) cho 14 ngôn ngữ.
  • Tăng các điều khoản tại thời điểm truy vấn hoặc tăng cường toàn bộ tài liệu tại thời điểm lập chỉ mục.
  • Phạm vi tìm kiếm các trường cụ thể.
  • Tìm kiếm mờ với các ký tự đại diện hoặc chỉnh sửa khoảng cách.

Mặc dù tất cả điều này nghe có vẻ phức tạp, nhưng API rất đơn giản. Trước tiên, hãy xem cách triển khai JavaScript.

var idx = lunr(function () { 
    this.field('title') 
    this.field('body')
    this.add({ 
        "title": "Twelfth-Night",
        "body": "If music be the food of love, play on: Give me excess of it…",
        "author": "William Shakespeare",
        "id": "1" 
    }) 
})

Quá trình lập chỉ mục này có hai trường tìm kiếm là title body. Khi chúng ta đã xây dựng chỉ mục, chúng ta có thể tìm kiếm các giá trị bằng cách sử dụng đối tượng idx.

idx.search("love")

Lunr trả về kết quả tìm kiếm trong một mảng JSON.

[ 
  { 
    "ref": "1",
    "score": 0.3535533905932737,
    "matchData": {
        "metadata": {
            "love": {
                "body": {} 
            } 
        }
    }
  } 
]

Ví dụ tuyệt vời ở trên được viết bằng JavaScript, nhưng chúng tôi là nhà phát triển .NET! Còn .NET thì sao ?!

Sử dụng Lunr-Core cho tìm kiếm trong .NET

Lunr-Core được port từ Lunr để sử dụng trong các ứng dụng .NET và có lợi ích tuyệt vời là tương thích 100% với Lunr. Điều đó có nghĩa là chúng ta có thể sử dụng các chỉ mục được xây dựng bằng triển khai JavaScript hoặc triển khai .NET. Để bắt đầu với Lunr-Core, chúng ta cần cài đặt gói NuGet.

dotnet add package LunrCore

API trong C# rất giống với phiên bản JavaScript của nó. Hãy xem việc lập chỉ mục một tài liệu.

var index = await Index.Build(async builder =>
{
    builder
        .AddField("title")
        .AddField("body");

    await builder.Add(new Document
    {
        { "title", "Twelfth-Night" },
        { "body", "If music be the food of love, play on: Give me excess of it…" },
        { "author", "William Shakespeare" },
        { "id", "1" },
    });
});

Khi chúng ta xây dựng chỉ mục của mình, chúng ta có thể sử dụng đối tượng index để thực hiện tìm kiếm.

await foreach (Result result in index.Search("love"))
{
    // do something with that result
}

Hãy xem xét những hạn chế của Lunr trước khi chuyển sang một mẫu .NET hoàn chỉnh.

Cân nhắc khi sử dụng Lunr

Lunr là một thư viện công cụ tìm kiếm đầy đủ tính năng, nhưng có những hạn chế mà các nhà phát triển .NET nên cân nhắc trước khi sử dụng Lunr.

Hạn chế đầu tiên là Lunr không thể xây dựng chỉ mục gia tăng. Điều đó có nghĩa là thêm một tài liệu vào chỉ mục sẽ yêu cầu lập chỉ mục lại từ đầu.

Các chỉ mục Lunr là bất biến. Khi chúng đã được xây dựng, không thể thêm, cập nhật hoặc xóa bất kỳ tài liệu nào trong chỉ mục.

Nếu dữ liệu của chúng ta thay đổi nhanh chóng, thì Lunr có thể không phải là lựa chọn tốt nhất của công nghệ tìm kiếm.

Một nhược điểm khác là Lunr ghi các chỉ mục vào JSON, một định dạng dữ liệu chưa được tối ưu hóa về mặt dung lượng có thể lớn hơn dữ liệu gốc một cách đáng ngạc nhiên.

Ví dụ, một CSV được lập chỉ mục có kích thước 2.6MB nhưng tập tin lưu trữ chỉ mục trên ổ đĩa cứng có kích thước 17,3 MB.

Nếu đọc và ghi từ ổ đĩa cứng là một hoạt động tốn kém, thì Lunr có thể không phải là lựa chọn phù hợp cho trường hợp sử dụng của chúng ta.

Ví dụ sử dụng Lunr-Core trong C#

Vì vậy, nếu bạn vẫn quan tâm đến việc sử dụng Lunr cho trải nghiệm tìm kiếm của mình, thì tôi đã cung cấp một mẫu bên dưới. Chúng ta sẽ đọc các thành phố của Hoa Kỳ từ một tập tin CSV và lập chỉ mục chúng. Chúng ta cũng sẽ ghi chỉ mục của mình vào ổ đĩa cứng để loại bỏ chi phí lập chỉ mục tài liệu của chúng ta khi khởi động.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using CsvHelper;
using CsvHelper.Configuration;
using CsvHelper.Configuration.Attributes;
using Lunr;
using Spectre.Console;

// our database
var cities = new Dictionary<string, City>();
var status = AnsiConsole
    .Status()
    .Spinner(Spinner.Known.Earth)
    .AutoRefresh(true);

// let's build our search index
Lunr.Index index = null;
const string indexName = "local.index.json";
await status.StartAsync("Thinking...", async ctx =>
{
    ctx.Status("[green]loading cities...[/]");
    var config = new CsvConfiguration(CultureInfo.InvariantCulture) {
        Delimiter = "|",
        HasHeaderRecord = true,
        MissingFieldFound = null
    };
    using var reader = new StreamReader("us_cities.csv");
    using var csv = new CsvReader(reader, config);
    
    await foreach (var city in csv.GetRecordsAsync<City>().Select((city, id) => city.WithId(id)))
    {
        cities.Add(city.Id, city);
    }

    if (File.Exists(indexName))
    {
        ctx.Status("[green]loading index from disk...[/]");
        var json = await File.ReadAllTextAsync(indexName);
        index = Lunr.Index.LoadFromJson(json);
    }
    else
    {
        ctx.Status("[green]building index...[/]");
        
        index = await Lunr.Index.Build(async builder =>
        {
            foreach (var field in City.Fields)
                builder.AddField(field);

            foreach (var (_, city) in cities)
            {
                await builder.Add(city.ToDocument());
            }
        });

        await using var file = File.OpenWrite(indexName);
        await index.SaveToJsonStream(file);
    }
});
var running = true;
Console.CancelKeyPress += (_, _) => running = false;
while (running)
{
    Console.Write("Search : ");
    var search = Console.ReadLine();

    var table = new Table()
        .Title($":magnifying_glass_tilted_left: Search Results for \"{search}\"")
        .BorderStyle(new Style(foreground: Color.NavajoWhite1, decoration: Decoration.Italic))
        .AddColumn("name")
        .AddColumn("county")
        .AddColumn("state")
        .AddColumn("alias")
        .AddColumn("score");

    await foreach (var result in index.Search(search ?? string.Empty).Take(10))
    {
        var city = cities[result.DocumentReference];
        table.AddRow(
            city.Name,
            city.County,
            city.StateAbbreviation,
            city.Alias,
            $"{result.Score:F3}"
        );
    }

    AnsiConsole.Render(table);
}

public class City
{
    // Headers:
    // City|State short|State full|County|City alias
    [Ignore] public string Id { get; set; }
    [Name("City")] public string Name { get; set; }
    [Name("State full")] public string StateName { get; set; }
    [Name("State short")] public string StateAbbreviation { get; set; }
    [Name("County")] public string County { get; set; }
    [Name("City alias")] public string Alias { get; set; }
    
    public City WithId(int id)
    {
        Id = id.ToString();
        return this;
    }

    public Document ToDocument()
    {
        return new(new Dictionary<string, object>
        {
            {"id", Id},
            {nameof(Name), Name},
            {nameof(StateName), StateName},
            {nameof(StateAbbreviation), StateAbbreviation},
            {nameof(County), County},
            {nameof(Alias), Alias}
        });
    }

    public static IEnumerable<string> Fields => new[]
    {
        nameof(Name),
        nameof(StateName),
        nameof(StateAbbreviation),
        nameof(County),
        nameof(Alias)
    }.ToList().AsReadOnly();
}

Phần kết luận

Lunr và nói cách khác, Lunr-Core rất tuyệt vời để cung cấp trải nghiệm tìm kiếm cho các tập dữ liệu tĩnh. Nó cũng là một lựa chọn hoàn hảo cho những người đang xây dựng trải nghiệm khách hàng, đặc biệt là khi Web Assembly đưa .NET vào trình duyệt.

Như bạn đã thấy trong bài đăng này, không mất nhiều thời gian để bắt đầu cung cấp trải nghiệm tìm kiếm hấp dẫn cho người dùng của bạn. Lunr cũng là một điểm khởi đầu tuyệt vời để nâng cấp lên một trong những giải pháp mạnh mẽ hơn đã đề cập trước đây.

Tôi hy vọng bạn thấy bài viết này hữu ích và một lần nữa cảm ơn bạn đã đọc.

Nếu Comdy hữu ích và giúp bạn tiết kiệm thời gian làm việc

Bạn có thể vui lòng đưa Comdy vào whitelist của trình chặn quảng cáo ❤️ để hỗ trợ chúng tôi trong việc trả tiền cho dịch vụ lưu trữ web để duy trì hoạt động của trang web.

Lập Trình C#
Bài Viết Liên Quan:
Tạo tập tin Zip với .NET 5
Trung Nguyen 11/11/2021
Tạo tập tin Zip với .NET 5

Trong bài viết này, chúng ta sẽ tìm hiểu lớp tiện ích ZipFile trong C#, cách nén tập tin và thư mục, cùng với giải nén tập tin zip.

Đọc và ghi file Excel trong C#
Trung Nguyen 29/10/2021
Đọc và ghi file Excel trong C#

Bài viết này sẽ giới thiệu cách đơn giản nhất mà tôi đã tìm thấy để đọc và ghi file Excel bằng C# sử dụng ExcelMapper.

Làm việc với PriorityQueue của .NET 6
Trung Nguyen 25/10/2021
Làm việc với PriorityQueue của .NET 6

Bài viết này sẽ giúp bạn tìm hiểu PriorityQueue của .NET 6 là gì, cách chúng ta thêm các phần tử và cách chúng ta có thể xếp hàng lại cho các phần tử.

Hướng dẫn nhanh và ví dụ về pattern matching trong C#
Trung Nguyen 23/10/2021
Hướng dẫn nhanh và ví dụ về pattern matching trong C#

Bài viết này sẽ trình bày một số ví dụ về pattern matching hữu ích và bạn có thể xem xét sử dụng trong các dự án hiện tại hoặc tương lai của bạn.