Skip to content

srose69/retriforever

Repository files navigation

RetriFORever

Локальный retrieval-слой для AI-ассистентов по коду. Без облака, без embedding-магии, без подписки за то, что раньше делал ctags.

Rust Python Tokio Tantivy SurrealDB Tree-sitter PyO3 MCP Docker Live reload Zero network License: MIT

In development. Something might not work correctly.

Быстрый старт · MCP tools · Почему не семантический поиск · Архитектура · FAQ


Зачем оно вообще

Мне надоело платить подписку за IDE, в которой поиск по коду сломан by design.

Ты просишь ассистента «найди, где мы создаём пользователя» — он находит три random-файла, половина из которых устарела на полгода, и дальше уверенно галлюцинирует API, которых в проекте нет. Не потому что модель тупая, а потому что ей в контекст скормили мусор: обрезанные сниппеты, случайные строки, куски тестов и README, отобранные эвристикой уровня «похоже на запрос по косинусной близости эмбеддинга».

Дальше модель делает следующее логичное действие — достраивает то, чего не хватило, из своего претрейна. Получается код, который выглядит как твой проект, но им не является. Ты тратишь ещё один круг: «нет, у нас не так, посмотри сюда». И ещё один. И ещё.

RetriFORever — это мой личный ответ на эту проблему: вынуть retrieval из IDE, сделать его детерминированным, отдать агенту ровно то, что он просил, и не навязывать сверху ещё одну модель.

Почему не семантический поиск

Потому что семантика — это категория естественного языка, а код — не естественный язык. Код — это формальная система с жёсткими идентификаторами, типами и структурой вызовов. Для него «смысловая близость» работает плохо по трём причинам:

  1. Идентификаторы — это не синонимы. user_id и account_id семантически близки, но в проекте это два разных ключа, и путать их — баг. Эмбеддинг с радостью их перепутает.
  2. Семантическое расстояние непредсказуемо. Ты не можешь объяснить коллеге, почему top-5 по запросу «auth middleware» это utils/date.py. А эмбеддер может.
  3. Модель поверх модели = ошибка в квадрате. У тебя уже есть LLM, которая генерирует код и иногда врёт. Добавить ещё одну модель, которая достаёт ей контекст и тоже иногда врёт — это не улучшение retrieval, это удвоение точек отказа.

Поэтому здесь намеренно нет embeddings, нет vector DB, нет reranker'а-на-трансформере. Есть tree-sitter (он детерминированный) и BM25 (он детерминированный). Ты задаёшь запрос — ты получаешь ровно то, что совпало, с номерами строк, с полным телом функции, без пересказа от имени искусственного интеллекта.

Если когда-нибудь захочется семантики — её можно добавить слоем сверху. Но основа должна быть формальной. Не наоборот.

Что оно делает

  • Режет репозиторий на синтаксические чанки через tree-sitter. Единица индексации — не файл и не строка, а функция / класс / метод / trait / impl / type alias / import. У каждого чанка есть путь, start_line, end_line и полное тело.
  • Индексирует чанки в Tantivy (BM25, Rust, быстрый). Поиск возвращает markdown-блок, готовый улететь в контекст LLM — с подсветкой языка, номерами строк и телом.
  • Держит структурный outline каждого файла и проекта — markdown-дерево символов и импортов. Агент смотрит на карту до того, как начинает что-то писать.
  • Следит за файловой системой через watchdog с debounce. Ты редактируешь файл — индекс догоняется сам. Не нужно вручную реиндексировать, не нужно ждать cron-а.
  • Работает под несколько проектов одновременно. Каждый — отдельный именованный индекс с собственными настройками включения/исключения.
  • Живёт в Docker и подключается к любому MCP-клиенту одним snippet-ом в mcp.json.

Что оно не делает

  • Не отправляет код во внешние API. Ни в OpenAI, ни в кого. Индекс и поиск — на твоей машине.
  • Не вызывает LLM. RetriFORever — это retrieval, а не ассистент. LLM живёт на стороне клиента.
  • Не строит эмбеддинги. См. выше.
  • Не пытается быть vector DB. Это не Qdrant, не Weaviate, не pgvector. Это Tantivy + SurrealDB, склеенные PyO3-мостом.
  • Не кэширует за тебя файлы из Dropbox и не индексирует S3. Ровно один use case: код в локальной FS.

Что получает агент — на реальном коде этого репозитория

Чтобы не выдумывать пример, я прогнал оба инструмента по core/ самого RetriFORever (18 файлов, 174 чанка). Запрос — естественный человеческий вопрос, которым агент пользуется чаще всего: «remove file from index».

Классический code search / ripgrep-style

$ rg "remove file from index" core/src/
(нет совпадений)

Ноль. Потому что в коде нет такой строки буквально — функция называется remove_file, а «from index» живёт в комментарии соседним абзацем. Для естественного запроса grep бесполезен. Чтобы получить хоть что-то, нужно заранее знать имя символа:

$ rg "remove_file" core/src/
core/src/index/trait_def.rs:39: async fn remove_file(&self, project_name: &str, file_path: &str) -> …
core/src/index/tantivy.rs:411:  async fn remove_file(&self, project_name: &str, file_path: &str) -> …
core/src/lib.rs:238:            pub fn remove_file<'p>(
core/src/lib.rs:247:                .remove_file(&project_name, &file_path)

Четыре строки, ноль тел, ноль ранжирования, ноль понимания, что из этого — объявление trait-метода, что — реализация, а что — вызов. Агент либо читает три файла целиком, либо додумывает сигнатуру сам.

RetriFORever на тот же запрос

await core.search("retriforever", "remove file from index", top_k=3)

Возвращает (форматирование — ровно то, что уходит в MCP-клиент):

1. core/src/lib.rs:238-251  [method PyRetriforeverCore.remove_file]  score 42.62
```rust
pub fn remove_file<'p>(
    &self,
    py: Python<'p>,
    project_name: String,
    file_path: String,
) -> PyResult<Bound<'p, PyAny>> {
    let index = Arc::clone(&self.index);
    future_into_py(py, async move {
        index
            .remove_file(&project_name, &file_path)
            .await
            .map_err(|e| runtime_err("IndexError", e))
    })
}
```

2. core/src/index/tantivy.rs:411-444  [method SearchIndex for TantivySearchIndex.remove_file]  score 40.06
```rust
async fn remove_file(&self, project_name: &str, file_path: &str) -> Result<(), IndexError> {
    let index = self.get_or_create_index(project_name).await?;
    let writer = self.get_writer(project_name, Arc::clone(&index)).await?;
    let fields = Fields::from_schema(&index.schema(), project_name)?;
    ...
}
```

3. core/tests/chunk_index.rs:179-198  [function remove_file_drops_all_chunks_for_path]  score 24.71
```rust
async fn remove_file_drops_all_chunks_for_path() {
    let (_dir, idx) = make_index();
    idx.index_chunks(PROJECT, "/repo/d.py", "python", "h", "",
        &[chunk("a", None, 1, 2, "x"), chunk("b", None, 3, 4, "y")])
        .await.expect("index");

    idx.remove_file(PROJECT, "/repo/d.py").await.expect("remove");
    ...
}
```

Три попадания, правильно упорядоченные: PyO3-обёртка → Tantivy-реализация → регрессионный тест. У каждого есть kind, parent, диапазон строк и тело. Агент не гадает — он видит, где контракт, где реализация и где фиксация поведения, всё в ~200 токенах против «читай три файла целиком».

Ровно этого и не хватает встроенному retrieval в любой современной IDE.

Структура проекта как контекст — code_outline

Второй инструмент, ради которого всё затевалось. Перед тем как что-то искать или править, агенту полезно увидеть карту файла — какие там символы, где они лежат, что импортируется. Это и есть главный анти-галлюцинационный приём: модель не придумает метод, которого нет, если ей показали реальный список методов.

Вот code_outline на core/src/index/tantivy.rs, дёрнутый ровно как это делает MCP-клиент, реальный вывод:

core/src/index/tantivy.rs  [rust]
  imports: use std::collections::HashMap; | use std::path::{Path, PathBuf};
           | use async_trait::async_trait; | use std::sync::{Arc, Mutex};
           | use tantivy::collector::TopDocs;
           | use tantivy::query::{AllQuery, Query, QueryParser, TermQuery};
           | use tantivy::schema::{Field, IndexRecordOption, Schema, Term, …};
           | use tantivy::{doc, DocAddress, Index, IndexWriter, TantivyDocument};
           | use crate::errors::IndexError;
           | use crate::index::trait_def::SearchIndex;
           | use crate::types::{ChunkKind, ChunkMeta, CodeChunk, SearchResult};

  [struct]   struct Fields {                                   :36-48
  [impl]     impl Fields {                                     :50-71
    [method]   fn from_schema(schema:                          :51-70
  [function] fn build_schema() -> Schema {                     :75-92
  [struct]   pub struct TantivySearchIndex {                   :96-100
  [impl]     impl TantivySearchIndex {                         :102-242
    [method]   pub fn new(index_root:                          :105-111
    [method]   fn index_path(&self, project_name:              :113-117
    [method]   fn marker_path(index_dir:                       :119-121
    [method]   fn needs_rebuild(index_dir:                     :125-132
    [method]   fn write_schema_marker(index_dir:               :134-140
    [method]   async fn get_or_create_index(&self, project_name: :142-178
    [method]   async fn get_writer(                            :180-225
    [method]   fn drop_caches(&self, project_name:             :227-241
  [function] fn build_index_on_disk(index_dir:                 :247-309
  [function] fn get_str(doc:                                   :313-317
  [function] fn get_u64(doc:                                   :321-323
  [function] const fn clamp_u32(value:                         :328-334
  [impl]     impl SearchIndex for TantivySearchIndex {         :337-702
    [method]   async fn index_chunks(                          :338-409
    [method]   async fn remove_file(&self, project_name:       :411-444
    [method]   async fn file_hash(                             :446-498
    [method]   async fn list_chunks(                           :500-584
    [method]   async fn search(                                :586-671
    [method]   async fn rebuild_index(&self, project_name:     :673-701

Что здесь важного — сразу:

  • Импорты как часть структуры. Агент видит, что tantivy::query::QueryParser и crate::types::CodeChunk реально используются в файле, и не выдумает retriforever::search::MagicQuery.
  • Вложенность impl → методы. Он различит, что index_chunks — это метод impl SearchIndex for TantivySearchIndex, а не свободная функция. На чистом grep этого не понять без контекста файла.
  • Точные диапазоны строк. Можно запросить ровно нужный метод отдельным read / reindex_file, а не читать файл на 700 строк.
  • Фильтры по виду. code_outline(file_path=..., kind="method") отдаст только методы; kind="impl" — только trait-реализации. Это дёшево (данные уже в индексе) и точно.

Это ещё не совсем граф зависимостей между файлами — определения и их использования пока не связаны рёбрами. Но фундамент уже лежит: каждый use / import распарсен, каждый символ имеет место жительства. Построить поверх этого directed graph definition ↔ reference — следующая задача из roadmap, и она ляжет органично, потому что индекс и так всё это знает.

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

Нужны Docker и Docker Compose. Дальше одна команда:

git clone https://github.com/srose69/retriforever && cd retriforever
./install.sh

Скрипт:

  1. Проверяет Docker;
  2. Собирает образ (Rust + maturin wheel → Python runtime);
  3. Поднимает контейнер с healthcheck;
  4. Пишет .env под твою машину;
  5. Печатает готовый snippet для mcp.json.

После этого — обязательный рестарт IDE. MCP-клиенты не умеют hot-reload, и если ты не перезапустишь редактор, сервер просто не подцепится.

Для установки руками AI-агентом (Cursor, Claude Code, Windsurf и прочие, кто умеет выполнять команды в терминале) есть отдельный гайд — install.AGENTS.md. Он написан в императиве «сделай это, потом это».

Ручной запуск, если install.sh не по душе

cp .env.example .env            # поправь RETRIFOREVER_SCAN_ROOT
docker compose up -d --build

Шаблоны конфигов: mcp_config.example.json, .retriforever.example.

MCP tools

Семь инструментов. Имена намеренно глагольные — чтобы агент без подсказок понял, что вызывать.

Tool Когда агент это зовёт
parse_project(project_name, project_path) Первый раз видит проект. Строит индекс с нуля, регистрирует имя.
get_project_status(project_name) Проверяет, что индекс готов (ready), а не ещё строится (parsing).
list_projects() Забыл, как он назвал проект пять минут назад.
remove_project(project_name) Снести индекс и начать заново.
search_code(query_text, top_k=10, project_name=?) Основной хлеб. BM25-поиск по чанкам. Возвращает ранжированные куски кода с телом и строками.
reindex_file(file_path, project_name=?) Знает, что файл только что изменился, и не хочет ждать watcher. Обходит hash-гард.
code_outline(project_name=?, file_path=?, kind=?) Хочет увидеть карту символов до того, как писать код. Антигаллюцинационная подпорка номер один.

search_code и code_outline — два инструмента, ради которых этот проект вообще существует. Остальные — гигиена.

Конфигурация проекта — .retriforever

Положи в корень проекта. TOML. Всё опционально.

language = "python"                       # подсказка для парсера, если авто не сработает
include  = ["src/**/*.py", "lib/**/*.py"]
exclude  = ["**/tests/fixtures/**", "**/.venv/**", "**/node_modules/**"]

Без файла работают дефолты: все поддерживаемые расширения, стандартные ignore-паттерны (node_modules, target, .git, __pycache__, dist, build, и т.д.). Полный пример — .retriforever.example.

Архитектура

┌─────────────────────────────────────────────────────────────┐
│ MCP client  (Cursor / Claude Code / Windsurf / твой агент)  │
└──────────────────────────────┬──────────────────────────────┘
                               │  stdio / http
                               ▼
┌─────────────────────────────────────────────────────────────┐
│ Python MCP layer                                            │
│   • FastMCP tools  (search_code, code_outline, …)           │
│   • tree-sitter parsers  (py / js / ts / rust)              │
│   • watchdog live reindex  (debounced async queue)          │
│   • project manager + registry + .retriforever loader       │
└──────────────────────────────┬──────────────────────────────┘
                               │  PyO3 async bridge
                               ▼
┌─────────────────────────────────────────────────────────────┐
│ Rust core  (retriforever._core)                             │
│   • Tokio coordinator  (per-project semaphore, statuses)    │
│   • Tantivy chunk index  (BM25, schema-versioned)           │
│   • SurrealDB + RocksDB  (persistent local store)           │
│   • xxHash64 file fingerprints  (incremental reindex)       │
└─────────────────────────────────────────────────────────────┘

Почему разделение именно такое:

  • Парсеры в Python. Потому что экосистема tree-sitter там богаче, потому что добавить новый язык — это 100 строк, а не cargo-workspace. MCP-обвязка тоже удобнее в Python: FastMCP + async.
  • Индекс и хранилище в Rust. Потому что на горячем пути не хочется GIL, GC и asyncio. Tokio + Tantivy + RocksDB — это три скучные кирпича, которые давно работают.
  • PyO3 async bridge между ними. Потому что гонять сеть между своими же процессами ради retrieval — это оверхед ради ничего.

Python-пакет — retriforever. Нативное ядро — retriforever._core, собирается maturin-ом из core/Cargo.toml и ставится рядом с чистым Python-кодом.

Как это устроено под капотом

Индексация. parse_project ходит по include/exclude, для каждого файла считает xxHash64. Если хэш совпадает с тем, что в RocksDB — файл пропускается. Иначе парсер достаёт чанки, Rust-ядро пишет их в Tantivy одной транзакцией и обновляет hash-entry.

Чанк. Это запись в Tantivy со схемой: project, file_path, kind (function / class / method / import / …), symbol, signature, body, start_line, end_line, imports. Индексируются symbol, signature и body; остальное хранится как stored fields.

Поиск. BM25 по объединённому полю, top_k обрезается коллектором TopDocs::with_limit(k).order_by_score(). Результат форматируется в markdown-блок с языком из расширения файла.

Инкрементальность. Watcher ловит событие → debounce 300 ms → парсер перечитывает файл → старые чанки этого файла удаляются по file_path → новые пишутся. Операция атомарна в рамках файла.

Миграции схемы. Индекс помечен версией. Если схема поменялась — индекс перестраивается с нуля, без ручной команды.

Поддерживаемые языки

Язык Чанки
Python function, class, method, import
JavaScript function, arrow_function, class, method, import
TypeScript / TSX function, class, interface, type_alias, method, import
Rust function, struct, enum, trait, impl + методы, mod, type_alias, use

Добавить новый язык — написать класс по интерфейсу ParserInterface из python/src/retriforever/parsers/base.py. Готовый шаблон — parsers/rust.py, самый компактный из всех. Грамматик в tree-sitter-ecosystem много; тот же Go, Java, C/C++, Ruby — дело одного файла каждый.

Разработка

# Rust
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test

# Python
cd python
./.venv/bin/ruff check src tests
./.venv/bin/ruff format --check src tests
./.venv/bin/mypy --strict src/retriforever
./.venv/bin/pytest tests -q

CI должен гонять всё это с -D warnings и --strict. Линты намеренно параноидальные: clippy::pedantic + nursery в Rust, pylint 10.00/10 в Python.

./build.sh делает то же самое одной командой плюс собирает Docker-образ, если docker доступен.

Собрать native wheel руками:

maturin build --release --manifest-path core/Cargo.toml
python -m pip install target/wheels/retriforever-*.whl

FAQ

«А почему не Sourcegraph / rg / ctags?» Sourcegraph — продукт для команд, с сервером и лицензией. rg — прекрасен, но он ищет по строкам, а не по символам, и не умеет отдавать функцию целиком. ctags умеет символы, но не умеет в ranked retrieval. RetriFORever — это символы + ranked retrieval + MCP-протокол для LLM-агентов в одной коробке.

«А embeddings хотя бы опционально будут?» Может быть. В виде опционального реранкера top-N поверх BM25, локально, без сетевых вызовов. Но только как надстройка, не как основа. Сначала должна надёжно работать формальная часть.

«Оно привязано к Cursor?» Нет. Это чистый MCP-сервер. Работает с Cursor, Claude Code, Windsurf, Zed (когда они добавят MCP), любым самописным клиентом. Snippet в mcp.json — единственная точка интеграции.

«Почему Rust + Python, а не один язык?» Потому что так меньше кода и меньше страданий. Parser-обвязка на Rust вокруг tree-sitter — это боль. MCP-обвязка на Rust — это ещё большая боль. А Tantivy в Python через FFI — это по производительности примерно как Lucene на Jython. PyO3 здесь — самое тупое и самое рабочее решение.

«Что с масштабом?» Текущая цель — репозиторий одного человека или команды, до сотен тысяч чанков. Tantivy на таком объёме тривиально держит миллисекундные запросы. Если у тебя монорепо на 50 млн строк — RetriFORever в такой форме тебе не подойдёт, и честно говоря, встроенный IDE-RAG тебе там тоже не подойдёт.

Roadmap

  • Symbol graph — ребра definition ↔ reference между чанками. Это то, что нужно агенту, чтобы ходить по коду, а не только искать по строкам.
  • Опциональный локальный реранкер — маленький cross-encoder поверх top-N BM25. Только как надстройка.
  • Go, Java, C/C++, Ruby — все грамматики уже есть в tree-sitter, нужно написать адаптеры.
  • Incremental outline cache — чтобы code_outline большого проекта отдавался мгновенно.
  • HTTP transport для MCP — для случаев, когда stdio не подходит (удалённый хост, несколько клиентов).

Лицензия

MIT. Делай что хочешь. Не выдавай за своё.

About

A local retrieval layer for AI code assistants. No cloud, no embedding magic, no subscription for what ctags used to do.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages