在ef core中使用postgres資料庫的全文檢索功能實戰

wjsgzcn發表於2020-05-14

起源

之前做的很多專案都使用solr/elasticsearch作為全文檢索引擎,它們功能全面而強大,但是對於較小的專案而言,構建和維護成本顯然過高,尤其是從關聯式資料庫/文件資料庫到全文檢索引擎的資料同步工作非常繁瑣,且容易出錯。

記得很久以前就知道postgresql資料庫內建全文檢索,最近發現這個資料庫越來越火,於是就又研究了一番,欣喜的發現居然支援ef core,於是對其進行了一些研究,並整理心得如下。

前提

本文假設讀者熟悉entity framework core的基本概念和基本使用。

目的

建立dotnet core專案,使用postgres資料庫和ef core,實現常見的全文檢索功能,包括

  • 建立索引欄位
  • 基本查詢
  • 查詢結果排名
  • 查詢結果高亮顯示

步驟1 - 新建專案並引入packages

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="EFCore.NamingConventions" Version="1.1.0" />
    <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="3.1.4" />
    <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="3.1.3" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.1.3" />
  </ItemGroup>

</Project>

注意NamingConventions包是可選的,其作用是將表和欄位名稱翻譯成蛇形,如MyData -> my_data,這樣比較方便手寫sql,不用寫煩人的引號。

步驟2 - 建立model和dbcontext

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using NpgsqlTypes;

public class Article
{
    public int Id { get; set; }

    [Required]
    [MaxLength(128)]
    public string Title { get; set; }

    [MaxLength(512)]
    public string Abst { get; set; }

    public NpgsqlTsVector TitleVector { get; set; }
    public NpgsqlTsVector AbstVector { get; set; }

    [NotMapped]
    public string TitleHL { get; set; }

    [NotMapped]
    public string AbstHL { get; set; }
}

本model中的TitleVector和AbstVector分別用來存放Title和Abst欄位的分詞結果,便於後續的查詢。不必擔心程式碼會不小心改掉這些欄位以至於查詢出錯,因為後續會設定一個觸發器,每次更改資料的時候都會自動更新這些欄位的內容。

using Microsoft.EntityFrameworkCore;

public class MyDbContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder
        .UseNpgsql("Host=localhost;Database=ft;Username=postgres;Password=123456")
        .UseLoggerFactory(PgFtSearch.Program.MyLoggerFactory)
        .UseSnakeCaseNamingConvention();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        modelBuilder.Entity<Article>().HasIndex(p => p.TitleVector).HasMethod("GIN");
        modelBuilder.Entity<Article>().HasIndex(p => p.AbstVector).HasMethod("GIN");
    }

    public DbSet<Article> Articles { get; set; }
}

首先UseNpgsql設定了要連線哪個資料庫,然後UseLoggerFactory用來列印日誌,主要是sql語句。MyLoggerFactory是怎麼來的,參考後續的程式碼。

GIN的兩行,用來告訴資料庫這兩個欄位是採用倒排索引。

步驟3 - 生成migration並手動新增觸發器

dotnet ef migrations add Init

然後,在生成的migration檔案中手動新增觸發器,在新增或者修改資料時,自動修改索引欄位的內容,應用程式不必擔心索引同步的問題。

migrationBuilder.Sql(
            @"CREATE TRIGGER article_title_search_vector_update BEFORE INSERT OR UPDATE
              ON articles FOR EACH ROW EXECUTE PROCEDURE
              tsvector_update_trigger(title_vector, 'pg_catalog.english', title);");

migrationBuilder.Sql(
            @"CREATE TRIGGER article_abst_search_vector_update BEFORE INSERT OR UPDATE
              ON articles FOR EACH ROW EXECUTE PROCEDURE
              tsvector_update_trigger(abst_vector, 'pg_catalog.english', abst);");

步驟4 - 編寫程式

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace PgFtSearch
{
    class Program
    {
        public static readonly ILoggerFactory MyLoggerFactory
            = LoggerFactory.Create(builder => { builder.AddConsole(); });
    
        static void Main(string[] args)
        {
            using (var db = new MyDbContext())
            {
                if (!db.Articles.Any())
                {
                    var articles = new List<Article>{
                        new Article{Title="testing is ok", Abst="this is a test about postgre full text searching"},
                        new Article{Title="tested all bugs", Abst="there is no bug exists in this app"}
                    };

                    db.AddRange(articles);
                    db.SaveChanges();
                }

                var query = "test";

                var data = db.Articles
                    .Where(p => p.TitleVector.Matches(query) || p.AbstVector.Matches(query))
                    .OrderByDescending(p=>p.TitleVector.Rank(EF.Functions.ToTsQuery(query)) * 2.0 + p.AbstVector.Rank(EF.Functions.ToTsQuery(query)))
                    .Select(p=>new Article{
                        Title = p.Title,
                        Abst = p.Abst,
                        TitleHL = EF.Functions.ToTsQuery(query).GetResultHeadline(p.Title),
                        AbstHL = EF.Functions.ToTsQuery(query).GetResultHeadline(p.Abst),
                    });

                foreach (var article in data)
                {
                    Console.WriteLine($"{article.Title}\t{article.Abst}\t{article.TitleHL}\t{article.AbstHL}");
                }
            }
        }
    }
}

首先,如果沒有資料,插入幾條測試資料。

下面到了最關鍵的地方,編寫資料查詢的程式碼,實現的具體功能是:

  • 使用test關鍵字在title或abst欄位中查詢資料
  • 對查詢結果進行排序,title欄位排序權重=2.0,高於abst欄位權重=1.0
  • 檢索結果的title和abst進行高亮顯示

最終生成的SQL如下:

SELECT 
  a.title AS "Title",
  a.abst AS "Abst",
  ts_headline(a.title, to_tsquery(@__query_0)) AS "TitleHL",
  ts_headline(a.abst, to_tsquery(@__query_0)) AS "AbstHL" FROM articles AS a WHERE (a.title_vector @@ plainto_tsquery(@__query_0)) OR (a.abst_vector @@ plainto_tsquery(@__query_0)) ORDER BY (ts_rank(a.title_vector, to_tsquery(@__query_0))::double precision * 2.0) + ts_rank(a.abst_vector, to_tsquery(@__query_0))::double precision DESC

程式碼在這兒,相信大家都能看懂,有問題歡迎交流。

總結

目前還未研究中文分詞的支援情況,也沒有測試效能。不過大致看來,完全可以在中小型專案中使用postgres資料庫的內建全文檢索功能替代solr/es等搜尋引擎,減少系統的複雜程度,提升全文檢索功能的穩定性。

相關文章