Anthropic заблокировала меня. Я потерял подписку за $100, но сохранил год работы и собрал личную RAG-базу

Николай Носков 10 мин

Anthropic внезапно заблокировала мой аккаунт. Я связываю это с тем, что я русский: внятного объяснения, которое сняло бы этот вопрос, я не получил. Подписка за $100 сгорела.

Деньги неприятно, конечно. Но куда неприятнее было другое: больше года работы с Claude. Проекты, переписки, решения, объяснения, почему мы выбрали именно такой вариант, где уже наступали на грабли. Не просто «какой код написали», а контекст, который обычно и делает новый чат продолжением работы, а не знакомством с нуля.

Я не собирался делать RAG. Я хотел не потерять эту историю и снова использовать её в работе.

«Просто загрузить бэкап» не получилось

Архив у меня был. Экспорт Claude.ai, локальные сессии Claude Code, память, проекты и артефакты. Один conversations.json весил около 146 МБ. По грубой оценке, внутри было примерно 46 миллионов символов реального текста: часть кириллицы в JSON записана не буквами, а последовательностями вроде \u041f.

Плюс архив сессий Claude Code: сотни файлов и сотни мегабайт. Не маленькая папка, которую можно прикрепить к новому чату и сказать: «вот, теперь ты всё знаешь».

Да и даже если бы файл влезал в контекст, это не решало бы задачу. Мне не нужно каждый раз отправлять модели весь год переписок. Мне нужно спросить про конкретный проект, решение или факт — и получить несколько относящихся к вопросу фрагментов.

То есть не «загрузить историю», а научиться её находить.

Сначала не модель, а скрипты

Разбирать сотни мегабайт с помощью субагентов я не стал. Для импорта не нужно рассуждать: нужно прочитать JSON, извлечь переписки, нормализовать названия, разложить файлы и построить индекс. Это задача для обычного кода.

Я сделал набор одноразовых скриптов. Один переносил переписки Claude.ai и Claude Code, другой — память и артефакты, третий строил INDEX.md по реальной структуре. Названия проектов сводились через таблицу алиасов: один и тот же проект в переписках мог называться по-разному.

Здесь важный момент: скрипт не должен был каждый раз пересобирать «идеальную» структуру. После первого переноса я правил её руками. Поэтому последующие операции делались аддитивно: ничего не удалять и не затирать без необходимости. Одноразовые скрипты миграции остались отдельно от рабочего пакета.

Это не самая эффектная часть статьи, зато самая дешёвая по токенам. Скрипт прочитал исходник один раз. Модель не тратила контекст на миллионы символов, которые всё равно пришлось бы потом индексировать.

Markdown стал единственным источником истины

Базу я положил в Obsidian. Не потому, что Obsidian умеет в RAG, а потому что знания должны оставаться обычными файлами, которые можно открыть, прочитать и поправить руками.

Структура получилась примерно такая:

Memory/ — профиль, предпочтения и устойчивый контекст. Projects/ — конкретные проекты. Topics/ — темы, которые проходят через несколько проектов. Переписки остаются отдельными Markdown-файлами, а не сливаются в один огромный документ.

Главное решение здесь простое:

Markdown — оригинал. SQLite — индекс, который можно пересобрать.

Если завтра я удалю index.db, знания не пропадут. Можно запустить переиндексацию и получить его снова. А если я отредактирую файл в Obsidian, именно эта правка и будет правильной версией — не копия в базе данных.

Для меня это две стороны одной системы: Obsidian нужен человеку, RAG — поиску.

Почему SQLite, а не отдельная векторная база

По слову RAG часто сразу представляют отдельную векторную базу, ещё один сервис, контейнер и мониторинг. Здесь это было бы лишним.

Сейчас в базе около 12 тысяч чанков. Для такого объёма хватает SQLite: в нём лежат файлы, фрагменты и полнотекстовый индекс. Эмбеддинги хранятся там же как бинарные массивы float32, а numpy считает косинусное сходство обычным перебором.

Схема намеренно несложная:

files       — путь, хеш, дата изменения, проект и число чанков
chunks      — текст, заголовок, номера строк, tier и проект
chunks_fts  — полнотекстовый индекс FTS5
embeddings  — id чанка, модель, размерность и вектор в BLOB
meta        — версия схемы и состояние индекса

Почему так? Потому что 12 тысяч векторов — это не тот масштаб, ради которого стоит поднимать и обслуживать отдельную систему. Если данных станет на несколько порядков больше или измерения покажут, что SQLite не справляется, архитектуру можно заменить. Пока она закрывает задачу без лишней инфраструктуры.

