Локальный retrieval-слой для AI-ассистентов по коду. Без облака, без embedding-магии, без подписки за то, что раньше делал ctags.
In development. Something might not work correctly.
Быстрый старт · MCP tools · Почему не семантический поиск · Архитектура · FAQ
Мне надоело платить подписку за IDE, в которой поиск по коду сломан by design.
Ты просишь ассистента «найди, где мы создаём пользователя» — он находит три random-файла, половина из которых устарела на полгода, и дальше уверенно галлюцинирует API, которых в проекте нет. Не потому что модель тупая, а потому что ей в контекст скормили мусор: обрезанные сниппеты, случайные строки, куски тестов и README, отобранные эвристикой уровня «похоже на запрос по косинусной близости эмбеддинга».
Дальше модель делает следующее логичное действие — достраивает то, чего не хватило, из своего претрейна. Получается код, который выглядит как твой проект, но им не является. Ты тратишь ещё один круг: «нет, у нас не так, посмотри сюда». И ещё один. И ещё.
RetriFORever — это мой личный ответ на эту проблему: вынуть retrieval из IDE, сделать его детерминированным, отдать агенту ровно то, что он просил, и не навязывать сверху ещё одну модель.
Потому что семантика — это категория естественного языка, а код — не естественный язык. Код — это формальная система с жёсткими идентификаторами, типами и структурой вызовов. Для него «смысловая близость» работает плохо по трём причинам:
- Идентификаторы — это не синонимы.
user_idиaccount_idсемантически близки, но в проекте это два разных ключа, и путать их — баг. Эмбеддинг с радостью их перепутает. - Семантическое расстояние непредсказуемо. Ты не можешь объяснить коллеге, почему top-5 по запросу «auth middleware» это
utils/date.py. А эмбеддер может. - Модель поверх модели = ошибка в квадрате. У тебя уже есть 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».
$ 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-метода, что — реализация, а что — вызов. Агент либо читает три файла целиком, либо додумывает сигнатуру сам.
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 на 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Скрипт:
- Проверяет Docker;
- Собирает образ (Rust + maturin wheel → Python runtime);
- Поднимает контейнер с healthcheck;
- Пишет
.envпод твою машину; - Печатает готовый snippet для
mcp.json.
После этого — обязательный рестарт IDE. MCP-клиенты не умеют hot-reload, и если ты не перезапустишь редактор, сервер просто не подцепится.
Для установки руками AI-агентом (Cursor, Claude Code, Windsurf и прочие, кто умеет выполнять команды в терминале) есть отдельный гайд — install.AGENTS.md. Он написан в императиве «сделай это, потом это».
cp .env.example .env # поправь RETRIFOREVER_SCAN_ROOT
docker compose up -d --buildШаблоны конфигов: mcp_config.example.json, .retriforever.example.
Семь инструментов. Имена намеренно глагольные — чтобы агент без подсказок понял, что вызывать.
| 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 — два инструмента, ради которых этот проект вообще существует. Остальные — гигиена.
Положи в корень проекта. 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 -qCI должен гонять всё это с -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«А почему не 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 тебе там тоже не подойдёт.
- 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. Делай что хочешь. Не выдавай за своё.