Витягуємо текст і чітко кажемо, коли не можемо
Із простого тексту, markdown, журналів, CSV, JSON, DOCX і PDF. DOCX розпаковується як архів, і читається XML-документ усередині; PDF проходить через стандартний екстрактор зі збереженням розмітки сторінки, а якщо його немає, є резервний варіант, який читає текстові оператори прямо з файла. Важливий випадок — коли не працює: сканований 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). Фактично використана стратегія звітується назад — мапа або обходження — бо саме з неї видно, чи походить бідний результат із сайту, чи з методу. Файл виключень сайту дотримується.