Repository: MongoEngine/mongoengine

Issue:
Text Search returns documents in different order when called multiple times
I have a user collection with a text index of the format,

```
{
    v: 1,
    key: { _fts: 'text', _ftsx: 1 },
    name: 'address.addressLine2_text_address.addressLine1_text_profile.email_text',
    ns: 'db.user',
    weights: {
      'profile.email': 1,
      'address.addressLine1': 1,
      'address.addressLine2': 1
    },
    default_language: 'english',
    language_override: 'language',
    textIndexVersion: 3
}
```

Using the `search_text()` on this index returns the correct set of docs but the **ordering** changes every time the method is called.
Ex: `User.objects.search_text("foo")`

Running a raw text search query with the same parameters returns the result in the same order every time.
Ex: `User.objects((__raw__={'$text': {'$search': "foo"}})`

The internal query being run by both these methods seem to be the same. 

Why is the behaviour **inconsistent** in case of `search_text()`?

This is particularly surprising, if the number of returned docs are limited resulting in completely different set of docs being returned from both the queries


Discussion:
Tried to quickly reproduce it but I couldn't. Could you provide a minimal reproducible snippet? At first glance I can see a difference between the 2 queries, in the sense that one is using SON

![image](https://github.com/MongoEngine/mongoengine/assets/11854690/c3d2f402-ca56-4694-a3d3-bf6a5d12e71b)

What should be the structure of the reproducible snippet?

I went through the Git history and found this commit [fixed a order in command](https://github.com/MongoEngine/mongoengine/pull/700/commits/a743b75bb4ffa1220aeb519199319dba58d4e4ea) which explicitly adds the SON. May be this could help?

P.S - I am using, mongoengine - 0.24.2, pymongo - 3.12.1 and MongoDB Atlas Version 4.4.26
A reprocible snippet is a minimal code snippet that demonstrates the issue (minimal model definition, no FLask , etc), more or less like a unit test. I couldn't reproduce your issue so far so it's not clear if the casting to SON is the issue. 

A reproducible snippet is something like

```
from mongoengine import *

connect()    # assuming you have a running local mongo on default port (without authentication)

class News(Document):
    title = StringField()
    content = StringField()
    is_active = BooleanField(default=True)

    meta = {
        "indexes": [
            {
                "fields": ["$title", "$content"],
                "default_language": "portuguese",
                "weights": {"title": 10, "content": 2},
            }
        ]
    }

result1 = list(News.objects.search_text('brasil'))
result2 = list(News.objects(__raw__={'$text': {'$search': "brasil"}}))

assert result1 == result2    # whatever you want to prove and is reproducible
```
if the issue is not always reproducible, make it a for loop with enough iteration to have it failing
Thanks for the detailed explanation. I have created a snippet along with a sample json dataset.

Please run `populate_initial_data()` once, to load the sample data into your db.

Demo Files - [issue_2759_demo.zip](https://github.com/MongoEngine/mongoengine/files/13877959/issue_2759_demo.zip)


Resolving patch:
diff --git a/mongoengine/base/document.py b/mongoengine/base/document.py
--- a/mongoengine/base/document.py
+++ b/mongoengine/base/document.py
@@ -322,7 +322,7 @@ def get_text_score(self):
 
         if "_text_score" not in self._data:
             raise InvalidDocumentError(
-                "This document is not originally built from a text query"
+                "This document is not originally built from a text query (or text_score was not set on search_text() call)"
             )
 
         return self._data["_text_score"]
diff --git a/mongoengine/queryset/base.py b/mongoengine/queryset/base.py
--- a/mongoengine/queryset/base.py
+++ b/mongoengine/queryset/base.py
@@ -71,6 +71,7 @@ def __init__(self, document, collection):
         self._none = False
         self._as_pymongo = False
         self._search_text = None
+        self._search_text_score = None
 
         self.__dereference = False
         self.__auto_dereference = True
@@ -229,7 +230,7 @@ def filter(self, *q_objs, **query):
         """An alias of :meth:`~mongoengine.queryset.QuerySet.__call__`"""
         return self.__call__(*q_objs, **query)
 
-    def search_text(self, text, language=None):
+    def search_text(self, text, language=None, text_score=True):
         """
         Start a text search, using text indexes.
         Require: MongoDB server version 2.6+.
@@ -239,6 +240,8 @@ def search_text(self, text, language=None):
             If not specified, the search uses the default language of the index.
             For supported languages, see
             `Text Search Languages <https://docs.mongodb.org/manual/reference/text-search-languages/#text-search-languages>`.
+        :param text_score:  True to have it return the text_score (available through get_text_score()), False to disable that
+            Note that unless you order the results, leaving text_score=True may provide randomness in the returned documents
         """
         queryset = self.clone()
         if queryset._search_text:
@@ -252,6 +255,7 @@ def search_text(self, text, language=None):
         queryset._mongo_query = None
         queryset._cursor_obj = None
         queryset._search_text = text
+        queryset._search_text_score = text_score
 
         return queryset
 
@@ -833,6 +837,7 @@ def _clone_into(self, new_qs):
             "_hint",
             "_collation",
             "_search_text",
+            "_search_text_score",
             "_max_time_ms",
             "_comment",
             "_batch_size",
@@ -1673,7 +1678,8 @@ def _cursor_args(self):
             if fields_name not in cursor_args:
                 cursor_args[fields_name] = {}
 
-            cursor_args[fields_name]["_text_score"] = {"$meta": "textScore"}
+            if self._search_text_score:
+                cursor_args[fields_name]["_text_score"] = {"$meta": "textScore"}
 
         return cursor_args
 


Test patch:
diff --git a/tests/queryset/test_queryset.py b/tests/queryset/test_queryset.py
--- a/tests/queryset/test_queryset.py
+++ b/tests/queryset/test_queryset.py
@@ -3692,7 +3692,7 @@ class News(Document):
                 "t2", language="french"
             )
 
-    def test_text_indexes(self):
+    def test_search_text(self):
         class News(Document):
             title = StringField()
             content = StringField()
@@ -3751,14 +3751,16 @@ class News(Document):
         assert "dilma" in new.content
         assert "planejamento" in new.title
 
-        query = News.objects.search_text("candidata")
+        query = News.objects.search_text("candidata", text_score=True)
         assert query._search_text == "candidata"
         new = query.first()
 
         assert isinstance(new.get_text_score(), float)
 
         # count
-        query = News.objects.search_text("brasil").order_by("$text_score")
+        query = News.objects.search_text("brasil", text_score=True).order_by(
+            "$text_score"
+        )
         assert query._search_text == "brasil"
 
         assert query.count() == 3
@@ -3778,6 +3780,13 @@ class News(Document):
         item = News.objects.search_text("brasil").order_by("$text_score").first()
         assert item.get_text_score() == max_text_score
 
+        # Verify query reproducibility when text_score is disabled
+        # Following wouldn't work for text_score=True  #2759
+        for i in range(10):
+            qs1 = News.objects.search_text("brasil", text_score=False)
+            qs2 = News.objects.search_text("brasil", text_score=False)
+            assert list(qs1) == list(qs2)
+
     def test_distinct_handles_references_to_alias(self):
         register_connection("testdb", "mongoenginetest2")