Package: elasticsearch-django
This project now requires Python 3.8+ and Django 3.2+.
For previous versions please refer to the relevant tag or branch.Elasticsearch for DjangoThis is a lightweight Django app for people who are using Elasticsearch
with Django, and want to manage their indexes.CompatibilityThe master branch is now based onelasticsearch-py8. If you are
using older versions, please switch to the relevant branch (released on
PyPI as 2.x, 5.x, 6.x).Search Index LifecycleThe basic lifecycle for a search index is simple:Create an indexPost documents to the indexQuery the indexRelating this to our use of search within a Django project it looks like this:Create mapping file for a named indexAdd index configuration to Django settingsMap models to document types in the indexPost document representation of objects to the indexUpdate the index when an object is updatedRemove the document when an object is deletedQuery the indexConvert search results into a QuerySet (preserving relevance)Django ImplementationThis section shows how to set up Django to recognise ES indexes, and the
models that should appear in an index. From this setup you should be
able to run the management commands that will create and populate each
index, and keep the indexes in sync with the database.Create index mapping fileThe prerequisite to configuring Django to work with an index is having
the mapping for the index available. This is a bit chicken-and-egg, but
the underlying assumption is that you are capable of creating the index
mappings outside of Django itself, as raw JSON. (The easiest way to
spoof this is to POST a JSON document representing your document type at
URL on your ES instance (POST http://ELASTICSEARCH_URL/{{index_name}})
and then retrieving the auto-magic mapping that ES created viaGET http://ELASTICSEARCH_URL/{{index_name}}/_mapping.)Once you have the JSON mapping, you should save it in the root of the
Django project assearch/mappings/{{index_name}}.json.Configure Django settingsThe Django settings for search are contained in a dictionary calledSEARCH_SETTINGS, which should be in the maindjango.conf.settingsfile. The dictionary has three root nodes,connections,indexesandsettings. Below is an example:SEARCH_SETTINGS={'connections':{'default':getenv('ELASTICSEARCH_URL'),'backup':{# all Elasticsearch init kwargs can be used here'cloud_id':'{{ cloud_id }}'}},'indexes':{'blog':{'models':['website.BlogPost',]}},'settings':{# batch size for ES bulk api operations'chunk_size':500,# default page size for search results'page_size':25,# set to True to connect post_save/delete signals'auto_sync':True,# List of models which will never auto_sync even if auto_sync is True'never_auto_sync':[],# if true, then indexes must have mapping files'strict_validation':False}}Theconnectionsnode is (hopefully) self-explanatory - we support
multiple connections, but in practice you should only need the one -
'default' connection. This is the URL used to connect to your ES
instance. Thesettingsnode contains site-wide search settings. Theindexesnodes is where we configure how Django and ES play together,
and is where most of the work happens.Note that prior to v8.2 the connection value had to be a connection
string; since v8.2 this can still be a connection string, but can also
be a dictionary that contains any kwarg that can be passed to theElasticsearchinit method.Index settingsInside the index node we have a collection of named indexes - in this
case just the single index calledblog. Inside each index we have amodelskey which contains a list of Django models that should appear
in the index, denoted inapp.ModelNameformat. You can have multiple
models in an index, and a model can appear in multiple indexes. How
models and indexes interact is described in the next section.Configuration ValidationWhen the app boots up it validates the settings, which involves the
following:Do each of the indexes specified have a mapping file?Do each of the models implement the required mixins?Implement search document mixinsSo far we have configured Django to know the names of the indexes we
want, and the models that we want to index. What it doesn't yet know is
which objects to index, and how to convert an object to its search index
document. This is done by implementing two separate mixins -SearchDocumentMixinandSearchDocumentManagerMixin. The
configuration validation routine will tell you if these are not
implemented.SearchDocumentMixinThis mixin is responsible for the seaerch index document format. We are
indexing JSON representations of each object, and we have two methods on
the mixin responsible for outputting the correct format -as_search_documentandas_search_document_update.An aside on the mechanics of theauto_syncprocess, which is hooked up
using Django'spost_saveandpost_deletemodel signals. ES supports
partial updates to documents that already exist, and we make a
fundamental assumption about indexing models - thatif you pass theupdate_fieldskwarg to amodel.savemethod call, then you are
performing a partial update, and this will be propagated to ES as a
partial update only.To this end, we have two methods for generating the model's JSON
representation -as_search_document, which should return a dict that
represents the entire object; andas_search_document_update, which
takes theupdate_fieldskwarg. This method handler two partial update
'strategies', defined in theSEARCH_SETTINGS, 'full' and 'partial'.
The default 'full' strategy simply proxies theas_search_documentmethod - i.e. partial updates are treated as a full document update. The
'partial' strategy is more intelligent - it will map the update_fields
specified to the field names defined in the index mapping files. If a
field name is passed into the save method but is not in the mapping
file, it is ignored. In addition, if the underlying Django model field
is a related object, aValueErrorwill be raised, as we cannot
serialize this automatically. In this scenario, you will need to
override the method in your subclass - see the code for more details.To better understand this, let us say that we have a model (MyModel)
that is configured to be included in an index calledmyindex. If we
save an object, without passingupdate_fields, then this is considered
a full document update, which triggers the object'sindex_search_documentmethod:obj=MyModel.objects.first()obj.save()...# AUTO_SYNC=true will trigger a re-index of the complete object document:obj.index_search_document(index='myindex')However, if we only want to update a single field (say thetimestamp),
and we pass this in to the save method, then this will trigger theupdate_search_documentmethod, passing in the names of the fields that
we want updated.# save a single field on the objectobj.save(update_fields=['timestamp'])...# AUTO_SYNC=true will trigger a partial update of the object documentobj.update_search_document(index,update_fields=['timestamp'])We pass the name of the index being updated as the first arg, as objects may have different representations in different indexes:defas_search_document(self,index):return{'name':"foo"}ifindex=='foo'else{'name':"bar"}In the case of the second method, the simplest possible implementation
would be a dictionary containing the names of the fields being updated
and their new values, and this is the default implementation. If the
fields passed in are simple fields (numbers, dates, strings, etc.) then
a simple{'field_name': getattr(obj, field_name}is returned. However,
if the field name relates to a complex object (e.g. a related object)
then this method will raise anInvalidUpdateFieldsexception. In this
scenario you should override the default implementationwith one of your
own.defas_search_document_update(self,index,update_fields):if'user'inupdate_fields:# remove so that it won't raise a ValueErrorupdate_fields.remove('user')doc=super().as_search_document_update(index,update_fields)doc['user']=self.user.get_full_name()returndocreturnsuper().as_search_document_update(index,update_fields)The reason we have split out the update from the full-document index
comes from a real problem that we ourselves suffered. The full object
representation that we were using was quite DB intensive - we were
storing properties of the model that required walking the ORM tree.
However, because we were also touching the objects (see below) to record
activity timestamps, we ended up flooding the database with queries
simply to update a single field in the output document. Partial updates
solves this issue:deftouch(self):self.timestamp=now()self.save(update_fields=['timestamp'])defas_search_document_update(self,index,update_fields):iflist(update_fields)==['timestamp']:# only propagate changes if it's +1hr since the last timestamp changeifnow()-self.timestamp<timedelta(hours=1):return{}else:return{'timestamp':self.timestamp}....Processing updates asyncIf you are generating a lot of index updates you may want to run them
async (via some kind of queueing mechanism). There is no built-in method
to do this, given the range of queueing libraries and patterns
available, however it is possible using thepre_index,pre_updateandpre_deletesignals. In this case, you should also turn offAUTO_SYNC(as this will run the updates synchronously), and process
the updates yourself. The signals pass in the kwargs required by the
relevant model methods, as well as theinstanceinvolved:# ensure that SEARCH_AUTO_SYNC=Falsefromdjango.dispatchimportreceiverimportdjango_rqfromelasticsearch_django.signalsimport(pre_index,pre_update,pre_delete)queue=django_rq.get_queue("elasticsearch")@receiver(pre_index,dispatch_uid="async_index_document")defindex_search_document_async(sender,**kwargs):"""Queue up search index document update via RQ."""instance=kwargs.pop("instance")queue.enqueue(instance.update_search_document,index=kwargs.pop("index"),)@receiver(pre_update,dispatch_uid="async_update_document")defupdate_search_document_async(sender,**kwargs):"""Queue up search index document update via RQ."""instance=kwargs.pop("instance")queue.enqueue(instance.index_search_document,index=kwargs.pop("index"),update_fields=kwargs.pop("update_fields"),)@receiver(pre_delete,dispatch_uid="async_delete_document")defdelete_search_document_async(sender,**kwargs):"""Queue up search index document deletion via RQ."""instance=kwargs.pop("instance")queue.enqueue(instance.delete_search_document,index=kwargs.pop("index"),)SearchDocumentManagerMixinThis mixin must be implemented by the model's default manager
(objects). It also requires a single method implementation -get_search_queryset()- which returns a queryset of objects that are
to be indexed. This can also use theindexkwarg to provide different
sets of objects to different indexes.defget_search_queryset(self,index='_all'):returnself.get_queryset().filter(foo='bar')We now have the bare bones of our search implementation. We can now use
the included management commands to create and populate our search
index:# create the index 'foo' from the 'foo.json' mapping file$./manage.pycreate_search_indexfoo# populate foo with all the relevant objects$./manage.pyupdate_search_indexfooThe next step is to ensure that our models stay in sync with the index.Add model signal handlers to update indexIf the settingauto_syncis True, then onAppConfig.readyeach model
configured for use in an index has itspost_saveandpost_deletesignals connected. This means that they will be kept in sync across all
indexes that they appear in whenever the relevant model method is
called. (There is some very basic caching to prevent too many updates -
the object document is cached for one minute, and if there is no change
in the document the index update is ignored.)There is aVERY IMPORTANTcaveat to the signal handling. It willonlypick up on changes to the model itself, and not on related
(ForeignKey,ManyToManyField) model changes. If the search document
is affected by such a change then you will need to implement additional
signal handling yourself.In addition toobject.save(), SeachDocumentMixin also provides theupdate_search_index(self, action, index='_all', update_fields=None, force=False)method. Action should be 'index', 'update' or 'delete'.
The difference between 'index' and 'update' is that 'update' is a
partial update that only changes the fields specified, rather than
re-updating the entire document. Ifactionis 'update' whilstupdate_fieldsis None, action will be changed toindex.We now have documents in our search index, kept up to date with their
Django counterparts. We are ready to start querying ES.Search Queries (How to Search)Running search queriesSearchQueryTheelasticsearch_django.models.SearchQuerymodel wraps this
functionality up and provides helper properties, as well as logging the
query:fromelasticsearch_django.settingsimportget_clientfromelasticsearch_django.modelsimportexecute_search# run a default match_all querysq=execute_search(index="foo",query={"match_all":{}})# the raw response is stored on the return object,# but is not stored on the object in the database.print(sq.response)Calling theexecute_searchfunction will execute the underlying
search, log the query JSON, the number of hits, and the list of hit meta
information for future analysis. Theexecutemethod also includes
these additional kwargs:user- the user who is making the query, useful for loggingsearch_terms- the search query supplied by the user (as opposed to
the DSL) - not used by ES, but stored in the logsreference- a free text reference field - used for grouping searches
together - could be session id.save- by default the SearchQuery created will be saved, but passing
in False will prevent this.Converting search hits into Django objectsRunning a search against an index will return a page of results, each
containing the_sourceattribute which is the search document itself
(as created by theSearchDocumentMixin.as_search_documentmethod),
together with meta info about the result - most significantly the
relevancescore, which is the magic value used for ranking
(ordering) results. However, the search document probably doesn't
contain all the of the information that you need to display the result,
so what you really need is a standard Django QuerySet, containing the
objects in the search results, but maintaining the order. This means
injecting the ES score into the queryset, and then using it for
ordering. There is a method on theSearchDocumentManagerMixincalledfrom_search_querywhich will do this for you. It uses raw SQL to add
the score as an annotation to each object in the queryset. (It also adds
the 'rank' - so that even if the score is identical for all hits, the
ordering is preserved.)frommodelsimportBlogPost# run a default match_all querysq=execute_search(index="blog",query={"match_all":{}})forobjinBlogPost.objects.from_search_query(sq):printobj.search_score,obj.search_rank