Repository: vas3k/vas3k.club

Issue:
Баг: нечёткий поиск слишком уж нечёткий
<!-- WARNING: Игнорирование шаблона может привести к тому, что таску закроют -->

## Чеклист

- [x] Я поискал поиском по трекеру похожие проблемы, в том числе в закрытых Issues
- [x] Баг стабильно воспроизводится и я знаю как это сделать

## Описание бага

Ищем по слову [домен](https://vas3k.club/search/?q=%D0%B4%D0%BE%D0%BC%D0%B5%D0%BD), чтобы найти посты, где люди рассказывают, где купить домен. В результатах все посты про «дом» (54 страницы только в Постах). Посты про домен практически невозможно найти.

Поиск с кавычками даёт тот же результат: ["домен"](https://vas3k.club/search/?q=%22%D0%B4%D0%BE%D0%BC%D0%B5%D0%BD%22).

## Ожидаемый результат

Посты/комментарии, в которых поисковый запрос встречается «как есть», показаны как минимум в самом начале. В идеале нерелевантные посты вообще отсутствуют в выдаче.



Discussion:

Resolving patch:
diff --git a/search/models.py b/search/models.py
--- a/search/models.py
+++ b/search/models.py
@@ -46,16 +46,17 @@ class Meta:
 
     @classmethod
     def search(cls, query):
-        query = SearchQuery(query, config="russian")
+        sq_simple = SearchQuery(query, config="simple", search_type="websearch")
+        sq_stemmed = SearchQuery(query, config="russian", search_type="websearch")
 
         return SearchIndex.objects\
-            .annotate(rank=SearchRank(F("index"), query))\
-            .filter(index=query, rank__gte=0.1)
+            .annotate(rank=SearchRank(F("index"), sq_simple) * 2 + SearchRank(F("index"), sq_stemmed))\
+            .filter(index=sq_simple | sq_stemmed, rank__gte=0.1)
 
     @classmethod
     def update_comment_index(cls, comment):
-        vector = SearchVector("text", weight="B", config="russian") \
-                 + SearchVector("author__slug", weight="C", config="russian")
+        vector = _multi_search_vector("text", weight="B") \
+                 + _multi_search_vector("author__slug", weight="C")
 
         SearchIndex.objects.update_or_create(
             comment=comment,
@@ -73,10 +74,10 @@ def update_comment_index(cls, comment):
 
     @classmethod
     def update_post_index(cls, post):
-        vector = SearchVector("title", weight="A", config="russian") \
-                 + SearchVector("text", weight="B", config="russian") \
-                 + SearchVector("author__slug", weight="C", config="russian") \
-                 + SearchVector("topic__name", weight="C", config="russian")
+        vector = _multi_search_vector("title", weight="A") \
+                 + _multi_search_vector("text", weight="B") \
+                 + _multi_search_vector("author__slug", weight="C") \
+                 + _multi_search_vector("topic__name", weight="C")
 
         if post.is_searchable:
             SearchIndex.objects.update_or_create(
@@ -97,14 +98,14 @@ def update_post_index(cls, post):
 
     @classmethod
     def update_user_index(cls, user):
-        vector = SearchVector("slug", weight="A", config="russian") \
-                 + SearchVector("full_name", weight="A", config="russian") \
-                 + SearchVector("email", weight="A", config="russian") \
-                 + SearchVector("bio", weight="B", config="russian") \
-                 + SearchVector("company", weight="B", config="russian") \
-                 + SearchVector("country", weight="C", config="russian") \
-                 + SearchVector("city", weight="C", config="russian") \
-                 + SearchVector("contact", weight="C", config="russian")
+        vector = _multi_search_vector("slug", weight="A") \
+                 + _multi_search_vector("full_name", weight="A") \
+                 + _multi_search_vector("email", weight="A") \
+                 + _multi_search_vector("bio", weight="B") \
+                 + _multi_search_vector("company", weight="B") \
+                 + _multi_search_vector("country", weight="C") \
+                 + _multi_search_vector("city", weight="C") \
+                 + _multi_search_vector("contact", weight="C")
 
         user_index = User.objects\
             .annotate(vector=vector)\
@@ -113,7 +114,7 @@ def update_user_index(cls, user):
             .first()
 
         intro_index = Post.objects.filter(author=user, type=Post.TYPE_INTRO)\
-            .annotate(vector=SearchVector("text", weight="B", config="russian"))\
+            .annotate(vector=_multi_search_vector("text", weight="B"))\
             .values_list("vector", flat=True)\
             .first()
 
@@ -136,3 +137,10 @@ def update_user_tags(cls, user):
             SearchIndex.objects.filter(user=user).update(
                 tags=list(UserTag.objects.filter(user=user).values_list("tag", flat=True)),
             )
+
+
+def _multi_search_vector(*expressions, weight=None):
+    return (
+        SearchVector(*expressions, weight=weight, config="russian") +
+        SearchVector(*expressions, weight=weight, config="simple")
+    )
diff --git a/users/views/people.py b/users/views/people.py
--- a/users/views/people.py
+++ b/users/views/people.py
@@ -21,7 +21,12 @@ def people(request):
 
     query = request.GET.get("query")
     if query:
-        users = users.filter(index__index=SearchQuery(query, config="russian"))
+        users = users.filter(
+            index__index=(
+                SearchQuery(query, config="simple", search_type="websearch") |
+                SearchQuery(query, config="russian", search_type="websearch")
+            )
+        )
 
     tags = request.GET.getlist("tags")
     if tags:


Test patch:
diff --git a/search/tests.py b/search/tests.py
new file mode 100644
--- /dev/null
+++ b/search/tests.py
@@ -0,0 +1,96 @@
+import re
+from datetime import datetime, timedelta
+
+import django
+from django.test import TestCase
+from django.urls import reverse
+
+from posts.models.post import Post
+from search.models import SearchIndex
+
+django.setup()
+
+from debug.helpers import HelperClient
+from users.models.user import User
+
+
+class SearchViewsTests(TestCase):
+    new_user: User
+    ids_count = 0
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.new_user: User = cls.create_user()
+        cls.create_post(
+            title="Дом дурачок 2023",
+            text="Текст о доме дурачке год 2023. Купил ардуино и радуюсь",
+        )
+        cls.create_post(
+            title="Где лучше купить домен?",
+            text="Домен лучше купить потом",
+        )
+        cls.create_post(
+            title="Очень оригинальный пост",
+            text="В котором нет почти ничего из прошлых",
+        )
+
+    @classmethod
+    def create_post(cls, **kwargs):
+        cls.ids_count += 1
+        post = Post.objects.create(
+            type=Post.TYPE_POST,
+            slug=kwargs.pop("slug", "test_{}".format(cls.ids_count)),
+            title=kwargs.pop("title", "title_{}".format(cls.ids_count)),
+            author=kwargs.pop("author", None) or cls.create_user(),
+            is_visible=True,
+            **kwargs,
+        )
+        SearchIndex.update_post_index(post)
+        return post
+
+    @classmethod
+    def create_user(cls, **kwargs):
+        user = User.objects.create(
+            email=kwargs.pop("email", "testemail{}@xx.com".format(cls.ids_count)),
+            membership_started_at=datetime.now() - timedelta(days=5),
+            membership_expires_at=datetime.now() + timedelta(days=5),
+            slug=kwargs.pop("slug", "slug{}".format(cls.ids_count)),
+            moderation_status=User.MODERATION_STATUS_APPROVED,
+            **kwargs,
+        )
+        SearchIndex.update_user_index(user)
+        return user
+
+    def search(self, q="", content_type=None, ordering="-rank"):
+        data = {"q": q, "ordering": ordering}
+        if content_type:
+            data["type"] = content_type
+        return self.client.get(reverse("search"), data=data)
+
+    def assert_search_results(self, q, expected_re, ordering="-rank", content_type=None, not_expected_re=None):
+        response = self.search(q, content_type=content_type, ordering=ordering)
+        text = response.content.decode()
+        self.assertRegex(text, re.compile(expected_re, re.DOTALL | re.MULTILINE))
+        if not_expected_re:
+            self.assertNotRegex(text, re.compile(not_expected_re, re.DOTALL | re.MULTILINE))
+
+    def setUp(self):
+        self.client = HelperClient(user=self.new_user)
+        self.client.authorise()
+
+    def test_exact_results_must_be_first(self):
+        self.assert_search_results("домен", "купить домен.*?Дом дурачок")
+        self.assert_search_results("дом", "Дом дурачок.*?купить домен")
+        self.assert_search_results("ардуино", "Дом дурачок", not_expected_re="домен|оригинальный")
+
+    def test_stemming_works_too(self):
+        self.assert_search_results("купить", "купить домен.*?Дом дурачок")
+        self.assert_search_results("оригинально", "Очень оригинальный пост", not_expected_re="дурачок|домен")
+        self.assert_search_results("прошлое", "Очень оригинальный пост", not_expected_re="дурачок|домен")
+
+    def test_advanced_syntax(self):
+        self.assert_search_results("купить дом", "купить домен.*?Дом дурачок", not_expected_re="оригинальный")
+        self.assert_search_results('"купить дом"', "купить домен", not_expected_re="Дом дурачок")
+        self.assert_search_results('"оригинальный домен"', "ничего не найдено")
+        self.assert_search_results("оригинальный домен", "ничего не найдено")
+        self.assert_search_results("оригинальный OR домен", "купить домен.*?оригинальный пост")