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.
Fixes: 1) Remove unused imports in `core/elasticsearch/__init__.py`. Extra: Refactor `process_query` for improved readability and functionality; update aggregation and result processing logic; reformat and clean up code.
This commit is contained in:
parent
efd927f4d1
commit
97829d23a6
2 changed files with 129 additions and 81 deletions
|
|
@ -1,12 +1,9 @@
|
||||||
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 import NotFoundError
|
from elasticsearch.dsl import SF, Q, Search
|
||||||
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
|
||||||
|
|
@ -33,19 +30,13 @@ SMART_FIELDS = [
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def process_query(query: str = "", request: Request | None = None):
|
def process_query(
|
||||||
"""
|
query: str = "", request: Request | None = None, filters: dict | None = None
|
||||||
Perform a lenient, typo‑tolerant, multi‑index search.
|
):
|
||||||
|
|
||||||
* Full‑text with fuzziness for spelling mistakes
|
|
||||||
* `bool_prefix` for edge‑ngram autocomplete / “icontains”
|
|
||||||
"""
|
|
||||||
if not query:
|
if not query:
|
||||||
raise ValueError(_("no search term provided."))
|
raise ValueError("no search term provided.")
|
||||||
|
filters = filters or {}
|
||||||
query = query.strip()
|
base_q = Q(
|
||||||
try:
|
|
||||||
q = Q(
|
|
||||||
"bool",
|
"bool",
|
||||||
should=[
|
should=[
|
||||||
Q(
|
Q(
|
||||||
|
|
@ -64,20 +55,46 @@ def process_query(query: str = "", request: Request | None = None):
|
||||||
],
|
],
|
||||||
minimum_should_match=1,
|
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 = (
|
search = (
|
||||||
Search(index=["products", "categories", "brands", "posts"])
|
Search(index=["products", "categories", "brands", "posts"])
|
||||||
.query(q)
|
.query(base_q)
|
||||||
.extra(size=100)
|
.filter(*fq)
|
||||||
|
.extra(size=20)
|
||||||
|
.highlight("description", fragment_size=150)
|
||||||
|
.extra(
|
||||||
|
aggs={
|
||||||
|
"by_category": {"terms": {"field": "category.keyword"}},
|
||||||
|
"by_brand": {"terms": {"field": "brand.keyword"}},
|
||||||
|
"price_stats": {"stats": {"field": "price"}},
|
||||||
|
}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
response = search.execute()
|
response = search.execute()
|
||||||
|
|
||||||
results: dict = {"products": [], "categories": [], "brands": [], "posts": []}
|
results: dict = {"products": [], "categories": [], "brands": [], "posts": []}
|
||||||
for hit in response.hits:
|
for hit in response.hits:
|
||||||
obj_uuid = getattr(hit, "uuid", None) or hit.meta.id
|
obj_uuid = getattr(hit, "uuid", None) or hit.meta.id
|
||||||
obj_name = (
|
obj_name = getattr(hit, "name", None) or getattr(hit, "title", None) or "N/A"
|
||||||
getattr(hit, "name", None) or getattr(hit, "title", None) or "N/A"
|
|
||||||
)
|
|
||||||
obj_slug = ""
|
obj_slug = ""
|
||||||
raw_slug = getattr(hit, "slug", None)
|
raw_slug = getattr(hit, "slug", None)
|
||||||
if raw_slug:
|
if raw_slug:
|
||||||
|
|
@ -112,9 +129,21 @@ def process_query(query: str = "", request: Request | None = None):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return results
|
facets = {
|
||||||
except NotFoundError:
|
"categories": [
|
||||||
raise Http404
|
(b.key, b.doc_count) for b in response.aggregations.by_category.buckets
|
||||||
|
],
|
||||||
|
"brands": [
|
||||||
|
(b.key, b.doc_count) for b in response.aggregations.by_brand.buckets
|
||||||
|
],
|
||||||
|
"price": {
|
||||||
|
"min": response.aggregations.price_stats.min,
|
||||||
|
"max": response.aggregations.price_stats.max,
|
||||||
|
"avg": response.aggregations.price_stats.avg,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"results": results, "facets": facets}
|
||||||
|
|
||||||
|
|
||||||
LANGUAGE_ANALYZER_MAP = {
|
LANGUAGE_ANALYZER_MAP = {
|
||||||
|
|
@ -163,6 +192,8 @@ 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": {
|
||||||
|
|
@ -177,6 +208,10 @@ 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"],
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,12 @@ 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):
|
||||||
|
|
@ -61,6 +67,13 @@ 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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue