Revert "Features: 1) Add suggest, price, and quantity fields to ProductDocument; 2) Introduce prepare_suggest method for enhancing autocomplete functionality; 3) Enhance search with filters, function scoring, and result aggregation; 4) Add new analyzers and token filters for synonyms and stopwords."

This reverts commit 97829d23a6.
This commit is contained in:
Egor Pavlovich Gorbunov 2025-06-20 05:12:11 +03:00
parent 352b5f14ef
commit 41b6c1aa07
2 changed files with 81 additions and 129 deletions

View file

@ -1,9 +1,12 @@
from django.conf import settings from django.conf import settings
from django.http import Http404
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from django_elasticsearch_dsl import fields from django_elasticsearch_dsl import fields
from django_elasticsearch_dsl.registries import registry from django_elasticsearch_dsl.registries import registry
from elasticsearch.dsl import SF, Q, Search from elasticsearch import NotFoundError
from elasticsearch.dsl import Q, Search
from rest_framework.request import Request from rest_framework.request import Request
from core.models import Brand, Category, Product from core.models import Brand, Category, Product
@ -30,120 +33,88 @@ SMART_FIELDS = [
] ]
def process_query( def process_query(query: str = "", request: Request | None = None):
query: str = "", request: Request | None = None, filters: dict | None = None """
): Perform a lenient, typotolerant, multiindex search.
* Fulltext with fuzziness for spelling mistakes
* `bool_prefix` for edgengram autocomplete / icontains
"""
if not query: if not query:
raise ValueError("no search term provided.") raise ValueError(_("no search term provided."))
filters = filters or {}
base_q = Q(
"bool",
should=[
Q(
"multi_match",
query=query,
fields=SMART_FIELDS,
fuzziness="AUTO",
operator="and",
),
Q(
"multi_match",
query=query,
fields=[f for f in SMART_FIELDS if f.endswith(".auto")],
type="bool_prefix",
),
],
minimum_should_match=1,
)
functions = [
SF("field_value_factor", field="quantity", modifier="log1p", missing=0),
SF("field_value_factor", field="rating", modifier="sqrt", missing=0),
SF("exp", field="created_at", origin="now", scale="30d"), # newness boost
]
fq = [
Q("function_score", query=base_q, functions=functions, boost_mode="sum"),
]
if "category" in filters:
fq.append(Q("term", category__slug=filters["category"]))
if "brand" in filters:
fq.append(Q("term", brand__slug=filters["brand"]))
if "price_min" in filters or "price_max" in filters:
range_q = {}
if "price_min" in filters:
range_q["gte"] = filters["price_min"]
if "price_max" in filters:
range_q["lte"] = filters["price_max"]
fq.append(Q("range", price=range_q))
search = ( query = query.strip()
Search(index=["products", "categories", "brands", "posts"]) try:
.query(base_q) q = Q(
.filter(*fq) "bool",
.extra(size=20) should=[
.highlight("description", fragment_size=150) Q(
.extra( "multi_match",
aggs={ query=query,
"by_category": {"terms": {"field": "category.keyword"}}, fields=SMART_FIELDS,
"by_brand": {"terms": {"field": "brand.keyword"}}, fuzziness="AUTO",
"price_stats": {"stats": {"field": "price"}}, operator="and",
} ),
) Q(
) "multi_match",
response = search.execute() query=query,
fields=[f for f in SMART_FIELDS if f.endswith(".auto")],
results: dict = {"products": [], "categories": [], "brands": [], "posts": []} type="bool_prefix",
for hit in response.hits: ),
obj_uuid = getattr(hit, "uuid", None) or hit.meta.id ],
obj_name = getattr(hit, "name", None) or getattr(hit, "title", None) or "N/A" minimum_should_match=1,
obj_slug = ""
raw_slug = getattr(hit, "slug", None)
if raw_slug:
obj_slug = raw_slug
elif hit.meta.index == "brands":
obj_slug = slugify(obj_name)
elif hit.meta.index == "categories":
obj_slug = slugify(f"{obj_name}")
image_url = None
idx = hit.meta.index
if idx == "products" and request:
prod = get_object_or_404(Product, uuid=obj_uuid)
first = prod.images.order_by("priority").first()
if first and first.image:
image_url = request.build_absolute_uri(first.image.url)
elif idx == "brands" and request:
brand = get_object_or_404(Brand, uuid=obj_uuid)
if brand.small_logo:
image_url = request.build_absolute_uri(brand.small_logo.url)
elif idx == "categories" and request:
cat = get_object_or_404(Category, uuid=obj_uuid)
if cat.image:
image_url = request.build_absolute_uri(cat.image.url)
results[idx].append(
{
"uuid": str(obj_uuid),
"name": obj_name,
"slug": obj_slug,
"image": image_url,
}
) )
facets = { search = (
"categories": [ Search(index=["products", "categories", "brands", "posts"])
(b.key, b.doc_count) for b in response.aggregations.by_category.buckets .query(q)
], .extra(size=100)
"brands": [ )
(b.key, b.doc_count) for b in response.aggregations.by_brand.buckets response = search.execute()
],
"price": {
"min": response.aggregations.price_stats.min,
"max": response.aggregations.price_stats.max,
"avg": response.aggregations.price_stats.avg,
},
}
return {"results": results, "facets": facets} results: dict = {"products": [], "categories": [], "brands": [], "posts": []}
for hit in response.hits:
obj_uuid = getattr(hit, "uuid", None) or hit.meta.id
obj_name = (
getattr(hit, "name", None) or getattr(hit, "title", None) or "N/A"
)
obj_slug = ""
raw_slug = getattr(hit, "slug", None)
if raw_slug:
obj_slug = raw_slug
elif hit.meta.index == "brands":
obj_slug = slugify(obj_name)
elif hit.meta.index == "categories":
obj_slug = slugify(f"{obj_name}")
image_url = None
idx = hit.meta.index
if idx == "products" and request:
prod = get_object_or_404(Product, uuid=obj_uuid)
first = prod.images.order_by("priority").first()
if first and first.image:
image_url = request.build_absolute_uri(first.image.url)
elif idx == "brands" and request:
brand = get_object_or_404(Brand, uuid=obj_uuid)
if brand.small_logo:
image_url = request.build_absolute_uri(brand.small_logo.url)
elif idx == "categories" and request:
cat = get_object_or_404(Category, uuid=obj_uuid)
if cat.image:
image_url = request.build_absolute_uri(cat.image.url)
results[idx].append(
{
"uuid": str(obj_uuid),
"name": obj_name,
"slug": obj_slug,
"image": image_url,
}
)
return results
except NotFoundError:
raise Http404
LANGUAGE_ANALYZER_MAP = { LANGUAGE_ANALYZER_MAP = {
@ -192,8 +163,6 @@ COMMON_ANALYSIS = {
"encoder": "double_metaphone", "encoder": "double_metaphone",
"replace": False, "replace": False,
}, },
"synonym_filter": {"type": "synonym", "synonyms_path": "analysis/synonyms.txt"},
"english_stop": {"type": "stop", "stopwords": "_english_"},
}, },
"analyzer": { "analyzer": {
"autocomplete": { "autocomplete": {
@ -208,10 +177,6 @@ COMMON_ANALYSIS = {
"tokenizer": "standard", "tokenizer": "standard",
"filter": ["lowercase", "asciifolding", "ngram_filter"], "filter": ["lowercase", "asciifolding", "ngram_filter"],
}, },
"synonym_analyzer": {
"tokenizer": "standard",
"filter": ["lowercase", "asciifolding", "synonym_filter"],
},
"name_phonetic": { "name_phonetic": {
"tokenizer": "standard", "tokenizer": "standard",
"filter": ["lowercase", "asciifolding", "double_metaphone"], "filter": ["lowercase", "asciifolding", "double_metaphone"],

View file

@ -52,12 +52,6 @@ class _BaseDoc(ActiveOnlyMixin, Document):
class ProductDocument(_BaseDoc): class ProductDocument(_BaseDoc):
suggest = fields.CompletionField(
analyzer="autocomplete",
search_analyzer="autocomplete_search",
)
price = fields.FloatField(attr="price")
quantity = fields.IntegerField(attr="quantity")
rating = fields.FloatField(attr="rating") rating = fields.FloatField(attr="rating")
class Index(_BaseDoc.Index): class Index(_BaseDoc.Index):
@ -67,13 +61,6 @@ class ProductDocument(_BaseDoc):
model = Product model = Product
fields = ["uuid"] fields = ["uuid"]
def prepare_suggest(self, instance):
terms = [instance.name]
if instance.brand:
terms.append(instance.brand.name)
terms.append(instance.category.name)
return {"input": terms, "weight": int(instance.quantity)}
_add_multilang_fields(ProductDocument) _add_multilang_fields(ProductDocument)
registry.register_document(ProductDocument) registry.register_document(ProductDocument)