Это не универсальный совет «всем использовать SQLite». Это ответ на вопрос «что достаточно для моей базы сейчас?».

Чанк — не просто кусок текста

Поиск работает не по целым файлам, а по чанкам. Но если нарезать текст каждые тысячу символов, можно разорвать мысль посередине. Поэтому Markdown разбивается по заголовкам и абзацам.

Целевой размер — примерно 600 токенов, жёсткий предел — 1000. Есть и маленькая защита от чрезмерного дробления: новый заголовок не обязательно закрывает текущий чанк, пока тот не набрал хотя бы 200 токенов. Иначе документ с короткими подзаголовками превращается в россыпь бесполезных обрывков.

У каждого чанка остаются заголовок и диапазон исходных строк. Это не декоративные метаданные. Поиск может сказать: «вот этот файл, строки 42–77», после чего агент читает именно этот отрывок, а не запрашивает документ целиком.

На большой базе это принципиально: модель получает не архив, а релевантный кусок и адрес, откуда он взят.

Переписка не должна заглушить профиль

Исторических чатов гораздо больше, чем коротких заметок о проектах и пользователе. Если ранжировать всё одинаково, ответ из старого диалога может вытеснить аккуратный профиль или CONTEXT.md.

Поэтому содержимое разделено на два уровня:

  • tier 1 — память, заметки и обзорные файлы;
  • tier 2 — история переписок и остальные материалы.

Уровень определяется по имени файла и расположению: например, Memory/ и корневой Notes/ идут в первый уровень, а проект — по пути Projects/<имя>/.... Это не попытка угадать смысл каждого файла. Правило видно в структуре, предсказуемо и настраивается.

В текущем индексе около 468 обзорных чанков против 11 тысяч с лишним чанков переписки. Для tier 1 при ранжировании есть небольшой множитель, чтобы он не тонул в истории.

Тут я, кстати, поймал неочевидную асимметрию: заметка в Notes/ и заметка того же типа в Projects/<проект>/Notes/ классифицируются по-разному, потому что классификатор смотрит на первый сегмент пути. Это не катастрофа, но хороший пример того, почему простые правила нужно проверять на реальной структуре, а не только читать в конфиге.

Поиск по словам и по смыслу

Точный поиск нужен, когда я помню конкретный термин, имя или технологию. Для него используется FTS5 и BM25.

Поиск по смыслу нужен, когда я помню содержание, но не исходную формулировку. Текст запроса и каждый чанк превращаются в векторы через OpenAI-совместимый API эмбеддингов. Затем векторы сравниваются по косинусному сходству.

Один поиск не заменяет другой. Поэтому результаты двух списков объединяются через Reciprocal Rank Fusion:

score[chunk_id] += 1 / (K + rank)

Если фрагмент высоко стоит и в полнотекстовой, и в векторной выдаче, он поднимается выше. Для этой базы я остановился на K = 10: при большем значении разница между первыми и средними позициями становилась слишком плоской. Обзорным материалам после объединения добавляется небольшой буст.

Векторная часть использует text-embedding-3-small, размерность 1536, запросы отправляются пачками. Сам индекс не привязан намертво к этому провайдеру: адрес API, модель, размерность, пакет и таймаут задаются в knowledge.toml, секрет берётся из переменной окружения или файла credentials.

Как модель получает доступ: MCP

Индекс сам по себе ничего не меняет в поведении нового чата. Агенту нужен интерфейс, через который можно искать, читать и записывать заметки. Для этого я подключил MCP-сервер.

Он работает по stdio и обменивается JSON-RPC 2.0. Отдельную библиотеку MCP для сервера я не подключал: достаточно небольшого обработчика протокола и существующего Python-пакета.

У модели есть инструменты для разных операций:

Инструмент Что делает
knowledge_search Ищет по словам и смыслу
knowledge_read Читает файл или диапазон строк
project_context Загружает стартовый контекст проекта
knowledge_write Создаёт или обновляет Markdown
knowledge_append Дописывает в существующий файл
knowledge_commit Сохраняет заметку о решении или сессии
knowledge_status Показывает состояние индекса
knowledge_projects Возвращает список проектов
knowledge_reindex Перестраивает индекс

Защита от потери правок встроена в сам сценарий: чтение возвращает хеш файла, а при обновлении можно передать этот хеш. Если файл успел измениться, запись возвращает конфликт вместо молчаливого перезаписывания.

Получается не просто «модель умеет читать базу», а полный цикл: найти нужное, проверить источник, применить контекст, записать новое решение.

Но инструмент ещё нужно заставить вызвать

В один из новых чатов я спросил: «как меня зовут?» Агент ответил, не обратившись к базе. Инструменты MCP были подключены. Файлы существовали. Но модель увидела косвенные признаки в локальной системе и решила, что уже знает ответ.

Это была не проблема индекса. Это была проблема поведения.

Поэтому в глобальном AGENTS.md появилось правило старта сессии: один раз прочитать профиль пользователя и искать контекст, относящийся к первому запросу. В AGENTS.md нет биографических фактов — только инструкция, где их брать и что делать с базой.

Подробности вынесены в скилл knowledge-first. Скилл — это SKILL.md с названием и описанием-триггером. Когда вопрос касается пользователя, прошлой работы или решений, ассистент подгружает эту справку по требованию. В ней описано, когда вызывать поиск, как читать найденный файл и как безопасно записывать результат.

Разделение такое:

AGENTS.md       — поведение на каждой сессии
knowledge-first — подробная инструкция по теме, загружается по триггеру
MCP             — реальные инструменты доступа к данным
Markdown        — единственное место, где живут факты

И здесь обнаружилась настоящая дыра поиска: латинское имя в Memory/profile.md плохо ранжировалось на запрос «как меня зовут». Поэтому профиль в начале сессии читается напрямую, а не ищется. Поиск хорош, но не нужно притворяться, будто он заменяет адресное чтение там, где известен точный файл.

В новом чате сценарий сработал: инструкция привела агента к профилю, он прочитал файл и ответил по нему, не выводя личные данные из имени компьютера или .gitconfig.

Obsidian редактирует напрямую — индекс должен догонять

Важное свойство этой схемы: человек может поменять файл в Obsidian, ничего не сообщая MCP-серверу. Значит, индекс надо сверять с диском.

Отдельный демон я не запускал. Перед чтением MCP-инструменты делают инкрементальную проверку Markdown-файлов, но не чаще раза в 10 секунд. На текущих нескольких сотнях файлов обход занимает около 0,03 секунды. Для изменения файла старые чанки удаляются и строятся заново; удалённый файл убирается из индекса.

Новые эмбеддинги догоняются ограниченными порциями. Если временно недоступен API, текст всё равно остаётся в Markdown и полнотекстовом индексе; вектор можно досчитать позже. Здесь есть компромисс: свежая заметка какое-то время лучше ищется по словам, пока не появился её эмбеддинг.

И вот тут я чуть не обвинил не того

На днях несколько новых чанков не получили эмбеддинги, а вызовы базы начали зависать. Прямой запрос к CometAPI возвращал HTTP 200, но тело ответа обрывалось посередине. Первое заключение было очевидным: проблема у CometAPI.

А потом я включил VPN и продолжил проверять — снова через curl. Он показывал всё ту же обрезанную загрузку. Я уже начал строить теории про прокси CometAPI, Azure и потерю пакетов.

Проблема оказалась в диагностике. На macOS curl в моей конфигурации не использовал системный прокси: переменных окружения не было. Он шёл напрямую по маршруту, который и правда обрывал ответ. Python, на котором работает RAG, системные настройки прокси читал. Когда я проверил запрос именно из Python-рантайма, шесть чанков успешно получили эмбеддинги за четыре секунды.

То есть провайдер не был сломан, а база не нуждалась в срочном переключении на другой API. Я просто двадцать минут проверял не тот сетевой путь, которым пользуется приложение.

Для меня это отдельный инженерный урок: проверять надо не абстрактную доступность API, а маршрут, HTTP-клиент и окружение реального процесса. «curl работает» и «приложение работает» — не всегда одно и то же.

Что получилось

Сейчас в базе 313 Markdown-файлов, около 11 752 чанков, 16 проектов; у всех чанков есть эмбеддинги. История не загружается в контекст целиком. На запрос система возвращает несколько фрагментов с путями и номерами строк, после чего агент может прочитать источник полностью или ограниченный диапазон.

Это не чудо-сжатие: Markdown-файлы всё ещё занимают много места. Выигрыш в другом — модель получает по задаче несколько релевантных отрывков вместо миллионов символов бэкапа.

Итоговая архитектура:

Заблокированный аккаунт не вернул мне подписку. Но больше года работы не оказался заперт в одном чате или одном сервисе. Теперь это обычные Markdown-файлы, которые я могу открыть сам, и индекс, который помогает агенту найти нужное.

Наверное, это и есть нормальная роль RAG в личном контексте: не сделать модель всезнающей, а перестать заставлять её каждый раз знакомиться с тобой и твоими проектами с нуля.

Поток сознания и мысли по таким ситуациям складываю в канал «Плуг».