Metadata-Version: 2.4
Name: agentic-rag-lib
Version: 0.1.0
Summary: RAG library: file(s) -> mocked text recognition -> hierarchical on-disk index -> multi-tool search (BM25, FAISS vector, hybrid, TOC, grep, agentic)
Author: Matvey Lebedev
License-Expression: MIT
Project-URL: Homepage, https://github.com/MatveyLebedev/Deep_agent
Project-URL: Repository, https://github.com/MatveyLebedev/Deep_agent
Keywords: rag,retrieval,bm25,faiss,search,toc,russian
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Text Processing :: Indexing
Classifier: Topic :: Scientific/Engineering :: Information Analysis
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.24
Requires-Dist: rank-bm25>=0.2.2
Requires-Dist: faiss-cpu>=1.7.4
Provides-Extra: stem
Requires-Dist: snowballstemmer>=2.2; extra == "stem"
Provides-Extra: ru
Requires-Dist: pymorphy3>=1.3; extra == "ru"
Requires-Dist: pymorphy3-dicts-ru; extra == "ru"
Provides-Extra: gigachat
Requires-Dist: langchain-gigachat>=0.5; extra == "gigachat"
Provides-Extra: langchain
Requires-Dist: langchain-core>=0.1; extra == "langchain"
Provides-Extra: dev
Requires-Dist: pytest>=7.4; extra == "dev"
Requires-Dist: ruff>=0.4; extra == "dev"
Requires-Dist: build>=1.0; extra == "dev"
Requires-Dist: twine>=5.0; extra == "dev"
Requires-Dist: snowballstemmer>=2.2; extra == "dev"
Requires-Dist: pymorphy3>=1.3; extra == "dev"
Requires-Dist: pymorphy3-dicts-ru; extra == "dev"
Dynamic: license-file

# raglib

RAG-библиотека: файлы → «распознавание текста» (в проде — целевая система,
здесь — мок) → персистентный иерархический индекс → поиск несколькими
инструментами. **Инвариант выдачи: результат — всегда целый пункт документа с
его номером**, пригодный для программной обработки. План и архитектура —
в [PLAN.md](PLAN.md).

## Установка

```bash
pip install -e .                    # ядро: numpy, rank-bm25, faiss-cpu
pip install -e '.[stem]'            # + snowballstemmer  (BM25 stem — лучшее качество RU)
pip install -e '.[ru]'              # + pymorphy3        (BM25 lemma)
pip install -e '.[gigachat]'        # + langchain-gigachat (эмбеддинги + chat GigaChat)
pip install -e '.[langchain]'       # + langchain-core   (@tool-обёртки для интеграций)
pip install -e '.[dev]'             # + pytest, ruff, build, все нормализаторы
```

LLM и эмбеддинги raglib **не поставляет**: вы передаёте готовые объекты
LangChain (`llm` и `embeddings`) напрямую — ставьте нужный провайдер сами
(`langchain-gigachat` для контура, `langchain-openai` и т.п.).

Extra комбинируются: `pip install -e '.[stem,langchain]'`. Ядро зависит
только от `numpy`, `rank-bm25`, `faiss-cpu` — всё остальное опционально.
RU-нормализация BM25 (`bm25_normalizer="auto"`, дефолт) сама берёт лучший
доступный бэкенд: `stem` → `lemma` → `none`.

## Сборка wheel (для переноса в закрытый контур)

Ядро — чистый Python, поэтому колесо получается универсальное
(`py3-none-any`). Для сборки нужен только `setuptools>=68` (build-backend в
`pyproject.toml`) — отдельный пакет `wheel` НЕ требуется (современный
`setuptools.build_meta` сам умеет собирать `.whl`), `build`-фронтенд тоже
опционален.

**На машине с интернетом** — удобнее через `build` (сам разрешит зависимости):

```bash
python -m pip install build
python -m build --wheel              # → dist/raglib-<версия>-py3-none-any.whl
```

**«На месте», офлайн, в контуре** — здесь есть нюанс: и `python -m build`, и
голый `pip wheel` по умолчанию создают ИЗОЛИРОВАННОЕ окружение для сборки и
пытаются СКАЧАТЬ `setuptools` из PyPI в него, даже если нужная версия уже
стоит в системе. Без сети это упадёт. Решение — флаг `--no-build-isolation`,
который заставляет использовать уже установленный в окружении `setuptools`
(в контуре это `80.9.0` — с запасом выше требуемых `>=68`):

```bash
pip wheel . --no-deps --no-build-isolation -w dist
```

Проверено: собирает колесо в venv, где кроме `setuptools==80.9.0` ничего
нет (ни `wheel`, ни `build`, ни сети) — то есть эта команда работает именно
в условиях контура.

Проверка колеса в чистом окружении:

```bash
python -m venv /tmp/check && /tmp/check/bin/pip install dist/raglib-*.whl
/tmp/check/bin/python -c "import raglib, faiss; print(raglib.__version__)"
```

Установка в закрытом контуре (без интернета) — заранее скачайте зависимости
там, где сеть есть, и перенесите вместе с колесом raglib:

```bash
# на машине с интернетом: собрать колёса всех зависимостей
pip wheel 'raglib[gigachat]' -w wheelhouse     # или: pip download raglib -d wheelhouse
# в контуре: поставить только из локальной папки, без обращения к PyPI
pip install --no-index --find-links wheelhouse 'raglib[gigachat]'
```

Версия задаётся в `pyproject.toml` (`project.version`) — поднимите её перед
сборкой нового колеса. Артефакты (`dist/`, `build/`) в git не коммитятся
(см. `.gitignore`).

## Деплой в закрытый контур (офлайн)

raglib совместим с окружением контура **как есть** — его зависимости уже стоят
там нужных версий (сверено с `requirements.txt` целевого сервиса):

| raglib | требует | в контуре | |
|---|---|---|---|
| `numpy` | ≥1.24 | 2.3.3 | ✅ |
| `faiss-cpu` | ≥1.7.4 | 1.12.0 | ✅ |
| `rank_bm25` | ≥0.2.2 | 0.2.2 | ✅ |
| `pymorphy3` (BM25 lemma) | ≥1.3 | 2.0.5 | ✅ |
| `langchain-gigachat` (LLM+эмбеддинги) | — | 0.5.0 | ✅ |
| `snowballstemmer` (BM25 stem) | ≥2.2 | — | опц. |

**Установка офлайн** (raglib — из колеса, зависимости фиксированы под контур —
см. [`requirements-contour.txt`](requirements-contour.txt)):

```bash
pip install --no-index --find-links wheelhouse \
    -c requirements-contour.txt 'raglib[gigachat,langchain]'
```

**BM25-нормализация**: дефолт `bm25_normalizer="auto"` в контуре сам выбирает
`lemma` (pymorphy3 есть, snowballstemmer нет) и пишет конкретный выбор в
manifest — менять `requirements` не нужно. Для лучшего качества (`stem`,
MRR 0.938 против 0.854) добавьте `snowballstemmer` (чистый Python, без
зависимостей) — `auto` подхватит его сам.

**GigaChat через LangChain** — и эмбеддер, и chat-модель передаются напрямую,
без адаптеров:

```python
from langchain_gigachat import GigaChatEmbeddings, GigaChat
from raglib import RagIndex

emb = GigaChatEmbeddings(credentials="...", scope="GIGACHAT_API_CORP", verify_ssl_certs=False)
index = RagIndex.build(inputs="docs/", index_dir="./idx", embeddings=emb)

llm = GigaChat(credentials="...", scope="GIGACHAT_API_CORP", verify_ssl_certs=False)
res = index.agentic_search("Какие сделки требуют одобрения совета?", llm=llm, top_k=8)
```

Проверено: в venv с точными пакетами контура (numpy 2.3.3, faiss, rank-bm25,
pymorphy3; **без** snowballstemmer) весь конвейер — сборка (`auto`→`lemma`) →
BM25 → навигация → перезагрузка — работает, все 65 офлайн-тестов зелёные.

## Быстрый старт

```python
from raglib import RagIndex
from raglib.recognition import MockRecognizer
from raglib.embeddings import HashingEmbeddings   # офлайн; в проде — langchain_gigachat.GigaChatEmbeddings

# построить индекс (вход — .md, как отдаёт целевая система распознавания)
index = RagIndex.build(
    inputs=["docs/charter.md"],            # файл / список / директория
    index_dir="./charter_index",
    recognizer=MockRecognizer(),           # шов для реальной системы распознавания
    embeddings=HashingEmbeddings(),        # None → BM25-only индекс
)

# загрузить готовый
index = RagIndex.load("./charter_index", embeddings=HashingEmbeddings())

# поиск: bm25 | vector | hybrid; strategy="tree" — сначала разделы, потом пункты
# BM25-нормализация RU: bm25_normalizer="auto" (дефолт) берёт лучший доступный
# бэкенд stem→lemma→none («сделки»≈«сделкой»); можно задать явно "stem"/"lemma"/"none".
# Конкретный выбор пишется в manifest; при load() переопределяется без пересборки.
hits = index.search("крупные сделки", method="hybrid", top_k=5)
for h in hits:
    print(h.clause_number, h.doc_id, h.score)   # "13.1", ...
    print(h.text)                               # ПОЛНЫЙ текст пункта

# оглавление и разделы: заголовки обогащаются текстом («## 7.» → первое
# предложение тела раздела), артефакты распознавания в превью не попадают
print(index.toc())                       # ключи + заголовки
print(index.toc(preview=True))           # + превью-предложение у каждого раздела
print(index.toc(clauses=True))           # + номера пунктов под каждым разделом
                                         #   (полнота сегментации видна сразу)
entries = index.toc_entries(doc="charter")   # структурно: key/title/preview/level
print(index.read_section("charter", "12.1"))  # раздел целиком, с подразделами

# find_section: по ключу, заголовку И превью содержимого; semantic=True — по смыслу
refs = index.find_section("наблюдательный совет")
refs = index.find_section("подтверждение решений собрания", semantic=True)

# навигационный поиск: раздел по смыслу → ranked-поиск внутри него
ref = refs[0]
hits = index.search("нотариальное удостоверение", method="bm25",
                    doc=ref.doc_id, section=ref.key)

# regex-поиск (выдача — те же целые пункты)
hits = index.grep(r"\d+\s*процент")

# агентский поиск: план → мульти-инструментальный поиск → LLM-рефлексия → дообыск
# llm — любая LangChain chat-модель (.invoke), передаётся напрямую
from langchain_gigachat import GigaChat
llm = GigaChat(credentials="...", scope="GIGACHAT_API_CORP", verify_ssl_certs=False)
res = index.agentic_search("Какие сделки требуют одобрения наблюдательного совета?",
                           llm=llm, top_k=8)
print(res.degraded, [h.clause_number for h in res.hits])

# удаление (валидирует, что папка — индекс raglib)
index.delete()                     # или RagIndex.delete_index("./charter_index")
```

## Инструменты поиска

Все методы работают над одним индексом и возвращают `SearchHit` — **целый
пункт с номером** (кроме `toc_*`, отдающих структуру оглавления). Оглавление
(`## СОДЕРЖАНИЕ` / `ОГЛАВЛЕНИЕ`) в поиск **не попадает**: это дайджест-навигация,
который иначе матчился бы почти на любой запрос (он перечисляет все статьи) и
возвращался бы с пустым номером. Сам раздел остаётся в `toc()` / `find_section`.

