Есть момент, когда приложение для заметок перестаёт себя оправдывать. Это не тот день, когда кончилось место, а первый раз, когда вы точно знаете, что что-то записывали, ищете это, а приложение отвечает, что ничего не найдено.
Вы это записали. Заметка на месте. Просто вы искали «что-то про ценовые уровни», а в заметке написано «три тарифа, якорь — средний SKU». Ни одного общего слова.
Этот сбой тем хуже, чем ценнее ваш архив. Когда заметок сто, вы помните формулировки. Когда десять тысяч, помните только мысль. Поиск по ключевым словам слабее всего именно тогда, когда он нужнее всего.
Notez на каждый запрос запускает два поиска и объединяет результаты. Этот пост о том, как это устроено, включая места, которые грубее, чем вы могли бы подумать.
Первая ветка: ключевые слова
Ветка ключевых слов — обычный SQL-поиск подстроки. Запрос превращается в %query% и без учёта регистра сравнивается:
с названием заметки
с простым текстом заметки (форматированный документ при сохранении превращается в плоский текст)
со значениями пользовательских свойств — названиями вариантов в свойствах с одиночным и множественным выбором и содержимым текстовых полей
Последний пункт важнее, чем кажется. Если у вас есть свойство Client с вариантом Acme, поиск по acme найдёт все заметки с этим значением, даже если в тексте такого слова нет.
Ветка возвращает до 50 результатов, сначала недавно изменённые.
В чём эта ветка сильна: в точных совпадениях. Идентификаторы, коды ошибок, имена, фраза, которую вы помните дословно. Набрав NOTE_MODIFIED, вы хотите сразу увидеть первой заметку, где есть ровно эта строка.
В чём слаба: во всём остальном. Поиск подстроки не прощает неточностей: одна опечатка в запросе, и ничего не найдено. Синонимы не находятся. Понятия не находятся.
Вторая ветка: смысл
Семантическая ветка превращает запрос в вектор и ищет текст заметок, который лежит рядом с ним в этом пространстве.
Конкретно: модель OpenAI text-embedding-3-small превращает запрос в эмбеддинг размерностью 1536, а Postgres (с pgvector) ранжирует сохранённые фрагменты заметок по косинусному расстоянию до него. Возвращается до 20 заметок.
В чём эта ветка сильна: в том самом случае, на котором сломался поиск по словам. «Ценовые уровни» и «три тарифа» оказываются рядом в пространстве эмбеддингов, потому что модель обучена на таком объёме текста, что знает: это одна тема.
В чём слаба: в точности. У векторов нет понятия «ровно эта строка». Поищите UUID, и семантическая ветка уверенно выдаст что-то близкое по теме и бесполезное.
Ни одной ветки по отдельности не хватает. В этом и весь смысл запускать обе.
Что превращается в эмбеддинги
Нельзя превратить целую заметку в один вектор и ждать хороших результатов. Заметка о встрече на 4000 слов, усреднённая в одну точку, не близка ни к чему конкретному: это пятно, размазанное по всем темам, которых она касается.
Поэтому перед расчётом эмбеддингов заметки режутся на фрагменты, и нарезка следует структуре самого документа:
Название добавляется в начало текста до всех остальных шагов, поэтому формулировки названия попадают в индекс, а не остаются отдельным полем, по которому никто не ищет.
Разбивка по заголовкам. Каждый заголовок начинает новый раздел. Заголовок без текста под ним всё равно индексируется отдельно, потому что заголовок несёт смысл, даже если раздел пуст.
Разбивка длинных разделов по абзацам. Всё, что длиннее 2000 символов, режется по границам абзацев, а не посреди предложения.
Склейка мелких фрагментов с соседними. Всё, что короче 100 символов, присоединяется к соседнему фрагменту, а не выбрасывается.
Последнее правило появилось из-за ошибки. Ранняя версия выбрасывала слишком короткие фрагменты, и от заметки из нескольких коротких разделов — а именно так выглядит заметка о встрече, написанная списками — почти ничего не оставалось. Формально заметка была проиндексирована. Найти её было нельзя.
Каждый сохранённый фрагмент помнит свой заголовок и позицию в документе, поэтому семантическое совпадение показывает, какая часть длинной заметки подошла, а не только то, что совпадение нашлось где-то в заметке.
Длинная заметка даёт много фрагментов, поэтому запрос берёт втрое больше результатов, чем нужно, и оставляет от каждой заметки только фрагмент с лучшей оценкой. Иначе одна разросшаяся заметка заняла бы все места в выдаче.
Два порога и почему они разные
Косинусное расстояние начинается с 0 (то же направление) и растёт. Результат считается совпадением, только если расстояние ниже порога, и у Notez два разных порога для двух разных задач.
Для поиска — 0,62. Строго.
Для «Похожих заметок» — 0,8. Мягко.
Разница не случайна. «Похожие заметки» — подсказка внизу заметки, которую вы уже читаете: неточное совпадение там стоит вам одного взгляда. Результаты поиска — это утверждение, что эти заметки отвечают на ваш запрос.
С порогом 0,8 поиск стал бесполезным, и заметно это стало не сразу. Произвольный запрос совпадал почти с любой короткой заметкой — эмбеддинги короткого текста лежат где-то посередине всего, — и поэтому в непустом рабочем пространстве поиск никогда не мог показать «Заметки не найдены». Любой поиск что-то возвращал. Часть этого была мусором, и ничто не отличало мусор от настоящих результатов.
Поиск, который никогда не может сказать «здесь ничего нет», — не поиск. Это генератор случайных заметок с лишними шагами.
Как объединяются две ветки
Эта часть проще, чем можно подумать: сначала идут результаты по ключевым словам, по порядку. Потом в конец добавляются семантические результаты, которых ещё нет в списке.
И всё. Ни переранжирования кросс-энкодером, ни слияния оценок, ни reciprocal rank fusion. Обе ветки работают параллельно, дубли убираются по id заметки, а при равенстве выигрывает ветка ключевых слов просто потому, что идёт первой.
Говорю об этом прямо, потому что у этого есть заметное следствие: идеальное семантическое совпадение может оказаться ниже посредственного совпадения по словам. Если ваш запрос — частая подстрока, верх выдачи займут совпадения по подстроке.
Настоящее совместное ранжирование было бы реальным улучшением, но его нет. А то, что есть, — честное объединение, а не ранжирование.
Фильтры действуют на обе ветки
Что бы вы ни выбрали в фильтре — теги, значения пользовательских свойств, корзину, — это ограничивает оба поиска, а не только поиск по словам. Семантическая ветка добавляет эти условия в тот же запрос, что и векторное расстояние, поэтому с фильтром семантическая половина результатов молча не пропадает.
Что происходит, когда семантическая ветка ломается
Чтобы получить эмбеддинг запроса, нужен сетевой вызов внешней модели. Сетевые вызовы падают.
Когда семантическая ветка выбрасывает исключение, ошибка пишется в лог, а ветка возвращает пустой список. Поиск не падает. Вы получаете результаты по ключевым словам — ровно то, что дало бы любое обычное приложение для заметок, — и страница отображается.
Так сделано намеренно: модная половина функции не должна ронять скучную. Откатиться к простому поиску по словам — плохой день. Показать страницу ошибки, потому что векторная база ответила медленно, — день похуже.
Коротко о «Похожих заметках»
У каждой заметки есть панель «Похожие заметки», и работает она чуть иначе, чем поиск: вместо эмбеддинга запроса она берёт эмбеддинг первого фрагмента самой заметки и находит ближайших соседей в том же рабочем пространстве, исключая саму заметку. До пяти результатов, с более мягким порогом 0,8.
Эта функция находит то, что вы бы никогда не стали искать: чтобы искать, нужно уже подозревать, что связь есть.
Ограничение рабочим пространством здесь держится на соединении с таблицей заметок, а не на строках эмбеддингов: своего столбца рабочего пространства у них нет. Если здесь ошибиться, рабочее пространство для работы начнёт рекомендовать ваши личные заметки.
Как искать с толком
С учётом того, как ветки делят работу:
То, что вы помните точно, — имя, текст ошибки, идентификатор — набирайте дословно. Ветка ключевых слов найдёт это сразу.
Для того, что помните только в общих чертах, напишите короткую фразу о мысли, а не о словах. «Почему мы убрали ретраи в очереди» сработает лучше, чем попытки угадать исходные формулировки.
Не пишите длинных запросов. Эмбеддинг очень длинного запроса — среднее нескольких тем, и он ни с чем толком не совпадает.
Сочетайте поиск с фильтрами. Если перед поиском сузить выборку до одного тега, широкий запрос становится точным, причём для обеих веток.
Если нашли заметку, но не нужное место, откройте её и загляните в «Похожие заметки»: часто именно там находятся остальные три заметки на эту тему.
Чего нет
Чтобы вам не пришлось выяснять это на собственном опыте:
Нет нечёткого поиска по словам, устойчивого к опечаткам. Ошибётесь в слове — ветка ключевых слов промолчит.
Нет операторов поиска: ни фраз в кавычках, ни
AND/OR, ни поиска по отдельным полям.Нет общего ранжирования по релевантности для двух веток, как описано выше.
Нет сохранённых поисков. Фильтры хранятся в URL, и страницу можно добавить в закладки, но именованных представлений нет.
Ничего из этого не секрет, и ничто из этого не причина, по которой заметку трудно найти. Причина почти всегда та, с которой начался этот пост: вы искали слова, а записывали мысль.
Два поиска, объединённые в один список, решают именно эту проблему. Это не магия. Просто признание того, что человек помнит свои записи по смыслу, а запрос LIKE — нет.
Читайте также
Ссылки, которые переживают переименование — почему Notez хранит в ссылке id целевой заметки и переименование не ломает ни одной ссылки на неё.
Два ассистента, один набор инструментов — как ИИ-чат и MCP-сервер разошлись до шестнадцати и десяти инструментов и какая ошибка в дереве заметок нашлась, пока их выравнивали.
Notez: приложение для заметок, созданное для разработчиков — что такое Notez, какими путями в него попадают заметки и что входит в каждый план.