Use DFS scoring for explicit multi-index search
CI / tests-and-images (pull_request) Successful in 2m14s
Runner smoke test / runner-smoke (pull_request) Successful in 3s

This commit is contained in:
codex committed 2026-11-03 02:20:23 +03:00
1 parent e653f3b50e
commit 1c7ddf2fcb
5 files changed
+169 -24

No files matched your search

+8 -4
View File
@@ -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:
+39
View File
@@ -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()
+14
View File
@@ -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()
+22 -1
View File
@@ -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
контракт ниже: отключение его подключения скрывает только добавленные редакции,
не удаляя подготовленные файлы и базовый индекс.
## Пустые выдачи в лаборатории