Извлекаем текст и прямо говорим, когда не можем
Из обычного текста, markdown, журналов, CSV, JSON, DOCX и PDF. DOCX распаковывается как архив, и читается XML-документ внутри; PDF проходит через стандартный extractor с сохранением разметки страницы, а если его нет, есть резервный вариант, который читает текстовые операторы прямо из файла. Важный случай — когда это не работает: отсканированный PDF, который является изображением, не содержит выбираемого текста, и система возвращает явное сообщение — загрузите DOCX или текст, либо сначала прогоните файл через оптическое распознавание. Оптическое распознавание у нас не реализовано, и мы не делаем вид, что оно есть.
Разбиваем на фрагменты с перекрытием, разрезая между предложениями, а не внутри них
Используемые нами значения по умолчанию: 500 лексических единиц и 50 перекрытия для голосового pipeline, 1.200 символов и 150 перекрытия для текстового; на стороне голосового поставщика — 500 и 100. Перекрытие существует потому, что предложение, разрезанное посередине между двумя фрагментами, становится непригодным для обоих. Разделитель предложений написан для языков, с которыми мы работаем: он распознаёт конец предложения, за которым следует латинская **или кириллическая** заглавная буква, включая румынские диакритики. Разделитель, написанный для английского, плохо режет на румынском и ещё хуже на русском.
Индексируем с векторами там, где это запущено, и говорим, где нет
Модель представления по умолчанию — `text-embedding-3-small`, с 1.536 измерениями, запрашиваемая через наш шлюз моделей, а не напрямую у поставщика — так можно сменить поставщика, не трогая код базы знаний. В базе колонка имеет тип vector с индексом `ivfflat` по косинусному расстоянию. В миграции есть резервная стратегия: если векторное расширение недоступно, схема создаётся в любом случае с trigram-индексом по тексту, а поиск переключается на лексическое сходство. Это не гипотеза — это написано именно потому, что права на расширение не гарантированы на любой установке.
Гибридный поиск, с весами на виду
Итоговый балл — 0,7 от векторного сходства плюс 0,3 от совпадения слов, с нормализованной лексической частью. Оценивается набор кандидатов и сохраняются первые фрагменты, а контекст, отправляемый модели, обрезает каждый фрагмент до максимальной длины, чтобы один длинный документ не занял все пространство. Конфигурация задается для каждого ассистента, по умолчанию: поиск включен или выключен, максимальное число фрагментов на вопрос (по умолчанию 5, ограничено от 1 до 20), порог сходства, тип поиска — семантический, лексический или гибридный — и вес лексической части.
Ворота одобрения: знание растет из пробелов, с человеком в цепочке
Когда агент отвечает, что у него нет информации, вопрос записывается в таблицу со статусом «в ожидании», помеченный каналом, из которого он пришел — голос или виджет. Человек получает уведомление и отвечает прямо в сообщении; webhook забирает ответ, переводит запись в статус «одобрен», а оттуда она попадает в контекст агента. Возможных статусов четыре: в ожидании, одобрен, проигнорирован, дубликат — а дубликаты связываются с исходным вопросом через ссылку, чтобы не накапливались десять формулировок одного и того же пробела. Передача агенту выполняется один раз на каждую одобренную запись.
Обновление: что происходит, когда источник меняется
При добавлении вычисляется отпечаток SHA-256 содержимого; если текст идентичен чему-то уже индексированному, он переиспользуется и за новую векторную репрезентацию не платят. Когда документ изменяется, его фрагменты полностью удаляются и переписываются — полная замена по источнику, а не частичное обновление, потому что частичное обновление оставляет после себя сиротские фрагменты старой версии, а именно они и дают ответы, противоречащие сайту. У источника есть видимый цикл статусов: в ожидании, в обработке, в повторной обработке, готово или неудача, а переиндексацию можно запросить точечно, по источнику.
Мы не переиндексируем то, что не изменилось
В цикле, который запускается каждые тридцать минут по списку, который только растет, перерасчет представления для не тронутого элемента — это повторная оплата за вектор, который не мог сдвинуться. Наш цикл сравнивает момент последнего изменения с моментом последней индексации и пропускает неизмененное. Представления запрашиваются пакетами, а не по одному. Это детали затрат, но ежемесячная стоимость базы знаний почти полностью решается именно здесь.
Подключенный каталог — это не загруженный список
Разница не в формате, а во времени. Загруженный список является верным на момент загрузки: цена, запас и новые товары остаются замороженными, пока кто-то не вспомнит загрузить их снова — и обычно выясняется, что никто не вспоминает, когда клиент спрашивает о цене трехмесячной давности. Подключенный каталог читается в момент вопроса, из источника, который ведет учет. Что мы построили в этом направлении: извлекатель, который распознает коллекции товаров в JSON или CSV по обычным ключам и, когда структура необычная, просит помощи у модели с обязательством отвечать строго в заданной форме; и парсер, который читает цену со живой страницы магазина. Лучшим маршрутом остается собственный структурированный интерфейс магазина, там, где он есть — но это проверяется по каждому магазину, а не предполагается.
Обход сайта: сначала карта, потом исследование
Сначала ищется карта сайта, в обычных вариантах названия, и разворачиваются вложенные карты на двух уровнях. Только если карты нет, переходят к обходу в ширину, строго по тому же происхождению, с настраиваемым лимитом страниц (по умолчанию 50, максимум 200). Фактически использованная стратегия сообщается обратно — карта или обход — потому что по ней видно, исходит ли бедный результат от сайта или от метода. Учитывается файл исключений сайта.