Десять с лишним лет теряю заметки. Часть 2: почему данные в базе и поиск без RAG

Это вторая часть цикла про заметочник, который я пилю по вечерам (и ночам). В первой я рассказал, как терял заметки где‑то между Notepad++, Trello, OneNote и Obsidian, — а сейчас расскажу, как собираюсь их находить.

Поиск с рендером

Поиск с рендером

В прошлой части проект назывался Salvera. Пока вы её читали, Salvera успела стать Nitinol — это сплав никеля и титана, который при нагревании восстанавливает форму. Название понравилось и показалось несколько метафоричным.

От боли к требованиям

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

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

Инструмент должен выживать при нулевой дисциплине. Никакой обязательной структуры. Порядок — приятная опция, а не условие работы. Чаще всего нет времени всё хорошо структурировать.

Данные остаются на моём компьютере. В моих заметках лежат куски логов, конфиги и протоколы встреч — каюсь, иногда и пароли. Значит: локальное хранение и шифрование. Я больше не хочу терять накопленные знания и не хочу, чтобы кто‑то получил к ним доступ.

Из этих требований выросли два решения. Оба спорные, но оба осознанные.

Данные — в базе, файлы — на выход по требованию

Вот цепочка, которая меня к этому привела.

«Данные приватны» означает шифрование. Зашифровать можно и россыпь .md‑файлов, но тогда весь смысл файлов — «открой чем угодно» — исчезает: любой сторонний просмотрщик или поисковик увидит не текст, а шифр. Главное преимущество файлов испаряется ровно в тот момент, когда включаешь шифрование.

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

Одна база SQLite решает обе задачи сразу: и заметки, и поисковый индекс лежат в одном зашифрованном файле. Криптографию я конечно же не изобретал — файл базы целиком шифруется стандартным AES. Защита включается в настройках одним переключателем — пароль плюс recovery‑фраза на случай, если пароль забудется; по умолчанию защита выключена, чтобы первый запуск не начинался с придумывания пароля (кому‑то это может, вообще не сдалось). Бонусом — данные меняются целиком или никак: если приложение упадёт посреди сохранения, вы не получите полузаписанную заметку.

Чем я за это плачу: базу не откроешь блокнотом и не положишь в Git (можно, но зачем). Поэтому наружу ведут три двери, и у каждой своя задача: экспорт в обычную папку с .md‑файлами («отдайте мои данные» — читается вообще без Nitinol; всё, что сложнее возможностей Markdown, при этом честно упрощается — такова цена «читается чем угодно»), переносимый архив для восстановления на другой машине и ежедневный автоматический бэкап, который хранит последние семь копий (количество настраивается). Заложником формата данные при этом не становятся: выйти можно в любой момент.

Честно говоря, есть ещё одна причина, и она не в поиске, а в ограничениях самого Markdown. Если я хочу сделать сложную структуру — вложенность страниц, сложные таблицы, макеты, секции и любое другое нетривиальное форматирование, — то HTML справляется с этим лучше. Markdown, конечно, тоже умеет сложное, но через вставки сырого HTML, и тогда чистый текст уже перестаёт быть чистым. Ну и в целом никуда мигрировать не обязательно — всё нативно открывается прямо в Nitinol).

Поиск без нейросетей

Первое, что приходит в голову при словах «заметочник с ИИ‑ассистентом» (или, как сейчас модно говорить, «второй мозг»), — это RAG, эмбеддинги, семантический поиск. Я честно пошёл всё это пробовать.

Собрал на коленке свой RAG с векторной базой: порезал заметки на куски, прогнал через мультиязычные эмбеддинги (обычные sentence-transformers, модель уровня multilingual-e5 / MiniLM — те, что тянут и русский, и английский, без экзотики), сложил векторы в локальную базу вроде FAISS. Затем искал ближайшие фрагменты по сходству векторов, а языковая модель собирала из них ответ. Стек максимально стандартный, минимум свистоперделок.

