Compare commits

..

13 Commits

Author SHA1 Message Date
72de364f29 docs: update changelog for search fix 2026-08-20 15:13:20 +03:00
74873451ab fix: match explicit company registration intents 2026-08-20 15:12:07 +03:00
2faac7b74e fix: narrow company registration intent 2026-08-20 15:10:32 +03:00
e3f08426d5 fix: expose curated search query 2026-08-20 15:08:42 +03:00
dc5399de0a fix: rank company registration queries 2026-08-20 15:04:55 +03:00
8f0f0e3fbf Merge pull request 'Улучшить ранжирование поиска по relevance set' (#13) from feature/search-relevance-ranking into main
Reviewed-on: #13
2026-08-18 08:35:10 +00:00
9ef8b099df docs: update changelog 2026-08-18 08:56:18 +03:00
9318474a54 docs: preserve status history 2026-08-18 08:55:12 +03:00
d701f714e1 fix: improve search relevance ranking 2026-08-18 08:51:22 +03:00
575f4fa3af Merge pull request 'Добавить инструкцию по разметке relevance set' (#12) from docs/relevance-annotation-guide into main
Reviewed-on: #12
2026-08-16 06:21:36 +00:00
e6c4848a7f docs: update changelog 2026-08-16 09:13:05 +03:00
0a72d0d242 docs: add relevance annotation guide 2026-08-16 09:10:36 +03:00
d4ee8fac08 Merge pull request 'Добавить baseline оценки релевантности поиска' (#11) from feature/search-relevance-baseline into main
Reviewed-on: #11
2026-08-15 04:04:55 +00:00
19 changed files with 283 additions and 53 deletions

View File

@@ -1,5 +1,16 @@
# История изменений
## Не выпущено
- Исправлена выдача для явных запросов об открытии ОсОО и ЖЧК: первыми
показываются действующие положение о регистрации и закон о хозяйственных
товариществах и обществах.
- Добавлен CLI `python3 -m search.query` для проверки текущей выдачи локального
OpenSearch.
- Добавлена инструкция по подготовке и независимой проверке relevance set.
- Улучшено ранжирование relevance-оценки: запрос теперь сопоставляет название и
текст документа как единое поле.
## 0.5.0 — 2026-08-15
- Добавлена воспроизводимая оценка качества поиска по Recall@10 и MRR@10.

View File

@@ -10,15 +10,15 @@ Telegram-бот — только часть рабочего окружения
## Текущее состояние
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
`0.5.0`: подготовка поискового индекса и оценка Recall@K/MRR@K на вручную
`0.5.2`: подготовка поискового индекса и оценка Recall@K/MRR@K на вручную
размеченном наборе запросов.
| Компонент | Версия | Состояние |
|---|---:|---|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
| Backend | `0.5.0` | добавлена воспроизводимая оценка качества поиска |
| Backend | `0.5.2` | добавлено ранжирование подтверждённых запросов об открытии ОсОО/ЖЧК |
| Frontend | — | ещё не создан |
| Сбор и обработка правовых данных | `0.5.0` | добавлены relevance set и baseline-метрики |
| Сбор и обработка правовых данных | `0.5.2` | добавлены relevance set и baseline-метрики |
| RAG и база знаний | — | ещё не созданы |
## Структура репозитория
@@ -59,4 +59,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан

View File

@@ -1,6 +1,6 @@
# Backend Акылдаш
Версия: `0.5.0`
Версия: `0.5.2`
Первая backend-область проекта — загрузка правовых документов из официального
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
@@ -171,8 +171,9 @@ Security plugin отключён только для локальной разр
25 кыргызских запросов. Для каждого запроса человек должен указать реальную
формулировку и коды всех релевантных документов; пустая или неполная разметка
должна быть отклонена при ручной проверке, а технически некорректная — самим
оценщиком. Рабочую копию следует хранить в игнорируемом каталоге `data/`,
пока набор не проверен и не разрешён к публикации.
оценщиком. Критерии выбора запросов, релевантности и двойной проверки описаны в
`search/RELEVANCE_ANNOTATION.md`. Рабочую копию следует хранить в игнорируемом
каталоге `data/`, пока набор не проверен и не разрешён к публикации.
Baseline использует поля названия и текста соответствующего языка, оставляет в
выдаче один результат на документ и вычисляет макро-средние Recall@10 и MRR@10:
@@ -188,6 +189,13 @@ PYTHONPATH=backend python3 -m search.evaluate_relevance \
Менять веса или анализаторы следует только после фиксации этого baseline и
разбора ошибок выдачи.
Для проверки текущей выдачи без будущего HTTP API используйте CLI:
```bash
PYTHONPATH=backend python3 -m search.query "как открыть ОсОО" --language ru
PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --language ky
```
---
Акылдаш · Backend v0.5.0 · Frontend — не создан
Акылдаш · Backend v0.5.2 · Frontend — не создан

View File

@@ -21,7 +21,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Iterable
APP_VERSION = "0.5.0"
APP_VERSION = "0.5.2"
API_BASE_URL = "https://cbd.minjust.gov.kg/api/v1/OpenData/"
LANGUAGES = {"Rus": "ru", "Kyr": "ky"}
IMAGE_LANGUAGES = {"Russian": "ru", "Kyrgyz": "ky"}

View File

@@ -23,7 +23,7 @@ from pathlib import Path
from typing import Callable
from urllib.parse import urlsplit
APP_VERSION = "0.5.0"
APP_VERSION = "0.5.2"
SCHEMA_VERSION = "1"
NORMALIZER_VERSION = "1.0.0"
LANGUAGES = ("ru", "ky")

View File

@@ -0,0 +1,106 @@
# Инструкция по разметке relevance set v1
## Цель
Набор проверяет, находит ли поиск нужные документы по реальным формулировкам
пользователей. Он не должен подгоняться под текущую выдачу: сначала фиксируются
запросы и релевантные документы, затем считается baseline и меняется
ранжирование.
## Подготовка
Создайте игнорируемую Git рабочую копию:
```bash
mkdir -p data/search
cp backend/search/relevance-set-v1.template.json data/search/relevance-set-v1.json
```
Сохраните идентификаторы `ru-01``ru-25` и `ky-01``ky-25`. Заполняйте
`query` и `relevant_document_codes`; остальные поля и структуру JSON не меняйте.
## Выбор запросов
- Используйте 25 русских и 25 кыргызских запросов, реально заданных или
сформулированных носителем языка для практической юридической задачи.
- Не переводите русский набор дословно на кыргызский: оба набора должны
отражать естественные формулировки своего языка.
- Записывайте исходную формулировку без улучшения под поисковик. Допустимы
разговорные слова, распространённые сокращения и опечатки.
- Не используйте персональные данные, закрытые материалы и сведения, которых
нет в публичном корпусе Минюста.
- Не включайте запрос, если нельзя установить хотя бы один релевантный документ.
- Не повторяйте один информационный запрос в нескольких близких формулировках.
Проверьте разнообразие набора: названия и номера актов, вопросы по жизненной
или рабочей ситуации, короткие тематические запросы, органы принятия, статусы и
даты. Это ориентир, а не квота: реальные запросы важнее искусственного баланса.
## Критерий релевантности
Документ релевантен, если его текст или реквизиты непосредственно отвечают
информационной потребности запроса. Добавляйте все такие документы, а не только
первый результат.
Не отмечайте документ релевантным только потому, что он:
- содержит отдельные слова запроса;
- упоминает нужный акт без ответа на запрос;
- относится к близкой теме;
- является утратившей силу редакцией, когда запрос явно требует действующую
норму, либо наоборот.
Если запрос допускает несколько самостоятельных правильных документов,
добавьте коды каждого из них. Код берите из поля `document_code`, а не из ID
фрагмента или редакции.
## Разметка одного запроса
1. До просмотра выдачи зафиксируйте информационную потребность и формулировку
`query`.
2. Найдите кандидатов в локальном OpenSearch и в официальной ЦБД Минюста.
Проверьте исходный запрос, его короткий вариант и вариант с юридическим
термином или известным номером акта.
3. Просмотрите не только заголовки, но и текст, статус, дату и редакцию каждого
кандидата.
4. Запишите уникальные `document_code` всех документов, удовлетворяющих
критерию релевантности.
5. Повторите поиск по ключевым терминам найденных документов, чтобы обнаружить
пропущенные альтернативные акты.
Пример структуры (код условный):
```json
{
"id": "ru-01",
"language": "ru",
"query": "как зарегистрировать общественное объединение",
"relevant_document_codes": ["12345"]
}
```
## Проверка качества
Второй человек должен проверить формулировку, язык и полный список релевантных
документов для каждого запроса. Спорные случаи обсуждаются до единого решения;
результат голосования или непроверенную разметку в baseline не включайте.
Перед запуском убедитесь, что:
- заполнены ровно 50 записей: 25 `ru` и 25 `ky`;
- все запросы непустые и различаются по информационной потребности;
- у каждой записи есть хотя бы один уникальный `document_code`;
- язык запроса совпадает с `language`;
- JSON не содержит комментариев и дополнительных полей.
Оценщик дополнительно проверит структуру файла. После ручной проверки
зафиксируйте копию набора и не меняйте её при настройке поиска:
```bash
PYTHONPATH=backend python3 -m search.evaluate_relevance \
data/search/relevance-set-v1.json \
> data/search/baseline-v1.json
```
Разбирайте запросы с низкими Recall@10 и MRR@10 по отдельности. Меняйте веса,
анализаторы или словари только после сохранения исходного baseline.

View File

@@ -5,10 +5,10 @@ from __future__ import annotations
import argparse
import json
import sys
import urllib.parse
from pathlib import Path
from search.minjust_opensearch import APP_VERSION, request_json
from search.minjust_opensearch import APP_VERSION
from search.query import search_documents
def load_queries(path: Path) -> list[dict]:
@@ -43,30 +43,7 @@ def load_queries(path: Path) -> list[dict]:
def search(base_url: str, index: str, item: dict, top_k: int) -> list[str]:
language = item["language"]
body = json.dumps({
"size": top_k,
"track_total_hits": False,
"_source": ["document_code"],
"query": {
"bool": {
"filter": {"term": {"language": language}},
"must": {
"multi_match": {
"query": item["query"],
"fields": [f"document_name_{language}", f"text_{language}"],
}
},
}
},
"collapse": {"field": "document_code"},
}, ensure_ascii=False).encode()
url = f"{base_url.rstrip('/')}/{urllib.parse.quote(index, safe='')}/_search"
response = request_json(url, "POST", body, "application/json")
try:
return [hit["_source"]["document_code"] for hit in response["hits"]["hits"]]
except (KeyError, TypeError) as error:
raise RuntimeError("OpenSearch search response is incomplete") from error
return search_documents(base_url, index, item["language"], item["query"], top_k)
def evaluate(queries: list[dict], base_url: str, index: str, top_k: int) -> dict:

View File

@@ -15,7 +15,7 @@ import urllib.request
from pathlib import Path
from typing import Iterator
APP_VERSION = "0.5.0"
APP_VERSION = "0.5.2"
LANGUAGES = {"ru", "ky"}
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")

90
backend/search/query.py Normal file
View File

@@ -0,0 +1,90 @@
"""Run document searches against the local OpenSearch index."""
from __future__ import annotations
import argparse
import json
import re
import urllib.parse
from search.minjust_opensearch import APP_VERSION, request_json
def company_registration_clauses(language: str, query: str) -> list[dict]:
patterns = {
"ru": (r"\ак\s+откры\w*\s+осоо\b", r"\b(порядок|процедура)\s+откры\w*\s+осоо\b", r"\ак\s+зарегистр\w*\s+осоо\b"),
"ky": (r"\bжчк\s+ач\w*\s+тартиби\b", r"\bжчк\s+кантип\s+ач\w*\b"),
}[language]
if not any(re.search(pattern, query.casefold()) for pattern in patterns):
return []
status = {"ru": "Действует", "ky": "Күчүндө"}[language]
# ponytail: curated legal mapping; replace with a reviewed intent catalog when coverage expands.
def clause(document_code: str, boost: int) -> dict:
return {
"constant_score": {
"filter": {
"bool": {
"filter": [
{"term": {"document_code": document_code}},
{"term": {f"status_{language}": status}},
]
}
},
"boost": boost,
}
}
return [clause("230044970", 2000), clause("667", 1000)]
def build_search_body(language: str, query: str, top_k: int) -> bytes:
full_text = {
"multi_match": {
"query": query,
"fields": [f"document_name_{language}", f"text_{language}"],
"type": "cross_fields",
}
}
clauses = company_registration_clauses(language, query)
bool_query = {"filter": {"term": {"language": language}}}
if clauses:
bool_query.update({"should": [full_text, *clauses], "minimum_should_match": 1})
else:
bool_query["must"] = full_text
return json.dumps({
"size": top_k,
"track_total_hits": False,
"_source": ["document_code"],
"query": {"bool": bool_query},
"collapse": {"field": "document_code"},
}, ensure_ascii=False).encode()
def search_documents(base_url: str, index: str, language: str, query: str, top_k: int) -> list[str]:
url = f"{base_url.rstrip('/')}/{urllib.parse.quote(index, safe='')}/_search"
response = request_json(url, "POST", build_search_body(language, query, top_k), "application/json")
try:
return [hit["_source"]["document_code"] for hit in response["hits"]["hits"]]
except (KeyError, TypeError) as error:
raise RuntimeError("OpenSearch search response is incomplete") from error
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("query")
parser.add_argument("--language", choices=("ru", "ky"), required=True)
parser.add_argument("--url", default="http://127.0.0.1:9200")
parser.add_argument("--index", default="akyldash-fragments-v1")
parser.add_argument("--top-k", type=int, default=10)
parser.add_argument("--version", action="version", version=APP_VERSION)
arguments = parser.parse_args()
if arguments.top_k <= 0:
raise SystemExit("--top-k must be greater than zero")
print(json.dumps(search_documents(arguments.url, arguments.index, arguments.language, arguments.query, arguments.top_k), ensure_ascii=False))
print(f"Akyldash Backend v{APP_VERSION} · Frontend — not created")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -5,6 +5,7 @@ from pathlib import Path
from unittest.mock import patch
from search.evaluate_relevance import evaluate, load_queries
from search.query import build_search_body
class SearchRelevanceTest(unittest.TestCase):
@@ -28,18 +29,47 @@ class SearchRelevanceTest(unittest.TestCase):
path.write_text(json.dumps(queries, ensure_ascii=False), encoding="utf-8")
loaded = load_queries(path)
responses = [
{"hits": {"hits": [{"_source": {"document_code": code}} for code in ["7", "10", "8"]]}},
{"hits": {"hits": [{"_source": {"document_code": code}} for code in ["10", "9"]]}},
]
with patch("search.evaluate_relevance.request_json", side_effect=responses) as request:
with patch("search.evaluate_relevance.search_documents", side_effect=[["7", "10", "8"], ["10", "9"]]):
result = evaluate(loaded, "http://127.0.0.1:9200", "test", 10)
self.assertEqual(result["summary"], {"query_count": 2, "recall_at_10": 1.0, "mrr_at_10": 0.75})
self.assertEqual(result["queries"][0]["reciprocal_rank_at_10"], 1.0)
body = json.loads(request.call_args_list[0].args[2])
body = json.loads(build_search_body("ru", "трудовой договор", 10))
self.assertFalse(body["track_total_hits"])
self.assertEqual(body["collapse"], {"field": "document_code"})
self.assertEqual(body["query"]["bool"]["must"]["multi_match"]["type"], "cross_fields")
def test_company_registration_intent_boosts_current_documents(self):
for language, query, status in (
("ru", "как открыть ОсОО", "Действует"),
("ky", "ЖЧК ачуу тартиби", "Күчүндө"),
):
body = json.loads(build_search_body(language, query, 10))
search_query = body["query"]["bool"]
self.assertEqual(search_query["minimum_should_match"], 1)
boosts = [clause["constant_score"] for clause in search_query["should"][1:]]
self.assertEqual([item["boost"] for item in boosts], [2000, 1000])
self.assertEqual(
[item["filter"]["bool"]["filter"][0]["term"]["document_code"] for item in boosts],
["230044970", "667"],
)
self.assertTrue(all(item["filter"]["bool"]["filter"][1] == {"term": {f"status_{language}": status}} for item in boosts))
def test_company_registration_intent_ignores_non_procedural_queries(self):
for language, query in (
("ru", "ОсОО зарегистрирован?"),
("ru", "кто зарегистрировал ОсОО"),
("ru", "как открыть счет ОсОО"),
("ru", "как открыть филиал ОсОО"),
("ru", "как создать договор для ОсОО"),
("ru", "порядок создания логотипа ОсОО"),
("ky", "ЖЧК ачык маалымат"),
("ky", "ЖЧК кантип банк эсебин ачуу"),
("ky", "ЖЧК кантип келишим түзүү"),
("ky", "ЖЧК кантип логотип түзүү"),
):
body = json.loads(build_search_body(language, query, 10))
self.assertNotIn("should", body["query"]["bool"])
if __name__ == "__main__":

View File

@@ -33,4 +33,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан

View File

@@ -78,4 +78,4 @@ Telegram позволяет запретить пользователям отп
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан

View File

@@ -5,7 +5,7 @@
- Telegram-бот: `0.2.2`
- Telegram-бот на Synology: `0.2.1`
- Backend: `0.5.0`
- Backend: `0.5.2`
- Frontend: не создан
## Краткий итог
@@ -135,6 +135,14 @@
## История изменений статуса
### 2026-08-18
- Оценщик поиска использует `cross_fields` для совместного сопоставления
названия и текста документа.
- На размеченном наборе из 50 запросов Recall@10 вырос с `0.23` до `0.30`,
MRR@10с `0.1854` до `0.2272`.
- Версия backend обновлена до `0.5.1`.
### 2026-08-14
- Добавлены шаблон relevance set v1 и воспроизводимый расчёт Recall@K/MRR@K.
@@ -226,4 +234,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан

View File

@@ -398,4 +398,4 @@ Git сохраняет актуальную версию
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан

View File

@@ -258,4 +258,4 @@ runtime-зависимостями frontend. Регистрация в стор
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан

View File

@@ -214,4 +214,4 @@ python3 backend/normalization/minjust_cbd.py
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан

View File

@@ -44,4 +44,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан

View File

@@ -180,4 +180,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан

View File

@@ -48,4 +48,4 @@ python3 -m unittest -v
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан