Skip to main content

RAG library: file(s) -> mocked text recognition -> hierarchical on-disk index -> multi-tool search (BM25, FAISS vector, hybrid, TOC, grep, agentic)

Project description

raglib

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

Установка

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", дефолт) сама берёт лучший доступный бэкенд: stemlemmanone.

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

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

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

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):

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

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

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

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:

# на машине с интернетом: собрать колёса всех зависимостей
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):

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-модель передаются напрямую, без адаптеров:

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) весь конвейер — сборка (autolemma) → BM25 → навигация → перезагрузка — работает, все 65 офлайн-тестов зелёные.

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

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:

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.

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): каждая часть номера обязана содержать хотя бы одну настоящую цифру, поэтому слово («Общие», «Зона») или римская цифра («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-блоков) в текст сам.

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-эмбеддеров.

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): можно парсить программно и цитировать без сверки с оригиналом.

Тесты

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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

agentic_rag_lib-0.1.0.tar.gz (82.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

agentic_rag_lib-0.1.0-py3-none-any.whl (56.8 kB view details)

Uploaded Python 3

File details

Details for the file agentic_rag_lib-0.1.0.tar.gz.

File metadata

  • Download URL: agentic_rag_lib-0.1.0.tar.gz
  • Upload date:
  • Size: 82.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.1

File hashes

Hashes for agentic_rag_lib-0.1.0.tar.gz
Algorithm Hash digest
SHA256 bb412748da614432ed6004bf7053a920e5e3e78892a46a9fa110d3a071b311b2
MD5 5e1913a77c06c164806d043d4d65305b
BLAKE2b-256 07afd3bd11df34acf94fa5d4497cf9c2bdc776bd196bf5988a1bf9ef663ad398

See more details on using hashes here.

File details

Details for the file agentic_rag_lib-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for agentic_rag_lib-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 05f9817d67b20564ce7604c062d2273d624de739fd540d8db5e0c8936748dd7d
MD5 86ce1c025578f3c2dbfff28f47af1ac7
BLAKE2b-256 9db0ea24180d3154840ad3af8d8fd984a9b307ed7a4c1fe56a05d0eced848a52

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page