Еволюція гетерогенного пошуку
Архівні дані не підлаштовуватимуться під ваші ідеальні схеми. Ще менше підлаштуються під вас – інші дослідники, які індексують архівні справи згідно зі власними уявленнями про те, що цікаво, а що ні, які таблички навести дослівно, які злити з сусідніми, а які геть пропустити. У цьому тексті я розкажу, як зміг подружити розмаїті таблички в межах одного пошукового індексу на VPS за 300 гривень на місяць (на жаль, уже 800).
Передісторія
На світанку проєкту, в часи, коли я ще планував простий сайт генеалогії села Політанок – politankians.surge.sh, в мені жила ілюзія контролю. Усі метричні книги з одного села чудово лягали у знайому матрицю: «Прізвище, Ім'я, По-батькові, Подія, Роль, Дата, Фонд, Опис, Справа». Дані жорстко перевірялися схемами zod. Але щойно амбіції вийшли за межі одного населеного пункту, ця модель розсипалася.
Якимись такими були мої власні індексації метричних книг:
Прізвище,Ім'я,По-батькові,Архів,Фонд,Опис,Справа,Аркуш,Акт,Тип акту,роль,Вік,Дата події,Пошт. Індекс,Поселення храму,Примітка
Глушкова,Матронна,?,ДАХмО,315,1,6679,327,1,хрещення,дитина,,7.01.1796,23542,Політанки,
Глушков,?,,ДАХмО,315,1,6679,327,1,хрещення,батько,,7.01.1796,23542,Політанки,
Глушкова,Матрона,,ДАХмО,315,1,6679,327,1,хрещення,мати,,7.01.1796,23542,Політанки,
,Евдокия,,ДАХмО,315,1,6679,327,1,хрещення,хрещена,,7.01.1796,23542,Політанки,
,,Танасов,ДАХмО,315,1,6679,327,1,хрещення,чоловік хрещеної,,7.01.1796,23542,Політанки,
Проте далі я замислився: а який сенс узагалі обмежуватися Політанками? Я ж можу масштабуватися на всю Україну! Інші дослідники ділитимуться табличками, і я сяк-так адаптую їх під свій формат! Найцікавішим здавався доробок усім відомого Володимира Цибульського, який регулярно викладав списки прізвищ щодо перепису 1897 року по Уманському повіту.
Я навіть встиг перефарбувати сайт Політанчан, уже тоді з'явилася перша версія Коренів.
Пошук 1.0
Отже, я виклав у UAGenealogy оголошення з проханням присилати таблички для нового проєкту – і взявся аналізувати те, що отримував. А бувало, наприклад, таке:
Католики,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
,Абрамович,Бабанін,Білич,Боруцький,Вадицький,Винницький,Врічан,Габіх,Гослінський,Давидовський,Дідковський,Еденштейн,Єлєрт,Желєвський,Заблоцький,Зилинський,Іваневич,Йогеман,Каз,Клімберг,Корчевський,Крищінський,Лабендський,Лінчевський,Магнушевський,Медушевський,Мойсивич,Навіцький,Овдинський,Павловський,Пжечарлінський,Понятовський,Рабчевський,Рисявий,Рудковський,Сабашук,Синицький,Станіславов,Тальма,Туральський,Улішевський,Фабіан,Халупський,Цебень,Чайковський,Шамринський,Шуляковський,Шупальський,Юзвік,Яворський
Легко зрозуміти, що це геть інше, ніж те, що я очікував. Так, це не індексація метричної книги, – це лише список прізвищ по перепису. Проте це ж, з боку інформатики, взагалі не таблиця. Проте це чудові дані, зібрані в той спосіб, який був зручний автору.
Можливо, можна було закинути дані в ШІ та попросити з ними щось зробити... Проте щоразу шукати нетривіальний спосіб вирішити кожну нову проблему, коли кожен дослідник принесе список прізвищ по своєму рідному селу... Я не захотів. Я вирішив розв'язати ДЖЕРЕЛО цих проблем, пращура цих проблем, якщо хочете. Я вирішив зробити рушій таким, аби він дозволяв шукати взагалі по чому завгодно, неначе Гугл. (Ну, не зовсім Гугл, усе таки обмежився форматом CSV, і є ще кілька обмежень, але про них пізніше.) Потрібен був рушій для пошуку, який би таке підтримував.
Виникла потреба в пошуковому рушії, здатному перетравлювати гетерогенні дані на дешевому VPS. Algolia була б чудовим сервісним варіантом, але для Коренів я не хотів прив'язувати пошук до usage-based SaaS-моделі: зі зростанням кількості записів і запитів витрати могли б стати непередбачуваними (див. Algolia pricing). Elasticsearch вимагав би значно більше оперативної пам'яті, ніж я планував орендувати (див. Elasticsearch: JVM settings and heap size). Рішенням став Typesense — блискавичний In-Memory рушій на C++ (див. Typesense FAQ — in-memory data store), який обіцяв магію динамічних схем (auto-schema) (див. Typesense: Collections — Auto Schema Detection).
Обмеження
Попри те, що Корені "їдять" майже все, є кілька обмежень, котрим повинна відповідати табличка:
- Лише CSV, а отже, не підходять:
- простий текст
- багаторівневі заголовки колонок, в т.ч. зі злиттям
- Кожна колонка мусить мати заголовок
Інші вимоги стосуються метаданих: вказати, яких років стосується табличка, яких архівних справ, якого географічного місця тощо, – це тема для іншої розмови. Достатньо все це заповнити, виконати вимоги до таблички, описані вище, – і рушій "магічно" все прийме.
Інцидент «Крентовський»
Проте 18 березня 2025 року ця «магія» спровокувала колапс. Система перестала знаходити існуючі записи — помічено було на прізвищі «Крентовський». Як я зрозумів проблему згодом, причиною був вибух кількості динамічних полів: wildcard-схема змушувала Typesense індексувати дедалі більшу кількість різнорідних ключів... Оперативна пам'ять сервера була витрачена не на самі дані, а на підтримку роздутої структури індексів. На жаль, зрозумів я суть проблеми аж за кілька місяців.
Єдиним рішенням стала повна зміна парадигми та концептуальне розділення даних для машини та для людини.
Пошук 2.0
Вихідна структура таблиці більше не має значення. Кожен рядок CSV тепер розглядається як узагальнений об'єкт Record<string, unknown>. Під час індексації система пропускає його через трансформацію, формуючи пошукову проєкцію:
values(string[]): Масив, отриманий черезObject.values(row). Усі комірки кожного запису просто зливаються в один список. Саме цей масив завантажується в оперативну пам'ять Typesense для повнотекстового пошуку (query_by: values).raw(object): Оригінальний об'єкт із первинними колонками. Він узагалі не використовується для пошуку, а лише лежить поруч як вантаж, щоб повернутися на фронтенд для точного відтворення вихідної структури.id: Зв'язка у форматі<table-id>-<row-number>для збереження контексту.
У коді ця зміна парадигми виглядає майже буквально так:
function convertRow(
row: Record<string, unknown>,
number: number,
table: IndexationTable,
location: [number, number],
) {
const values = Object.values(row).map((value) => String(value).trim());
return {
id: `${table.id}-${number + 1}`,
location,
tableId: table.id,
title: table.title,
year: determineRowYear(row, table),
values,
raw: row,
};
}
Наприклад, рядок із таблиці про смерть перетворюється на такий документ:
{
"id": "death-register-42",
"tableId": "death-register",
"title": "Книга смертей, 1849",
"year": 1849,
"values": ["Іваненко", "Петро", "08.01.1849", "гарячка"],
"raw": {
"Прізвище": "Іваненко",
"Ім'я": "Петро",
"Дата смерті": "08.01.1849",
"Причина": "гарячка"
}
}
Зверніть увагу на дублювання: values — це індексована проєкція, а raw — оригінал для відображення. Це не зайва копія, а свідомий компроміс між швидким пошуком і точним показом джерела.
Оскільки історичні реалії України вимагають багатомовності, фронтенд бере пошуковий запит, приводить його до нижнього регістру та генерує транслітеровані варіанти. Запит спрямовується як multi-search одразу до трьох колекцій Typesense: unstructured_pl, unstructured_ru та unstructured_uk, об'єднуючи все в єдиний ранжований список. Для фільтрації за часом пошуку парсер (на етапі завантаження даних у Typesense) намагається витягти year із відомих варіацій колонок («Рік», «Год», «Дата», «Дата народження» тощо) або ж бере його з YAML-метаданих самої таблиці. Якщо встановити не вдалося, запис може лишитися і без року (заглушка 0).
Ця flattened-модель врятувала продуктивність і дозволила масштабуватися, але ціною певного семантичного каліцтва — повної втрати походження збігу (provenance).
Оскільки повнотекстовий пошук працює сліпо, виключно по ізольованому масиву values, рушій фізично не здатен пояснити клієнту, в якому саме елементі списку знайшов співпадіння. Пошук знаходить «Петра», але алгоритм не знає, чи це ім'я самої особи, по батькові її дитини, чи випадкова згадка у розлогій архівній примітці. Відповідь є, але її первинний контекст для машини втрачено безповоротно. Через це доводиться зіставляти matched_tokens (див. Typesense: Search API — highlighting and matched tokens) і raw на клієнтському боці, що не завжди працює надійно.
На практиці клієнт отримує токени збігів окремо від raw і підсвічує їх під час обходу оригінальних колонок:
const tokensToHighlight =
highlights?.flatMap(({ matched_tokens }) => matched_tokens.flat()) ?? [];
Object.entries(document.raw).map(([key, value]) => (
<div key={key}>
<dt>{key}</dt>
<dd>
<DynamicValue value={value} tokens={tokensToHighlight} />
</dd>
</div>
));
Тобто пошуковий індекс не диктує інтерфейсу, скільки колонок має бути в результаті.
Який вигляд має схема колекції
У Typesense кожна мовна колекція має однакові технічні поля. Довільними залишаються тільки ключі всередині raw:
const collection = {
name: 'unstructured_uk',
enable_nested_fields: true,
fields: [
{ locale: 'uk', name: 'values', optional: false, type: 'string[]' },
{ name: 'raw', optional: false, type: 'object' },
{ name: 'location', type: 'geopoint' },
{ facet: true, name: 'tableId', type: 'string' },
{ name: 'title', type: 'string' },
{ facet: true, name: 'year', type: 'int32' },
],
};
Схема описує контейнер документа, але не намагається перелічити Прізвище, Ім'я, Причина, Роль чи будь-яку іншу колонку. Саме тому додавання нової таблиці не вимагає змін структури.
Один запит — три мовні індекси
Пошук формує варіанти запиту для кожної колекції, а Typesense повертає об'єднаний (union) список (див. Typesense: Federated / Multi Search):
const normalizedQuery = query.toLowerCase();
const searches = [
{
collection: 'unstructured_pl',
q: transliterateIntoPolish(normalizedQuery),
},
{
collection: 'unstructured_ru',
q: transliterateIntoRussian(normalizedQuery),
},
{
collection: 'unstructured_uk',
q: transliterateIntoUkrainian(normalizedQuery),
},
];
const response = await client.multiSearch.perform(
{ searches, union: true },
{
query_by: 'values',
drop_tokens_threshold: 0,
num_typos: 2,
sort_by: '_text_match:desc,year:desc',
},
);
Фільтр років додається до тих самих параметрів. Наприклад, запит за період 1800–1850 стає year: [1800..1850], а записи з невизначеним роком зберігають year: 0 і не зникають із загального пошуку.
Що далі?
Кожен запис у базі пошуку містить також географічні координати. Згодом, коли даних набереться побільше, з'явиться також обмеження щодо того, в якому місці виконується пошук. Хоча поки нема певності, який вигляд матиме цей UI.