Результаты оказались удручающими. Релевантность работала плохо, всё жутко галлюцинировало и находило похожее вместо нужного. Я пытался докрутить это гибридным поиском — полнотекст плюс векторы плюс нормализация, со взвешенным слиянием результатов, — но всё равно получалось не то. Вдобавок вся конструкция весила прилично (особенно на русском корпусе: модель под полгига, токенов на русском больше, индексация дольше), работала медленно и при всём этом давала посредственный результат. Сразу оговорюсь, чтобы не выдавать это за науку: строгого замера recall я не делал — это были прикидки на своих же заметках, по принципу «нашёл то, что искал, или не нашёл».

Когда я сел разбираться, почему так, причина оказалась не в кривых руках (ну, не только), а в самом контенте. Стектрейс, имя переменной, кусок конфига — это точный текст, а не «смысл». Ровно то, ради чего тетрадь и заводилась. Семантическая близость здесь скорее вредит: когда я ищу свой собственный parseConfig, ответ «возможно, вы имели в виду другой конфиг» помогает мало. А цена этой близости — постоянный пересчёт векторов при каждой правке, лишние полгига веса, шифрование и синхронизация ещё одного индекса.

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

Поэтому я развернулся на 180 градусов и зашёл с другой стороны. Вопрос не «как построить индекс получше», а «как вообще ищет тот, кто ищет хорошо».

А ищет он не одним запросом. Когда я сам лезу в свои заметки, я не жду, что первая формулировка попадёт в цель: набираю грубый запрос, смотрю выдачу, цепляюсь за нужное слово из найденного, повторяю. Разовый матч — хоть по векторам, хоть по словам — так не умеет: у него одна попытка, и вся ставка на то, что запрос сразу оказался удачным. А модель, которой поиск отдан как инструмент, умеет ровно это: уточнять и повторять, пока не наберётся достаточно контекста.

Отсюда вывод для меня: если ретривом рулит модель в цикле, то на моём контенте можно обойтись вообще без собственного векторного индекса — хватит хорошего лексического поиска и агента поверх него. Так и сделал. Ассистент у меня остался — но он не роется в отдельной «умной» базе, а пользуется тем же обычным поиском, что и пользователь по Ctrl+K: сам придумывает запрос, читает найденное, при необходимости повторяет. Честная оговорка: цикл пока работает с теми провайдерами, для которых я прикрутил нативные тулы, — OpenAI, DeepSeek и совместимые эндпоинты; до Anthropic и локальной Ollama руки ещё не дошли, там ассистент получает контекст одним куском. Вся семантика теперь живёт в голове у LLM, а не во втором тяжёлом индексе, который надо тащить рядом с заметками.

И чтобы меня правильно поняли: это не «RAG не работает» и не «эмбеддинги — зло». Это «конкретно мой наскоро собранный RAG на моих данных проиграл обычному поиску». Дверь не заперта: если однажды соберу честный замер и он покажет, что семантика реально находит лучше, — будет и она, и отдельная статья с разбором, где я налажал. Но платить весом, скоростью и галлюцинациями просто потому, что «сейчас так принято», я не готов.

Осталось сделать так, чтобы обычный поиск действительно находил. Из коробки он для русскоязычной код‑тетради почти бесполезен, поэтому вокруг него наросло несколько слоёв — расскажу без погружения в кишки.

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

Код. parseConfig при индексации распадается ещё и на parse и config, поэтому находится по любому из кусков. Оригинал остаётся на месте. Для блокнота, где половина контента — код, логи и обрывки технических заметок, это оказалось важнее морфологии.

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

Мелочи, которые не мелочи. «ё» и «е» считаются одной буквой (иначе «ежик» не найдёт «ёжика»); опечатки в заголовках прощаются; есть фильтры вроде before:after:tag:project: — потому что «найти» часто значит «найти то, что я писал в марте в том проекте».

Напрягался явно меньше оппонентов — и всё равно уехал с медалью.

Напрягался явно меньше оппонентов — и всё равно уехал с медалью.

И главный принцип, ради которого всё затевалось: если поставить на заметку замок, её содержимое не попадёт ни в поиск, ни в подсказки, ни к ассистенту. Не хочу, чтобы, когда я шарю экран и что‑то ищу, подсвечивались избранные ссылки с pornhub или ещё что‑то, чем я не хочу делиться с коллегами.

Обещанные цифры

В комментариях к прошлой статье @Iscander_Che подкинул дельную идею с нагрузочным тестированием — и, как выяснилось, очень вовремя: тесты обнажили довольно много проблем.

У поиска (конечно, не только у него) появился обязательный гейт: перед каждым релизом прогоняется тест, который строит шифрованную базу из 10 000 записей и проверяет, укладывается ли каждый тип запроса в отведённое ему время. Не уложился — релиз не собирается.

Вот что получилось после всех оптимизаций (тестировал на ноутбуке под Windows):

Действие

Время

Обычный текстовый запрос

2–7 мс

Поиск по началу слова (2 буквы)

~18 мс

Показать все заблокированные страницы

~150 мс

Переиндексировать весь корпус (10 000 заметок)

~2,2 с

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

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

Это уже можно потрогать

Пока писал статью, дело дошло до бета версии. Сайт — nitinol.app — оттуда можно скачать приложение и погонять его на своих заметках. Там же, прямо на странице, крутится живое демо: это не видео и не макет, а настоящий рендерер того же коммита, только с демонстрационными данными в памяти вкладки. Тяжёлые холсты из него выкинуты ради веса, так что эскизы и mermaid покажут заглушку, синк и бэкап выключены, а ассистент отвечает по сценарию — всё остальное живое. С телефона демо не откроется, нужен экран пошире.

Продукт бесплатный — и для себя, и для работы.

Про текущее состояние, чтобы никого не разочаровать. Есть сборки под Windows и Linux (.deb и AppImage). Windows‑установщик не подписан — при первой установке SmartScreen скажет «неизвестный издатель», это ожидаемо. На Ubuntu 24.04 и новее AppImage штатно не стартует, там нужен .deb. Linux я гонял только на Ubuntu под GNOME. macOS‑сборок нет вовсе и они ни разу не прогонялись — сделаю, если будут запросы. Мобильное приложение — в планах после стабильной версии.

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

Милости прошу на расстрел помидорами. В настройках приложения (как и на сайте) есть отправка баг‑репорта на почту — буду очень признателен за любую обратную связь.

Что дальше

Синхронизация между устройствами. Требование «всё локально, без облака» не отменяет второго ноутбука — и это вылилось в самую сложную часть проекта: как переносить заметки между машинами через собственное хранилище (обычную сетевую папку, WebDAV или S3), не поднимая ни одного сервера. Подавать это как решённую задачу пока не буду: были эпизоды, когда перенос тысячи изменений замораживал приложение на полминуты. Сейчас это секунды, но история того, как я всё это делал, тянет на отдельную часть.

А пока три вопроса к вам:

  1. Насколько для вас критичен доступ к заметкам с телефона? Мне кажется, в 2026-м это уже мастхэв, но интересно — вы правда читаете и правите заметки с мобилы, или телефон только чтобы быстро что‑то бросить, а разбор всё равно за компом?

  2. Часто ли приходится открывать реально большие файлы — логи и дампы на 200+ МБ? И что вы с ними делаете внутри заметочника: просто читаете, ищете по ним, режете на куски? Пытаюсь понять, стоит ли вкладываться в работу с гигантскими файлами, или это редкий кейс, который и так закрывается специализированными инструментами (klogg, lnav, ripgrep, а на объёмах — что‑то вроде Grafana Loki или ELK).

  3. Готовы ли вы отдавать содержимое заметок облачной LLM? У меня выходит противоречие: хранилище локальное и шифрованное, а самый умный ассистент — по API у облачного провайдера. Вам достаточно того, что под замком ассистент ничего не видит, или ассистент имеет смысл только с локальной моделью, пусть и глупее?

Спасибо всем, кто комментировал первую часть, — ваши отклики очень помогли. Всем добра)

Автор: gudrymudving

Источник

Оставить комментарий