Search results: prioritize articles titles (#13918)

* Add HairTrigger spec

* Add weights to columns tsvectors

* Add correct sorting for search results

* Remove Timecop.travel

* Changed order of columns in trigger based on weights
This commit is contained in:
rhymes 2021-06-09 14:28:19 +02:00 committed by GitHub
parent 78c566e1e2
commit 4bc56b8ac0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 125 additions and 24 deletions

View file

@ -123,12 +123,12 @@ class Article < ApplicationRecord
.declare("l_org_vector tsvector; l_user_vector tsvector") do
<<~SQL
NEW.reading_list_document :=
to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.body_markdown, ''))) ||
to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.cached_tag_list, ''))) ||
to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.cached_user_name, ''))) ||
to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.cached_user_username, ''))) ||
to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.title, ''))) ||
to_tsvector('simple'::regconfig,
setweight(to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.title, ''))), 'A') ||
setweight(to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.cached_tag_list, ''))), 'B') ||
setweight(to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.body_markdown, ''))), 'C') ||
setweight(to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.cached_user_name, ''))), 'D') ||
setweight(to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.cached_user_username, ''))), 'D') ||
setweight(to_tsvector('simple'::regconfig,
unaccent(
coalesce(
array_to_string(
@ -139,7 +139,7 @@ class Article < ApplicationRecord
''
)
)
);
), 'D');
SQL
end

View file

@ -14,7 +14,7 @@ module Search
relation = relation.search_articles(term) if term.present?
relation = sort(relation, sort_by, sort_direction)
relation = sort(relation, term, sort_by, sort_direction)
tag_flares = Homepage::FetchTagFlares.call(relation)
@ -25,7 +25,10 @@ module Search
serialize(relation, tag_flares)
end
def self.sort(relation, sort_by, sort_direction)
def self.sort(relation, term, sort_by, sort_direction)
# By skipping ordering, we rely on the custom ranking defined in the article's tsvector document
return relation if term.present? && sort_by.blank?
return relation.reorder(sort_by => sort_direction) if sort_by&.to_sym == :published_at
relation.reorder(DEFAULT_SORT_BY)

View file

@ -0,0 +1,69 @@
# This migration was auto-generated via `rake db:generate_trigger_migration'.
# While you can edit this file, any changes you make to the definitions here
# will be undone by the next auto-generated trigger migration.
class CreateTriggerArticlesInsertUpdate1 < ActiveRecord::Migration[6.1]
def up
drop_trigger("update_reading_list_document", "articles", :generated => true)
create_trigger("update_reading_list_document", :generated => true, :compatibility => 1).
on("articles").
name("update_reading_list_document").
before(:insert, :update).
for_each(:row).
declare("l_org_vector tsvector; l_user_vector tsvector") do
<<-SQL_ACTIONS
NEW.reading_list_document :=
setweight(to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.title, ''))), 'A') ||
setweight(to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.cached_tag_list, ''))), 'B') ||
setweight(to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.body_markdown, ''))), 'C') ||
setweight(to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.cached_user_name, ''))), 'D') ||
setweight(to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.cached_user_username, ''))), 'D') ||
setweight(to_tsvector('simple'::regconfig,
unaccent(
coalesce(
array_to_string(
-- cached_organization is serialized to the DB as a YAML string, we extract only the name attribute
regexp_match(NEW.cached_organization, 'name: (.*)$', 'n'),
' '
),
''
)
)
), 'D');
SQL_ACTIONS
end
end
def down
drop_trigger("update_reading_list_document", "articles", :generated => true)
create_trigger("update_reading_list_document", :generated => true, :compatibility => 1).
on("articles").
name("update_reading_list_document").
before(:insert, :update).
for_each(:row).
declare("l_org_vector tsvector; l_user_vector tsvector") do
<<-SQL_ACTIONS
NEW.reading_list_document :=
to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.body_markdown, ''))) ||
to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.cached_tag_list, ''))) ||
to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.cached_user_name, ''))) ||
to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.cached_user_username, ''))) ||
to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.title, ''))) ||
to_tsvector('simple'::regconfig,
unaccent(
coalesce(
array_to_string(
-- cached_organization is serialized to the DB as a YAML string, we extract only the name attribute
regexp_match(NEW.cached_organization, 'name: (.*)$', 'n'),
' '
),
''
)
)
);
SQL_ACTIONS
end
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2021_05_12_025422) do
ActiveRecord::Schema.define(version: 2021_06_09_103939) do
# These are extensions that must be enabled in order to support this database
enable_extension "citext"
@ -1606,12 +1606,12 @@ ActiveRecord::Schema.define(version: 2021_05_12_025422) do
declare("l_org_vector tsvector; l_user_vector tsvector") do
<<-SQL_ACTIONS
NEW.reading_list_document :=
to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.body_markdown, ''))) ||
to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.cached_tag_list, ''))) ||
to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.cached_user_name, ''))) ||
to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.cached_user_username, ''))) ||
to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.title, ''))) ||
to_tsvector('simple'::regconfig,
setweight(to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.title, ''))), 'A') ||
setweight(to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.cached_tag_list, ''))), 'B') ||
setweight(to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.body_markdown, ''))), 'C') ||
setweight(to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.cached_user_name, ''))), 'D') ||
setweight(to_tsvector('simple'::regconfig, unaccent(coalesce(NEW.cached_user_username, ''))), 'D') ||
setweight(to_tsvector('simple'::regconfig,
unaccent(
coalesce(
array_to_string(
@ -1622,7 +1622,7 @@ NEW.reading_list_document :=
''
)
)
);
), 'D');
SQL_ACTIONS
end

View file

@ -0,0 +1,11 @@
require "rails_helper"
# HairTrigger suggests adding this test to make sure the schema and triggers are aligned
# See https://github.com/jenseng/hair_trigger#testing
RSpec.describe HairTrigger, type: :model do
describe ".migrations_current?" do
it "is always true" do
expect(described_class.migrations_current?).to be(true)
end
end
end

View file

@ -102,7 +102,17 @@ RSpec.describe Search::Article, type: :service do
end
context "when sorting" do
it "sorts by 'hotness_score' and 'comments_count' in descending order by default" do
it "sorts by title, tags, body ranking by default with a search term", :aggregate_failures do
article_body = create(:article)
article_body.update(body_markdown: "The Other Side of Silence Impedit consequatur")
article_tag = create(:article, tags: "consequatur")
article_title = create(:article, title: "The Other Side of Silence Impedit consequatur")
results = described_class.search_documents(term: "consequatur")
expect(results.pluck(:id)).to eq([article_title.id, article_tag.id, article_body.id])
end
it "sorts by 'hotness_score' and 'comments_count' in descending order without a search term" do
article1, article2, article3 = create_list(:article, 3)
article1.update_columns(hotness_score: 10, comments_count: 10)
@ -113,13 +123,21 @@ RSpec.describe Search::Article, type: :service do
expect(results.pluck(:id)).to eq([article2.id, article1.id, article3.id])
end
it "supports sorting by published_at in ascending and descending order", :aggregate_failures do
article1 = create(:article)
it "supports sorting by published_at in ascending and descending order with a search term", :aggregate_failures do
article1 = create(:article, tags: "ruby")
article2 = create(:article, tags: "ruby", published_at: 1.week.ago)
article2 = nil
Timecop.travel(1.week.ago) do
article2 = create(:article)
end
results = described_class.search_documents(term: "ruby", sort_by: :published_at, sort_direction: :asc)
expect(results.pluck(:id)).to eq([article2.id, article1.id])
results = described_class.search_documents(term: "ruby", sort_by: :published_at, sort_direction: :desc)
expect(results.pluck(:id)).to eq([article1.id, article2.id])
end
it "supports sorting by published_at in ascending and descending order without a search term",
:aggregate_failures do
article1 = create(:article)
article2 = create(:article, published_at: 1.week.ago)
results = described_class.search_documents(sort_by: :published_at, sort_direction: :asc)
expect(results.pluck(:id)).to eq([article2.id, article1.id])