Use DFS scoring for explicit multi-index search
This commit is contained in:
1 parent
e653f3b50e
commit
1c7ddf2fcb
5 files changed
+169
-24
No files matched your search
@@ -21,7 +21,7 @@ from search.reviews import ReviewSnapshots, ReviewStore
|
||||
|
||||
|
||||
API_VERSION = "v1"
|
||||
SEARCH_ALGORITHM_VERSION = "search-7"
|
||||
SEARCH_ALGORITHM_VERSION = "search-8"
|
||||
LANGUAGES = {"ru", "ky"}
|
||||
CODE = re.compile(r"^[0-9]+$")
|
||||
MAX_PAGE_SIZE = 100
|
||||
@@ -125,9 +125,12 @@ class Api:
|
||||
def search_url(self, suffix: str) -> str:
|
||||
return f"{self.base_url}/{urllib.parse.quote(self.index, safe='')}/{suffix}"
|
||||
|
||||
def query_opensearch(self, body: dict) -> dict:
|
||||
def query_opensearch(self, body: dict, *, search_type: str | None = None) -> dict:
|
||||
try:
|
||||
return request_json(self.search_url("_search"), "POST", json.dumps(body, ensure_ascii=False).encode(), "application/json")
|
||||
url = self.search_url("_search")
|
||||
if search_type is not None:
|
||||
url = f"{url}?{urllib.parse.urlencode({'search_type': search_type})}"
|
||||
return request_json(url, "POST", json.dumps(body, ensure_ascii=False).encode(), "application/json")
|
||||
except RuntimeError as error:
|
||||
raise ApiError(502, "search backend is unavailable") from error
|
||||
|
||||
@@ -177,7 +180,8 @@ class Api:
|
||||
if sort == "date":
|
||||
body["sort"].append({"date_adopted": "desc"})
|
||||
body["sort"].append({"_score": "desc"})
|
||||
response = self.query_opensearch(body)
|
||||
search_type = "dfs_query_then_fetch" if "," in self.index else None
|
||||
response = self.query_opensearch(body, search_type=search_type)
|
||||
try:
|
||||
hits = response["hits"]["hits"]
|
||||
except (KeyError, TypeError) as error:
|
||||
|
||||
@@ -3,6 +3,7 @@ import contextlib
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
@@ -190,6 +191,44 @@ class SearchApiTest(unittest.TestCase):
|
||||
self.assertIn("legal_force_code", body["aggs"]["legal_forces"]["terms"]["field"])
|
||||
self.assertEqual(body["query"], {"term": {"is_current_edition": True}})
|
||||
|
||||
def test_global_dfs_is_only_sent_for_explicit_multi_index_searches(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
multi = Api("http://opensearch:9200", "base-v2,supplement-v1", Path(temporary), review_secret=b"test-secret")
|
||||
alias = Api("http://opensearch:9200", "current", Path(temporary), review_secret=b"test-secret")
|
||||
single = Api("http://opensearch:9200", "single-v2", Path(temporary), review_secret=b"test-secret")
|
||||
one_hit = {"hits": {"hits": [{"_index": "base-v2", "_source": {"document_code": "5", "document_name_ru": "Кодекс", "document_name_ky": "Кодекс"}}]}}
|
||||
empty = {"hits": {"hits": []}}
|
||||
alias_hit = {"hits": {"hits": [{"_index": "current-20261103", "_source": {"document_code": "7", "document_name_ru": "Закон"}}]}}
|
||||
aggregations = {"aggregations": {name: {"buckets": []} for name in ("document_types", "statuses", "authorities", "legal_forces")}}
|
||||
with patch("search.api.request_json", side_effect=[one_hit, empty, alias_hit, one_hit, aggregations]) as request:
|
||||
multi_ru = multi.search({"q": ["test"], "language": ["ru"], "page_size": ["10"]})
|
||||
multi_ky_empty = multi.search({"q": ["test"], "language": ["ky"], "page_size": ["10"]})
|
||||
alias_ru = alias.search({"q": ["test"], "language": ["ru"], "page_size": ["10"]})
|
||||
single_ky = single.search({"q": ["test"], "language": ["ky"], "page_size": ["10"]})
|
||||
alias.filters({"language": ["ru"]})
|
||||
|
||||
calls = request.call_args_list
|
||||
multi_ru_url = urllib.parse.urlsplit(calls[0].args[0])
|
||||
multi_ky_url = urllib.parse.urlsplit(calls[1].args[0])
|
||||
self.assertEqual(multi_ru_url.path, "/base-v2%2Csupplement-v1/_search")
|
||||
self.assertEqual(urllib.parse.parse_qs(multi_ru_url.query), {"search_type": ["dfs_query_then_fetch"]})
|
||||
self.assertEqual(urllib.parse.parse_qs(multi_ky_url.query), {"search_type": ["dfs_query_then_fetch"]})
|
||||
self.assertEqual([item["code"] for item in multi_ru["results"]], ["5"])
|
||||
self.assertEqual(multi_ky_empty["results"], [])
|
||||
self.assertEqual(multi.review_snapshots.verify(multi_ru["review_token"])["index_name"], "base-v2,supplement-v1")
|
||||
self.assertEqual(multi.review_snapshots.verify(multi_ky_empty["review_token"])["index_name"], "base-v2,supplement-v1")
|
||||
|
||||
for call in calls[2:]:
|
||||
parsed = urllib.parse.urlsplit(call.args[0])
|
||||
self.assertEqual(parsed.query, "")
|
||||
self.assertEqual(alias.review_snapshots.verify(alias_ru["review_token"])["index_name"], "current-20261103")
|
||||
self.assertEqual(single.review_snapshots.verify(single_ky["review_token"])["index_name"], "base-v2")
|
||||
multi_body = json.loads(calls[0].args[2])
|
||||
alias_body = json.loads(calls[2].args[2])
|
||||
self.assertEqual(multi_body, alias_body)
|
||||
self.assertNotIn("search_type", multi_body)
|
||||
self.assertEqual(json.loads(calls[1].args[2])["query"]["bool"]["filter"][0], {"term": {"language": "ky"}})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -47,5 +47,19 @@ class SearchRankingOpenSearchTest(unittest.TestCase):
|
||||
self.assertEqual(result["results"][0]["code"], "5")
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
os.getenv("AKYLDASH_OPENSEARCH_URL") and "," in os.getenv("AKYLDASH_OPENSEARCH_INDEX", ""),
|
||||
"set AKYLDASH_OPENSEARCH_URL and a comma-separated AKYLDASH_OPENSEARCH_INDEX for read-only multi-index search",
|
||||
)
|
||||
class SearchMultiIndexDfsOpenSearchTest(unittest.TestCase):
|
||||
def test_global_statistics_mode_accepts_read_only_multi_index_search(self):
|
||||
base_url = os.environ["AKYLDASH_OPENSEARCH_URL"].rstrip("/")
|
||||
index = os.environ["AKYLDASH_OPENSEARCH_INDEX"]
|
||||
api = Api(base_url, index, Path("."))
|
||||
result = api.search({"q": ["расторжение договора аренды"], "language": ["ru"], "page_size": ["10"]})
|
||||
self.assertIsInstance(result["results"], list)
|
||||
self.assertEqual(api.review_snapshots.verify(result["review_token"])["index_name"], index)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -3,7 +3,7 @@ import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from search.api import Api, ApiError
|
||||
from search.api import Api, ApiError, SEARCH_ALGORITHM_VERSION
|
||||
|
||||
|
||||
class SearchReviewTest(unittest.TestCase):
|
||||
@@ -148,6 +148,27 @@ class SearchReviewTest(unittest.TestCase):
|
||||
self.assertNotIn('id="error"', page)
|
||||
self.assertIn("localStorage", page)
|
||||
|
||||
def test_new_search_snapshots_use_search_8_without_invalidating_search_7(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
|
||||
with patch("search.api.request_json", return_value={"hits": {"hits": [{"_index": "current-v2", "_source": {"document_code": "7", "document_name_ru": "Закон"}}]}}):
|
||||
result = api.handle("GET", "/search?q=test&language=ru")[1]
|
||||
current = api.review_snapshots.verify(result["review_token"])
|
||||
self.assertEqual(SEARCH_ALGORITHM_VERSION, "search-8")
|
||||
self.assertEqual(current["algorithm_version"], "search-8")
|
||||
|
||||
legacy_token = api.review_snapshots.create(
|
||||
"legacy query", "ru", "current-v1", [{"rank": 1, "code": "7"}], "search-7"
|
||||
)
|
||||
legacy_saved = api.handle("POST", "/search-reviews", {
|
||||
"review_token": legacy_token,
|
||||
"reviewer": "Юрист",
|
||||
"results": [{"rank": 1, "code": "7", "rating": 3}],
|
||||
})
|
||||
self.assertEqual(legacy_saved[0], 201)
|
||||
exported = api.handle("GET", "/search-reviews/export")[1]["reviews"]
|
||||
self.assertEqual(exported[-1]["algorithm_version"], "search-7")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -3,13 +3,15 @@
|
||||
## Решения и границы
|
||||
|
||||
Эта заметка фиксирует исходные измерения от 2 ноября, узкое исправление
|
||||
терминологии и контракт для закрытия пробела корпуса. Она не утверждает, что
|
||||
поиск уже стал юридически релевантнее: новая выдача должна быть проверена
|
||||
отдельно на изолированном стенде и сравнена с исходными снимками. Полный набор
|
||||
из 7 500 запросов повторно не собирался.
|
||||
терминологии, условный multi-index scoring и контракт для закрытия пробела
|
||||
корпуса. Выдачи и AI-аннотации не являются юридической оценкой. Целевой A/B
|
||||
прогон и полный benchmark для итогового SHA описаны ниже; они не заменяют
|
||||
экспертную оценку.
|
||||
|
||||
Изменён backend `0.13.3` → `0.13.4`, frontend внутренней лаборатории
|
||||
`0.3.3` → `0.3.4`, версия алгоритма `search-6` → `search-7`.
|
||||
`0.3.3` → `0.3.4`, версия алгоритма `search-6` → `search-8`. `search-7`
|
||||
относится к предшествующему составу ranking semantics; новые подписанные
|
||||
снимки этого PR помечаются `search-8`. Сохранённые отзывы не переписываются.
|
||||
|
||||
## Исходный benchmark
|
||||
|
||||
@@ -74,23 +76,88 @@ Backend теперь сохраняет исходную строку и доб
|
||||
сочетания «договор [арендная форма]» вариант «договор [та же форма]
|
||||
имущественного найма». В частности, `договора аренды` превращается в
|
||||
`договора имущественного найма`. Расширение не срабатывает для аренды квартиры
|
||||
или земельного участка, поднайма, найма работника или лизинга. Точный запрос остаётся в строгой
|
||||
лексической ветке, поэтому его профильный муниципальный акт не выкидывается из
|
||||
поиска. Фактический top-k после изменения и влияние на соседние запросы требуют
|
||||
отдельного A/B прогона; кодовый тест проверяет только варианты и DSL.
|
||||
или земельного участка, поднайма, найма работника или лизинга. Точный запрос
|
||||
остаётся в строгой лексической ветке, поэтому его профильный муниципальный акт
|
||||
не исключается из поиска.
|
||||
|
||||
В read-only пробе предыдущей версии `aa6fabe` для общего запроса код 5 поднялся
|
||||
с позиции за пределами top 10 на #5, но нерелевантный индивидуальный акт 135066
|
||||
оставался первым. Поэтому новая ревизия усиливает только `match_phrase` для
|
||||
синтетического варианта с юридическим термином (`boost=12`); исходная фраза
|
||||
оставляет исходный вес 3. Это проверяемая гипотеза, а не подтверждённая починка:
|
||||
текущий commit нужно повторно прогнать на изолированном индексе. Критерий
|
||||
приёмки: для общего запроса код 5 — top 1; для явной формулировки «имущественный
|
||||
наём» он остаётся top 1; для запросов о жилом помещении, земельном участке,
|
||||
поднайме, трудовых отношениях, международном арбитраже и кыргызских запросов
|
||||
расширение отсутствует, а контрольная выдача не ухудшается относительно
|
||||
baseline. Вес не должен подниматься автоматически, если этот критерий не
|
||||
выполняется.
|
||||
оставался первым. На SHA `726b1ee` isolated code-only прогон на исходном индексе
|
||||
поставил код 5 первым для общего запроса; из 7 500 запросов изменились 19
|
||||
выдач, 7 481 совпала с baseline, число пустых осталось 6. Этот результат
|
||||
проверяет `search-7` с `match_phrase` boost 12 для синтетической юридической
|
||||
фразы, исходный phrase boost 3; он не описывает multi-index DFS. После
|
||||
подключения трёх актов в отдельном supplement на `e653f3b` изменились 184
|
||||
выдачи против code-only варианта и 38 top-1; все шесть пустых выдач
|
||||
сохранились. Разные наборы индексов нельзя считать одним benchmark. Для
|
||||
итогового conditional-DFS SHA нужен отдельный прогон; контроль запросов об
|
||||
аренде жилья/земли, поднайме, трудовых отношениях, международном праве и KY
|
||||
формулировках остаётся обязательным. Rank-метрики не являются юридической
|
||||
оценкой.
|
||||
|
||||
## Условный scoring для явного multi-index scope
|
||||
|
||||
При поиске по явно настроенному comma-separated scope (`base,supplement`) API
|
||||
передаёт `search_type=dfs_query_then_fetch` как URL query parameter. Запросы по
|
||||
одному настроенному индексу или alias сохраняют прежний `query_then_fetch`;
|
||||
одиночные wildcard и alias не классифицируются по результату их expansion.
|
||||
DFS включается только при запятой в настроенной строке scope.
|
||||
Тело DSL, фильтры, query weights, сортировка, индексы и документы не меняются.
|
||||
Параметр применяется только к `/search`; фильтры и остальные API вызовы
|
||||
используют прежний режим.
|
||||
|
||||
OpenSearch описывает `query_then_fetch` как scoring по локальным term/document
|
||||
frequencies шардов, а DFS — по глобальным frequencies через шарды; DFS обычно
|
||||
точнее, но может быть медленнее. `search_type` поддерживается именно как
|
||||
параметр Search API URL ([OpenSearch Search API](https://docs.opensearch.org/latest/api-reference/search-apis/search/)).
|
||||
Поэтому ограниченный режим использует глобальные statistics для явной
|
||||
multi-index конфигурации и не меняет behavior одиночного поиска.
|
||||
|
||||
На изолированном OpenSearch 3.7.0 оба режима сравнили на одинаковых DSL bytes и
|
||||
одном неизменном наборе данных: 300 заранее выбранных запросов, максимум 300.
|
||||
Порядок менялся в 93 случаях, top-1 — в 29; из 9 именованных риск-кейсов
|
||||
изменились 3, все 5 выбранных positive controls сохранили top-1. Контроль с
|
||||
индивидуальным актом 135066 сохранил этот акт первым. Результат по пяти
|
||||
педагогическим/научным запросам с медицинским актом на первом месте и по
|
||||
запросу о болезнях животных не исправлен; это межтематические lexical overlaps,
|
||||
а не основание вводить глобальные веса или специальные document ID.
|
||||
Сравнение ограничено выборкой и не является разметкой юридической правильности.
|
||||
Медианы 22,955 мс для default и 16,115 мс для DFS не используются как оценка
|
||||
скорости: default всегда исполнялся первым, возможен cache/order bias.
|
||||
|
||||
Frozen experiment metadata is stored outside the repository in
|
||||
`reports/search-benchmark-2026-11-02/dfs-probe-e653-summary.json`. The paired
|
||||
response-capture artifact `dfs-probe-e653.jsonl` has SHA-256
|
||||
`4a9373ba5236c5c5a1d85139b6dbe67bcf01df02216f8b2f292e5c451e6432c8`.
|
||||
Выборка содержала base index (42 624 501 fragments) и read-only supplement
|
||||
(8 934 fragments); UUID/count guards прошли, основной индекс и сохранённые
|
||||
отзывы во время эксперимента не менялись. В PR не включены сырые ответы,
|
||||
персональные данные или выдачи документов.
|
||||
|
||||
Кодовый тест проверяет URL, encoded multi-index path, RU/KY, пустую выдачу,
|
||||
прежнее тело запроса и неизменность single alias/index и filters. Read-only
|
||||
integration gate для root-owned runtime:
|
||||
|
||||
```bash
|
||||
AKYLDASH_OPENSEARCH_URL=http://127.0.0.1:8081 \
|
||||
AKYLDASH_OPENSEARCH_INDEX='akyldash-fragments-v2,akyldash-benchmark-supplement-20261103-r1' \
|
||||
PYTHONPATH=backend python3 -m unittest \
|
||||
backend.test_search_api_opensearch.SearchMultiIndexDfsOpenSearchTest.test_global_statistics_mode_accepts_read_only_multi_index_search -v
|
||||
```
|
||||
|
||||
Тест выполняет только поиск и проверяет подписанный scope; он не создаёт и не
|
||||
удаляет индексы. Root запускает его только при подтверждении, что указанный
|
||||
supplement ещё существует и доступен на read-only runtime. Полный benchmark на
|
||||
7 500 запросах нужен для итогового pinned SHA с тем же frozen baseline.
|
||||
300-query эксперимент и предыдущие benchmarks не следует выдавать за
|
||||
результат нового SHA.
|
||||
|
||||
Откат к предшествующему приложению возвращает `query_then_fetch` для multi-index
|
||||
поиска. Индекс, документы, saved reviews и старые подписанные токены при этом
|
||||
не требуют изменений или миграции; новые отзывы, сохранённые до rollback,
|
||||
остаются помечены `search-8`. Для supplement действует отдельный rollback
|
||||
контракт ниже: отключение его подключения скрывает только добавленные редакции,
|
||||
не удаляя подготовленные файлы и базовый индекс.
|
||||
|
||||
## Пустые выдачи в лаборатории
|
||||
|
||||
|
||||
Reference in new issue
Block a user