| Метод | Что делает |
| --- | --- |
| `search(q, method="bm25")` | лексический BM25 (нормализация RU: stem/lemma/none) |
| `search(q, method="vector")` | векторный (FAISS, косинус); `strategy="tree"` — разделы→пункты |
| `search(q, method="hybrid")` | BM25 + вектор через RRF |
| `search(..., doc=…, section=…)` | фильтры: документ / префикс раздела по нумерации |
| `grep(pattern)` | regex по пунктам (выдача — те же целые пункты) |
| `toc(preview=…, clauses=…)` | оглавление; `toc_entries()` — структурно |
| `find_section(q, semantic=…)` | раздел по ключу/заголовку/превью или по смыслу |
| `read_section(doc, key)` | раздел целиком, с подразделами, без усечения |
| `agentic_search(q, llm=…)` | промт → план → поиск → LLM-рефлексия → дообыск |

**Формат выдачи — `SearchHit`:**

```python
h.clause_number   # "13.1"  — номер пункта ("" у ненумерованных)
h.locator         # СТАБИЛЬНАЯ ссылка, НИКОГДА не пустая: номер пункта, а если
                  #   номера нет — путь-провенанс ("Отзыв доступа") или id ("notes.md#4")
h.text            # полный текст пункта — точный срез исходного markdown, не режется
h.score           # ранг метода (BM25 / косинус / RRF / число совпадений grep)
h.doc_id          # идентификатор документа (санитизированное имя файла)
h.doc_name        # имя исходного документа ("charter.md"); "" → см. doc_id
h.section_path    # цепочка предков ПО КЛЮЧАМ: ["12", "12.1", "12.1.4"] (у раздела
                  #   без номера — синтетический ключ ["§3"])
h.section_titles  # заголовки этих разделов, выровнены с section_path 1:1
                  #   ["Статья 12. …", "12.1. …", ""]  ("" у листа-пункта)
h.path            # читаемый путь глава→пункт: заголовок (или номер) на каждом уровне
h.breadcrumb      # тот же путь строкой: "Статья 12. … › 12.1. … › 12.1.4"
h.method          # bm25 | vector | hybrid | grep | agentic
h.verdict         # relevant | partial — только для agentic_search
```

**Пункты и разделы без номера.** Не у всех документов есть нумерация (регламенты,
преамбулы, приложения). raglib работает с ними как с полноценными: каждому
разделу без номера присваивается **стабильный синтетический ключ** `§1`, `§2`, …
— он показывается в `toc()` как `[§N]`, принимается в `read_section`/`find_section`
и в фильтре `section=`. У пункта без номера `clause_number` остаётся пустым (у него
и правда нет номера), но `breadcrumb` даёт раздел-владелец, а `locator` — всегда
непустую ссылку для программной привязки. Оглавление (`## СОДЕРЖАНИЕ`) при этом
из поиска исключено (см. «Инструменты поиска»). Демонстрация — в
[`notebooks/demo.ipynb`](notebooks/demo.ipynb).

`agentic_search` возвращает `AgenticResult`: `.hits`, `.trace` (журнал шагов),
`.degraded` (True = откат в hybrid), `.iterations`, `.llm_calls`.

## Контракт выдачи

Единица выдачи ретривера — всегда `SearchHit` (двухуровневая модель, PLAN.md
§1/§5: `Chunk` — единица ИНДЕКСАЦИИ, наружу не выходит; `Clause`/пункт — единица
ВЫДАЧИ). `search()` и `grep()` возвращают `list[SearchHit]`; `agentic_search()` —
`AgenticResult`, у которого `.hits` — тот же `list[SearchHit]`.

**Гарантии для каждого хита:**

- **Целый пункт, не фрагмент.** Совпадение ищется по внутренним окнам, но результат
  всегда агрегируется до целого нумерованного пункта.
- **Точная привязка к источнику.** `h.text == doc.md_text[span[0]:span[1]]` —
  дословный, неусечённый срез распознанного markdown (`h.text in doc.md_text`
  всегда истинно); переразмерный пункт возвращается целиком, а не по окну совпадения.
- **Без дублей.** Один `clause_id` не повторяется; score пункта — максимум по его окнам.
- **Согласованность номера.** Если `clause_number` непустой — `section_path[-1] == clause_number`.
- **Провенанс-метаданные.** `doc_name` + путь глава→пункт: `section_titles` выровнены
  с `section_path` 1:1 (`""` там, где у уровня нет заголовка, напр. у листа-пункта),
  `breadcrumb == " › ".join(path)`.

Инварианты проверяются для ВСЕХ методов (`tests/test_search.py::_assert_output_invariants`).

**Порядок и фильтры.** Хиты — по убыванию релевантности (BM25 / косинус / RRF / число
совпадений grep), не более `top_k`. `doc=` / `section=` сужают кандидатов, но не меняют
ни одной гарантии выше.

**Деградация — явная, не молчаливая.**

- `method="vector"|"hybrid"` поднимают `RuntimeError`, если индекс BM25-only или на поиск
  не передан клиент эмбеддингов.
- `bm25`, `grep`, `toc` работают полностью офлайн.
- `agentic_search` выставляет `res.degraded=True` при откате в hybrid (и всё равно
  возвращает пункты, соблюдающие инвариант).

**Вне контракта.** Внутренние `Chunk` наружу не отдаются. `score` НЕ сравним между
методами и между запросами. `toc()` / `find_section()` возвращают `TocEntry` / `SectionRef`
(структуру навигации), а НЕ `SearchHit` — это другой контракт.

Одной строкой: **каждый результат — целый, дословный, однозначно идентифицированный
нумерованный пункт со своим документом и путём глава→пункт, упорядоченный по релевантности.**

## Чанкинг (индексация)

Индексируется не пункт целиком, а его **внутренние окна** — и только у переразмерных
пунктов. Двухуровневая модель `Clause`/`Chunk` (PLAN.md §5):

1. Сначала документ режется на **целые пункты** (`segment_clauses`) — по нумерации,
   не по длине. Оглавление сюда не попадает; разделы без номера — обычные пункты.
2. Затем по каждому пункту строятся окна (`window_spans`, счёт по **символам**):
   - пункт ≤ `chunk_size` → **один чанк = весь пункт** (1:1, обычный случай);
   - пункт > `chunk_size` → скользящие окна шириной `chunk_size` с перекрытием
     `chunk_overlap` (шаг = `chunk_size − chunk_overlap`).

BM25 и FAISS строятся **по чанкам**; на поиске скор чанков **агрегируется обратно в
пункт** (score = максимум по чанкам пункта, дедуп по `clause_id`), поэтому в выдачу
всегда уходит **целый пункт**, а не окно. Векторы разделов — mean-pooling векторов их
чанков. Чанки наружу не отдаются никогда.

Параметры: `RagIndex.build(chunk_size=1500, chunk_overlap=150)` (символы) — пишутся в
manifest `chunking: {size, overlap}`.

### Что видит LLM в агентском поиске

Не путать с чанкингом. В `agentic_search` **чанки в LLM не попадают** — кандидаты уже
собраны в целые пункты. Но на шаге **REFLECT** текст каждого пункта обрезается до
`snippet_chars` (по умолчанию **800 символов**) — только для промпта оценки
релевантности. Пункт, разбитый на ≥2 чанка, по определению длиннее `chunk_size`
(>1500 символов), поэтому LLM в reflection видит только **первые ~800 символов**, а не
пункт целиком. При этом в `res.hits[i].text` пункт возвращается **целиком, дословно**:

- **LLM оценивает** релевантность по первым `snippet_chars` символам пункта;
- **в выдачу** пункт уходит целым (инвариант «целый пункт» соблюдён).

Следствие: если ключевая информация в хвосте длинного пункта (после `snippet_chars`),
вердикт LLM строится на его начале — ретрив всё равно находит пункт по хвостовому чанку,
а выдача остаётся целой. `snippet_chars` зафиксирован на 800 в фасаде `agentic_search()`;
чтобы увеличить, создайте `AgenticSearcher(engine, llm, snippet_chars=N)` напрямую.

## Устойчивость к OCR-ошибкам в номерах

Распознавание сканов путает цифры с похожими буквами и точку с запятой, поэтому
пункт «10.2» приходит как «1О.2», «l0.2» или «10,2», и строгий сегментатор его бы
пропустил. При `ocr_number_repair=True` (дефолт в `RagIndex.build`) номера
восстанавливаются и в **нумерации пунктов**, и в **ключах разделов**:

| Ошибка OCR | Пример | → |
| --- | --- | --- |
| буква вместо `0` | `1О.2` / `1O.2` (кир./лат. O) | `10.2` |
| `l` / `I` вместо `1` | `l2.3` | `12.3` |
| `З` вместо `3`, `б` вместо `6`, `S` вместо `5` | `1б.4` | `16.4` |
| запятая вместо точки | `12,1` | `12.1` |
| лишние пробелы | `7 . 2` | `7.2` |

Так `search`, `toc`, `find_section` и фильтр `section=` работают по
восстановленному номеру: `find_section("10")` найдёт раздел «Статья 1О», а хит
получит `locator="10.2"`. Восстановленный текст пункта в выдаче **не меняется** —
`h.text` остаётся дословным срезом исходника (правится только номер-ключ).

**Защита от ложных срабатываний** ([`parsing/ocr.py`](src/raglib/parsing/ocr.py)):
каждая часть номера обязана содержать **хотя бы одну настоящую цифру**, поэтому
слово («Общие», «Зона») или римская цифра («II») никогда не станут номером; а
результат с ведущим нулём отбрасывается как дата/сумма (`01.02.2025` — не пункт).
Полностью строгий разбор — `RagIndex.build(..., ocr_number_repair=False)`
(ключи разделов из заголовков всё равно восстанавливаются — они надёжнее).

## LLM и эмбеддинги — объекты LangChain напрямую

raglib не поставляет клиентов провайдеров и не оборачивает их в адаптеры: вы
строите объекты LangChain в своём коде и передаёте их как есть. Тот же `llm` и
`embeddings`, что уже крутятся в вашем LangChain / deepagents стеке, работают и
здесь.

**LLM** для агентского поиска — любой LangChain chat-модели достаточно
(`.invoke(messages) → AIMessage`): raglib шлёт OpenAI-совместимые
`{"role","content"}`-словари прямо в `.invoke()` и разворачивает ответ (строку
или список content-блоков) в текст сам.

```python
from langchain_gigachat import GigaChat        # контур
# from langchain_openai import ChatOpenAI      # или любой другой провайдер

llm = GigaChat(credentials="...", scope="GIGACHAT_API_CORP", verify_ssl_certs=False)
res = index.agentic_search("…", llm=llm, top_k=8)   # llm=None → без рефлексии, plain hybrid
```

**Эмбеддинги** — тоже напрямую: протокол raglib (`embed_documents` /
`embed_query`) совпадает с интерфейсом LangChain-эмбеддеров.

```python
from langchain_gigachat import GigaChatEmbeddings          # прод в контуре
RagIndex.build(inputs=..., index_dir=..., embeddings=GigaChatEmbeddings(...))
```

## Эмбеддинги

| Что передать в `embeddings=` | Когда |
| --- | --- |
| любой LangChain-эмбеддер напрямую | прод: `langchain_gigachat.GigaChatEmbeddings` в контуре и т.п. |
| `HashingEmbeddings()` | тесты/CI: детерминированный, без сети |
| `None` | BM25-only индекс (vector/hybrid дают понятную ошибку) |

## Результаты тестирования

**Офлайн-набор:** 73 unit/integration-теста (фикстуры, `HashingEmbeddings`,
`MockLLM` — сеть в CI не нужна): парсинг разделов и пунктов, **OCR-ошибки в
номерах**, **разделы без номера (`§N`) и исключение оглавления**, все артефакты
распознавания, roundtrip хранилища, все методы поиска, инварианты выдачи,
навигация, RU-нормализация (в т.ч. авто-выбор бэкенда), агентский цикл
(happy-path / refine / деградация / бюджеты). Отдельно проверен запуск в
venv, имитирующем контур (без snowballstemmer).

**Боевой корпус:** два распознанных устава (ООО, 18 стр. + АО, 35 стр.,
markdown из docling), эмбеддинги `google/gemini-embedding-001`
через OpenRouter (dim 3072). Итог: 447 пунктов / 494 юнита, сборка ~30 с.

**Полнота сегментации** (после обработки артефактов распознавания: пункты-списки
`- 1.1 …`, пробелы в номерах `7 . 2.`, склейки `7.3.1.текст`, пункты-строки
таблиц, таблицы, сплющенные в одну строку, OCR-склейки `9. 21.2.`):
**ноль дыр в нумерации и ноль дубликатов номеров** на обоих уставах;
самый длинный пункт сжался с 19,6 тыс. до 3 тыс. символов.
Проверка своего корпуса: `index.toc(clauses=True)` — дыры видны сразу.

**Качество поиска** (8 перефразированных юридических запросов, релевантность —
автоматически по паттернам в тексте пункта):

| Конфигурация | MRR | Σ релевантных@5 |
|---|---|---|
| bm25, normalizer="none" | 0.719 | 24 |
| **bm25, normalizer="stem"** (дефолт) | **0.938** | **34** |
| bm25, normalizer="lemma" | 0.854 | 30 |
| hybrid (stem) | 0.854 | 32 |
| vector (gemini-embedding-001) | 0.833 | 27 |

Контрольный случай: пункт о неприменении ст. 45 (сделки с заинтересованностью)
по перефразированному запросу — вне топ-10 без нормализации → ранг 1 со stem.

**Агентский поиск вживую** на слабой модели (`deepseek/deepseek-v4-flash`,
та же, что в целевом контуре): 3 вопроса по уставам — все `degraded=False`,
по 1 итерации / 2 LLM-вызова / 25–35 с; PLAN и REFLECT стабильно возвращают
парсибельный JSON; выдача — целые пункты с вердиктами relevant/partial.

## Как выбирать инструмент (рекомендации по итогам замеров)

| Задача | Инструмент |
|---|---|
| Точные термины, номера статей, проценты | `search(method="bm25")` или `grep()` |
| Перефразированный смысловой вопрос | `search(method="vector")` или `"hybrid"` |
| «Найди раздел про X и прочитай целиком» | `find_section(semantic=True)` → `read_section()` |
| Сложный вопрос без готовой формулировки | `agentic_search()` (фильтрует шум рефлексией) |
| Большой корпус / известна область | `strategy="tree"` и/или фильтры `doc=`, `section=` |

Практические советы:

- **Нормализатор BM25**: `stem` (дефолт) — лучший по замеру; `lemma` не окупает
  зависимость pymorphy3; `none` — только если нужны точные словоформы. Менять
  режим можно при `load(bm25_normalizer=...)` — пересборка и повторные
  эмбеддинги не нужны.
- **BM25-only режим** (`embeddings=None`) — законный: BM25 + TOC + grep работают
  полностью офлайн; это же деградация при недоступности эмбеддера.
- **`find_section` по подстроке** — для случаев «примерно знаю название раздела»;
  одиночные частотные слова («протокол») цепляют преамбулы. Для смысла —
  `semantic=True`.
- **Агентский поиск**: проверяйте `res.degraded` (True = честный откат в hybrid)
  и держите `res.trace` в логах — там весь план/вердикты для разбора качества.
  A/B против обычного поиска: тот же вызов с `llm=None`.
- **Выдача любого метода** — целые пункты с номерами (`clause_number`, полный
  `text` — точный срез распознанного markdown): можно парсить программно и
  цитировать без сверки с оригиналом.

## Тесты

```bash
python -m pytest      # полностью офлайн: HashingEmbeddings + MockLLM + фикстуры
```
