Radiofisik

my knowledge base

Fulltext Search

В большинстве руководств по реализации полнотекстового поиска рассматривается простой случай когда все поля для поиска находятся в одной таблице, тогда нет проблем с созданием индекса… Однако жизнь оказывается несколько сложней и в реальной системе как правило данные, по которым нужно искат раскиданы по разным таблицам. Рассмотрим такой случай. Для начала сделаем сущности и контекст, добавим миграцию.

 public class Article
    {
        public Guid Id { get; set; }
        public string Title { get; set; }
        public string Content { get; set; }

        public List<Tag> Tags { get; set; }
    }

 public class Tag
    {
        public Guid Id { get; set; }
        public string Name { get; set; }
        public Guid ArticleId { get; set; }

        public Article Article { get; set; }
    }

 public class EfContext : DbContext
    {
        public DbSet<Article> Articles { get; set; }

        public DbSet<Tag> Tags { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Article>()
                .HasMany(a => a.Tags)
                .WithOne(t => t.Article)
                .HasForeignKey(_ => _.ArticleId);
        }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
            => optionsBuilder.UseNpgsql("Host=localhost;Database=testdb;Username=postgres;Password=postgres");
    }

В простейшем виде задачу полнотекстового поиска с учетом весов можно решить запросом

select *
from "Articles"
         left join (
    select "ArticleId", string_agg("Name", ',') "Tags"
    from "Tags"
    group by "ArticleId") t on "Id" = t."ArticleId"
where setweight(to_tsvector('russian', "Articles"."Title"), 'A') ||
      setweight(to_tsvector('russian', "Articles"."Content"), 'B') ||
      setweight(to_tsvector('russian', coalesce("t"."Tags", '')), 'C')
          @@ to_tsquery('russian', 'программирование');

Для упрощения запроса можно ввести функцию

create or replace function make_vector(title text, content text, tags text)
    returns tsvector as
$$
begin
    return (
                setweight(to_tsvector('russian', coalesce(title, '')), 'A') ||
                setweight(to_tsvector('russian', coalesce(content, '')), 'B') ||
                setweight(to_tsvector('russian', coalesce(tags, '')), 'C')
        );
end;
$$ language 'plpgsql' immutable;

Запрос упростится

select *, make_vector("Articles"."Title", "Articles"."Content", "t"."Tags" )
from "Articles"
         left join (
    select "ArticleId", string_agg("Name", ',') "Tags"
    from "Tags"
    group by "ArticleId") t on "Id" = t."ArticleId"
where make_vector("Articles"."Title", "Articles"."Content", "t"."Tags" )
          @@ to_tsquery('russian', 'программирование');

по хорошему результат поиска надо упорядочить по релевантности

select *, ts_rank(make_vector("Articles"."Title", "Articles"."Content", "t"."Tags" ), to_tsquery('russian', 'программирование')) as rank
from "Articles"
         left join (
    select "ArticleId", string_agg("Name", ',') "Tags"
    from "Tags"
    group by "ArticleId") t on "Id" = t."ArticleId"
where make_vector("Articles"."Title", "Articles"."Content", "t"."Tags" )
          @@ to_tsquery('russian', 'программирование')
order by rank desc;

еще упростить запрос можно введя

create view articleTags as
select "ArticleId", string_agg("Name", ',') "Tags"
from "Tags"
group by "ArticleId";

create view searchView as select "Articles"."Id",
       make_vector("Articles"."Title", "Articles"."Content", "t"."Tags") as "search"
from "Articles"
         left join articleTags t on "Id" = t."ArticleId";
select *, ts_rank(s.search, to_tsquery('russian', 'программирование')) as rank
from "Articles"
         left join searchView s on s."Id"="Articles"."Id"
where s."search" @@ to_tsquery('russian', 'программирование')
order by rank desc;

Теперь допустим что данные меняются крайне редко, а поиск должен работать быстро. тогда можно применить materialized view с индексом

create  materialized view searchView2 as select "Articles"."Id",
       make_vector("Articles"."Title", "Articles"."Content", "t"."Tags") as "search"
from "Articles"
         left join articleTags t on "Id" = t."ArticleId";

CREATE INDEX ON searchView2 USING GIN (search);

Обновлять можно периодически или по триггерам

create or replace function refresh_search()
    returns trigger
    language plpgsql
as
$$
begin
    refresh materialized view searchView2;
    return null;
end;
$$;

create trigger refresh_search
    after insert or update or delete or truncate
    on "Articles"
    for statement
    execute procedure refresh_search();

create trigger refresh_search
    after insert or update or delete or truncate
    on "Tags"
    for statement
    execute procedure refresh_search();

Поиск в таком случае осуществляется очень быстро по индексу, но операция обновления materialized view дорогая, и делать ее внутри триггера крайне не желательно, особенно если операции изменения в таблицах не редки. Нужен более ресурсосберегающий подход, примерно с той же идеалогией, будем использовать вместо materialized view таблицу.

Для поиска добавим отдельную таблицу

public class ArticleSearch
{
    public Guid Id { get; set; }
    public Guid ArticleId { get; set; }
    public Article Article { get; set; }
    public NpgsqlTsVector SearchVector { get; set; }
}

 modelBuilder.Entity<Article>()
     .HasOne(a => a.Search)
     .WithOne(t => t.Article)
     .HasForeignKey<ArticleSearch>(_ => _.ArticleId);

 modelBuilder.Entity<ArticleSearch>()
     .HasIndex(p => p.SearchVector)
     .HasMethod("GIN");

Осталось сделать самое сложное - добавить в поисковый вектор данные, причем это надо делать при всех изменениях в обоих таблицах - тегов и статей

Первоначально заполним таблицу

create extension if not exists "uuid-ossp";

insert into "ArticleSearch"("Id", "ArticleId", "SearchVector")
select uuid_generate_v4(), "Id", "search"
from searchView;

Создадим функцию обновления

create or replace function update_search_for_article(id uuid)
    returns void
    language plpgsql
as
$$
begin
    delete from "ArticleSearch" where "ArticleId"="Id";
    insert into "ArticleSearch"("Id", "ArticleId", "SearchVector")
    select uuid_generate_v4(), "Id", "search"
    from searchView
    where searchView."Id" = id;
end;
$$;

Используем ее в триггерах

create or replace function refresh_search_by_articles()
    returns trigger
    language plpgsql
as
$$
begin
    perform update_search_for_article(new."Id");
    return null;
end;
$$;

create trigger refresh_search
    after insert or update or delete
    on "Articles"
    for each row
    execute procedure refresh_search_by_articles();


create or replace function refresh_search_by_tags()
    returns trigger
    language plpgsql
as
$$
begin
    perform update_search_for_article(new."ArticleId");
    return null;
end;
$$;

create trigger refresh_search
    after insert or update or delete
    on "Tags"
    for each row
    execute procedure refresh_search_by_tags();
    
 create index on "ArticleSearch" using gin ("SearchVector");

теперь наш запрос снова работает и если посмотреть explain использует индекс

select *, ts_rank(s."SearchVector", to_tsquery('russian', 'программирование')) as rank
from "Articles"
         left join "ArticleSearch" s on s."ArticleId"="Articles"."Id"
where s."SearchVector" @@ to_tsquery('russian', 'программирование')
order by rank desc;

тот же запрос с использованием EF будет иметь вид

 var context = new EfContext();
 var searchWord = "программирование";
 var result = context.Articles.Where(a => a.Search.SearchVector.Matches(EF.Functions.ToTsQuery("russian", searchWord)))
             .OrderByDescending(a => a.Search.SearchVector.Rank(EF.Functions.ToTsQuery("russian", searchWord))).ToList();

репозиторий тестового проекта https://github.com/Radiofisik/EFFullTextSearch

Использовались материалы

  • https://eax.me/postgresql-full-text-search/
  • https://www.npgsql.org/efcore/mapping/full-text-search.html?tabs=pgold%2Cpg12