Задача Переосмыслить структуру сайта, доработать ее и пересобрать контент
Переосмыслить структуру сайта, доработать ее и пересобрать контент
У меня сейчас все сущности отдельно (достались в наследство):
- Провинции
- Города
- Типы мест (Дайвинг, Водопад, Пагода и т.п.)
- Непосредственно места.
Это сейчас очень ограничено и мало имеет полезности. Надо подумать какие сущности с чем связать, какие компоненты на странице дополнительные вывести и переписать весь контент.
Ворклоги
Вот тут, кстати, надо продумать какую-то систему метрик и классификации объектов, не только по качеству, но и по направленности. К примеру, если человек не хочет пещеры, то ему точно не надо гроты и т.п. Если он выбирает вечернее платье, вряд ли ему надо в бухту Ха Лонга или дайвинг. Если ре ищет в городе ресторан или казино, ему не нужен водопад. В свою очередт если он ещет к горе, то почему не заскочить на базу, где есть и ручей, и пещера, и ресторан и еще что-либо. Кстати, вот эти связки мест точно нужны. Есть вот базы отдыха, где и кемпинг, и поесть, и пещера, и покупаться. Там можно на пару дней зависнуть. А жемчужный остров в Ня Чанге можно 3 дня не покидать
Вот мысли чатгпт:
Да — здесь уже нужна не просто таксономия мест, а система совместимости, намерений и “ёмкости” объекта.
Проблема в том, что тип объекта сам по себе почти ничего не говорит о том, зачем человек туда едет и сколько потребностей он может закрыть.
Например:
- «пещера» — это тип;
- «активный отдых», «природа», «на полдня», «не подходит при клаустрофобии» — это уже направленность;
- «туристический комплекс с пещерой, рестораном, купанием и ночёвкой» — это вообще составной объект, который может заменить несколько отдельных точек маршрута.
Нужны три разных слоя классификации
1. Что это
Это ваша текущая типология:
- пещера;
- водопад;
- ресторан;
- пляж;
- казино;
- дайвинг;
- пагода;
- парк;
- кемпинг;
- отель.
Она нужна, но её недостаточно.
2. Зачем туда едут
Это пользовательское намерение:
- природа;
- активный отдых;
- романтика;
- вечерний выход;
- еда;
- шопинг;
- развлечения;
- отдых с детьми;
- ночная жизнь;
- спокойный отдых;
- приключение;
- фотогеничное место;
- оздоровление;
- культурная программа.
Вот здесь появляются ваши примеры:
- человек ищет вечернее платье → ему нужны шопинг, центр города, ресторан, вечерняя программа;
- ищет казино → ему нужны nightlife, рестораны, отели, транспорт вечером;
- едет к горе → ему можно предложить пещеру, ручей, смотровую, кафе, кемпинг;
- не любит пещеры → исключаются пещеры, гроты, подземные храмы и похожие объекты.
3. Как объект ведёт себя в маршруте
Это уже операционная классификация:
- на 30 минут;
- на 2 часа;
- на полдня;
- на день;
- на 2–3 дня;
- можно совместить;
- лучше отдельно;
- транзитная точка;
- базовая точка;
- конечная точка;
- самодостаточный комплекс;
- требует ночёвки;
- зависит от погоды;
- требует бронирования;
- подходит только в определённое время суток.
Вот это критически важно для маршрута.
Нужна сущность «комплекс» или «кластер»
То, что вы описываете с базами отдыха и Жемчужным островом, нельзя моделировать просто как обычное место.
Нужно ввести понятие:
Составной объект / туристический комплекс
Он может включать:
- рестораны;
- отели;
- пляжи;
- аттракционы;
- пещеры;
- кемпинг;
- бассейн;
- спа;
- маршруты;
- шоу;
- торговлю;
- транспорт внутри территории.
Примеры:
- курорт;
- островной комплекс;
- национальный парк;
- база отдыха;
- тематический парк;
- большой храмовый комплекс;
- горный кластер;
- пляжный кластер;
- экопарк.
У него должны быть свои свойства:
- минимальное рекомендуемое время;
- максимальное разумное время;
- можно ли ночевать;
- можно ли есть на месте;
- есть ли внутренний транспорт;
- сколько активностей внутри;
- подходит ли как самостоятельная часть поездки.
Например:
Жемчужный остров Тип: туристический комплекс Рекомендуемая длительность: 1–3 дня Закрывает потребности: пляж, развлечения, еда, дети, шоу, проживание
Тогда система понимает: это не просто одна точка на два часа, а контейнер маршрута.
Полезная модель объекта
Для каждого объекта я бы добавил набор признаков.
Тематические признаки
- природа;
- еда;
- шопинг;
- история;
- религия;
- приключения;
- вода;
- горы;
- ночная жизнь;
- развлечения;
- wellness;
- дети;
- романтика;
- фотография.
Нежелательные признаки
Это важно не меньше.
- замкнутые пространства;
- высота;
- физическая нагрузка;
- толпы;
- шум;
- жара;
- вода;
- животные;
- религиозные ограничения;
- долгий трансфер;
- экстремальная активность.
Если человек говорит «не люблю пещеры», система должна исключать не только тип пещера, но и признаки:
- подземный объект;
- грот;
- тоннель;
- тесное пространство;
- спелеология.
То есть нужны не только положительные интересы, но и антипредпочтения.
Нужны “потребности”, которые закрывает объект
Это отдельная сильная идея.
Каждое место может закрывать одну или несколько потребностей:
- поесть;
- переночевать;
- искупаться;
- развлечь детей;
- отдохнуть;
- погулять;
- посмотреть природу;
- купить вещи;
- получить эмоции;
- провести вечер;
- укрыться от дождя;
- провести целый день без переездов.
Например:
Водопад
Закрывает:
- природа;
- фото;
- прогулка;
- купание — иногда;
- активность.
Казино
Закрывает:
- вечер;
- развлечения;
- nightlife;
- ресторан — иногда.
Туристическая база
Закрывает:
- еда;
- природа;
- купание;
- ночёвка;
- активность;
- отдых на 1–2 дня.
Тогда маршрут можно строить не вокруг количества точек, а вокруг баланса закрытых потребностей.
Нужны метрики объекта
Я бы разделил их на несколько групп.
Привлекательность
- уникальность;
- визуальная ценность;
- популярность;
- качество опыта;
- репутация;
- актуальность.
Логистика
- удалённость;
- доступность;
- время в пути;
- сложность подъезда;
- наличие парковки;
- зависимость от транспорта.
Временная ёмкость
- минимум времени;
- рекомендуемое время;
- максимум разумного времени;
- можно ли занять полдня;
- можно ли занять целый день;
- можно ли остаться на ночь.
Составность
- количество активностей;
- количество сервисов;
- наличие еды;
- наличие проживания;
- наличие развлечений;
- наличие природных объектов;
- самостоятельность.
Контекст
- подходит утром;
- подходит вечером;
- подходит в дождь;
- подходит в жару;
- подходит с детьми;
- подходит без машины;
- подходит для пары;
- подходит для соло.
Очень важна “плотность пользы”
Можно ввести внутреннюю метрику:
Сколько разных потребностей закрывается в одной точке
Например:
Обычный водопад
- природа;
- фото;
- прогулка.
Плотность пользы: средняя.
База отдыха
- природа;
- купание;
- еда;
- ночёвка;
- пещера;
- кемпинг;
- активность.
Плотность пользы: высокая.
Большой островной комплекс
- пляж;
- аттракционы;
- рестораны;
- шоу;
- отели;
- дети;
- вечерняя программа.
Плотность пользы: очень высокая.
Такие объекты надо иначе показывать в маршруте:
«Эта точка может занять 2–3 дня и заменить несколько отдельных поездок».
Нужны связи не только между местами, но и внутри комплексов
Связи могут быть такими:
- входит в комплекс;
- находится на территории;
- доступно только через комплекс;
- можно посетить отдельно;
- входит в общий билет;
- требует отдельного билета;
- работает в другие часы;
- лучше посещать в определённой последовательности.
Например:
Жемчужный остров Включает:
- аквапарк;
- пляж;
- рестораны;
- шоу;
- канатную дорогу;
- отели.
Пользователь может:
- добавить весь комплекс;
- добавить только часть;
- добавить комплекс на 1 день;
- добавить комплекс на 3 дня.
Как это применять к рекомендациям
Система должна сначала определять намерение пользователя, а потом уже выбирать типы объектов.
Пример:
Пользователь ищет вечернее платье.
Намерения:
- шопинг;
- город;
- вечер;
- возможно ресторан;
- возможно фотогеничное место;
- минимальные переезды.
Неуместные категории:
- дайвинг;
- водопады;
- пещеры;
- удалённые бухты.
Уместная связка:
торговый центр → салон/бутик → ресторан → бар/шоу
Другой пример:
Пользователь едет к горе.
Намерения:
- природа;
- активность;
- панорамы;
- возможно полный день.
Дополнительные связи:
- ручей;
- пещера;
- кафе;
- кемпинг;
- смотровая;
- база отдыха;
- ночёвка.
И тогда система предлагает не «ещё одну гору», а готовый кластер впечатлений.
Классификация маршрутов
Маршрут тоже должен иметь направленность:
- городской;
- природный;
- гастрономический;
- вечерний;
- семейный;
- приключенческий;
- романтический;
- пляжный;
- смешанный;
- без переездов;
- с высокой плотностью активностей;
- расслабленный.
И баланс:
- 70% природа / 30% город;
- 50% отдых / 50% активности;
- 20% шопинг / 30% еда / 50% развлечения.
Это позволит проверять, не “сломался” ли маршрут.
Например:
Пользователь хочет спокойный пляжный отдых, а маршрут на 60% состоит из переездов и активностей.
Агент должен сказать:
Маршрут не соответствует вашим предпочтениям.
Самая полезная новая сущность
Я бы добавил не только комплекс, но ещё и сценарий.
Примеры сценариев:
- день в дождь;
- вечер в городе;
- два дня без выезда из комплекса;
- поездка с ребёнком;
- романтический вечер;
- активный день в горах;
- день без автомобиля;
- остановка по пути между городами.
Сценарий связывает:
- намерения;
- ограничения;
- типы мест;
- комплексы;
- время;
- порядок действий.
Минимальная модель
К вашим сущностям:
- провинция;
- город;
- тип места;
- место;
я бы добавил:
- комплекс;
- сценарий;
- потребность;
- интерес;
- антипредпочтение;
- активность;
- сервис;
- маршрутный профиль.
И связи:
- место входит в комплекс;
- место закрывает потребность;
- место подходит сценарию;
- место конфликтует с предпочтением;
- место дополняет другое место;
- место заменяет другое место;
- место требует отдельного дня;
- место можно посетить по пути;
- комплекс рассчитан на N дней.
Главный вывод
Систему лучше строить не вокруг вопроса:
«Что это за место?»
А вокруг трёх вопросов:
Зачем туда ехать? Сколько времени оно может занять? Какие потребности оно закрывает?
И отдельно выделить составные объекты, где человек реально может провести день, два или три без необходимости ездить по десяткам разрозненных точек.
Это все релевантно к вот этому ворклогу.
Еще информация от чатгпт
Да, с такой схемой техническая сторона не является ограничением. Более того, у тебя уже есть почти всё необходимое для туристического knowledge graph — просто сейчас он описан в универсальных терминах.
Главное наблюдение: не нужно создавать отдельные таблицы под каждую туристическую сущность. Провинции, города, места, активности, потребности, сценарии и комплексы могут быть KBConcept, а смысл отношений между ними — KBFact с участниками через KBFactParticipation. Именно для этого твоя n-арная модель подходит особенно хорошо.
Как я читаю твою архитектуру
У тебя уже есть пять очень сильных уровней.
1. Сущности
KBConcept — универсальный семантический узел:
- Вьетнам;
- провинция Кханьхоа;
- Нячанг;
- VinWonders;
- водопад;
- дайвинг;
- семейный отдых;
- дождливая погода;
- поездка на три дня;
- конкретный маршрут.
При этом есть:
type;- иерархия через
parentId; - корень через
rootId; - координаты;
- произвольные данные в
data; - мультиязычные названия через
KBLabel; - файлы и изображения;
- собственный URL через
SiteRoute.
То есть это уже готовая основа для каталога и графа одновременно.
2. Отношения и утверждения
KBFact — это не просто ребро графа, а полноценное утверждение:
- тип отношения;
- текстовое представление;
- временная валидность;
- источник;
- уверенность;
- статус проверки;
- важность;
- происхождение факта.
А KBFactParticipation позволяет одному факту связать любое число объектов с разными ролями.
Например, утверждение:
VinWonders подходит семье с детьми для двухдневного отдыха в сухую погоду.
Можно выразить одним фактом:
KBFact.type = "visit_suitability"
Участники:
VinWonders role=destination
Семья с детьми role=audience
Два дня role=recommended_duration
Сухая погода role=preferred_condition
Уже это намного выразительнее обычной таблицы place_tags.
3. Временность и неопределённость
Для туризма это критично.
У тебя факт может иметь:
validFrom;validTo;knownSince;confidence;status;source.
Значит, можно нормально хранить:
- сезонное закрытие;
- временный ремонт;
- изменение цены;
- новое расписание;
- ухудшение дороги;
- сезон медуз;
- закрытие канатной дороги;
- фестиваль;
- временное ограничение купания.
Причём новое знание не обязано перезаписывать старое. Это идеально соответствует реальной туристической информации.
4. Противоречия
KBConflict и KBConstraint позволяют не притворяться, что база всегда знает истину.
Например:
- официальный сайт говорит, что вход стоит 500 000 донгов;
- свежий отзыв говорит о цене 600 000;
- агрегатор показывает 550 000.
Вместо случайного выбора можно сохранить три факта и открыть конфликт value_mismatch.
Для кнопки «Что важно знать сейчас» это особенно ценно:
Официальная цена — 500 000 ₫, но в двух свежих источниках указана цена 600 000 ₫. Рекомендуем проверить перед поездкой.
Это гораздо честнее обычной базы данных.
5. Контекстные пространства знаний
KBKnowledgeSpace и KBFactProjection позволяют один и тот же факт по-разному интерпретировать в разных контекстах.
Например:
- редакционная база VietnamGuru;
- личное пространство пользователя;
- временный результат веб-поиска;
- данные от местного эксперта;
- пользовательские сообщения;
- пространство конкретного маршрута.
Один факт может иметь разную:
- видимость;
- доверенность;
- важность.
Это позволяет не смешивать редакционно подтверждённые данные с пользовательскими советами.
Как переложить туризм на эту модель
Я бы не создавал десятки Prisma-моделей вроде Province, City, Place, Activity, Need, Scenario.
Вместо этого — ограниченный словарь типов KBConcept.type.
Например:
geo_country
geo_province
geo_city
geo_district
place
place_complex
place_component
transport_hub
place_type
activity
service
need
interest
avoidance
audience
condition
season
time_window
duration
scenario
route
route_segment
route_day
trip
Это не обязательно сразу делать enum в Prisma. На раннем этапе строковый type даже полезнее — он позволяет развивать онтологию без миграции схемы.
Но сам словарь типов стоит централизовать в коде.
Географическая иерархия
Для административной структуры достаточно parentId:
Вьетнам
└── Кханьхоа
├── Нячанг
└── Камрань
Но parentId следует использовать только для настоящей иерархии.
Не стоит через него выражать:
- место находится рядом;
- место входит в маршрут;
- ресторан расположен в комплексе;
- город является базой для поездок.
Для этого нужны факты.
Например:
Fact.type = "located_in"
place role=subject
city role=container
Почему не ограничиться parentId? Потому что объект может одновременно находиться:
- в районе;
- в городе;
- в провинции;
- в туристической зоне;
- на территории комплекса.
Это уже не дерево, а граф.
Как выразить типы мест
Твой текущий «тип места» — это отдельный KBConcept:
Водопад
Пещера
Грот
Пагода
Казино
Ресторан
Дайвинг-центр
Связь:
Fact.type = "has_type"
place role=subject
placeType role=type
Один объект может иметь несколько типов.
Например, база отдыха:
Экопарк
Кемпинг
Природный комплекс
Зона купания
Ресторанный комплекс
Это лучше, чем поле typeId, потому что составные объекты почти никогда не укладываются в одну категорию.
Как выразить семантическое родство типов
Твой пример:
Если человек не хочет пещеры, ему не нужны гроты.
Нужно связать сами типы.
Fact.type = "semantic_subtype"
Грот role=child
Пещера role=parent
Можно построить такую онтологию:
Подземные природные объекты
├── Пещера
├── Грот
├── Подземная река
├── Карстовый тоннель
└── Спелеологический маршрут
Но здесь есть важное разделение.
parentId можно использовать для строгой классификации:
грот является разновидностью подземного объекта.
А KBFact — для мягких отношений:
similar_to
commonly_combined_with
may_trigger_same_avoidance
alternative_to
Например:
Тоннели Кути — не пещера, но могут быть нежелательны человеку с боязнью замкнутых пространств.
Здесь таксономия не поможет. Нужна связь с концептом:
Замкнутые пространства
Факт:
Fact.type = "has_experience_attribute"
Тоннели Кути role=subject
Замкнутые пространства role=attribute
Тогда пользовательское исключение применяется по атрибуту опыта, а не только по категории.
Тип объекта и характер опыта нужно разделить
Это ключевой момент.
Например, «пещера» описывает физический объект. Но пользователю важнее:
- темно;
- тесно;
- влажно;
- требуется физическая нагрузка;
- приключенческий характер;
- длительная прогулка;
- риск испачкаться;
- не подходит в вечернем платье.
Поэтому нужны концепты типа experience_attribute:
indoor
outdoor
underground
water_based
physically_demanding
formal_friendly
muddy
crowded
quiet
romantic
family_friendly
weather_sensitive
Связи:
Fact.type = "has_experience_attribute"
Fact.type = "requires_condition"
Fact.type = "conflicts_with_condition"
Пример с вечерним платьем:
Пользовательский контекст:
formal_clothing = true
Объект:
Водопад
has_experience_attribute = wet
has_experience_attribute = uneven_terrain
has_experience_attribute = outdoor
Система делает вывод не потому, что «вечернее платье несовместимо с водопадом» записано вручную, а через признаки.
А вот ресторан, казино или театр имеют:
formal_friendly
indoor
evening_suitable
Потребности пользователя
Я бы оформил потребности как концепты:
Поесть
Переночевать
Искупаться
Купить одежду
Развлечь детей
Провести вечер
Посмотреть природу
Избежать переездов
Провести день в дождь
Получить активный опыт
Отдохнуть
Связь:
Fact.type = "satisfies_need"
place role=provider
need role=need
У участия можно использовать value для силы:
value = "0.9"
Или localImportance, хотя семантически лучше не смешивать важность факта и силу удовлетворения потребности. Для вычисляемых значений я бы добавил структурированный data в факт либо отдельное числовое поле.
Например:
{
"strength": 0.9,
"capacity": "full",
"notes": "Можно провести полный день"
}
Сейчас у KBFact нет общего data, и это одно из немногих расширений, которое я бы действительно рассмотрел.
Комплексы и составные места
Тебе точно нужна семантика составного объекта, но необязательно отдельная Prisma-модель.
Концепт:
KBConcept.type = "place_complex"
Например:
VinWonders Nha Trang
База отдыха X
Национальный парк Phong Nha
Курортный кластер
Компоненты — обычные места или сервисы.
Связь:
Fact.type = "part_of_complex"
аквапарк role=component
VinWonders role=complex
Но можно сделать более богатый факт:
Fact.type = "complex_composition"
Участники:
VinWonders role=complex
Аквапарк role=component
Пляж role=component
Отель role=component
Ресторан role=component
Шоу role=component
Я всё же предпочёл бы отдельный факт на компонент, потому что:
- у компонентов разные часы;
- разные билеты;
- разные сроки действия;
- разные источники;
- разные уровни уверенности.
Например:
Fact.type = "complex_component"
subject = Аквапарк
container = VinWonders
access_mode = included_ticket
При этом statement может быть человекочитаемым, а детали — в структурированных данных.
Что означает «можно зависнуть на три дня»
Это не одно поле duration.
Нужно различать:
- минимальное время знакомства;
- типичную длительность;
- максимальную полезную длительность;
- наличие проживания;
- достаточность внутренних активностей;
- необходимость выхода за пределы объекта.
Например, для VinWonders:
minimum_meaningful_duration = 1 day
recommended_duration = 2 days
maximum_stay_without_repetition = 3 days
overnight_available = true
self_contained = true
Это можно представить несколькими фактами:
recommended_duration
supports_overnight_stay
self_sufficiency
activity_capacity
self_sufficiency — особенно важная метрика.
Например:
0.1 — отдельная смотровая площадка
0.4 — достопримечательность с кафе и парковкой
0.7 — база отдыха на полный день
0.95 — курортный комплекс на несколько дней
Её можно вычислять из:
- еды;
- проживания;
- числа активностей;
- разнообразия активностей;
- вечерней программы;
- бытовой инфраструктуры;
- внутреннего транспорта;
- защищённости от погоды.
Я бы не хранил её только вручную. Лучше хранить исходные факты, а итоговую оценку создавать как KBFactType.derived с derivedFrom.
У тебя для этого уже есть factType и derivedFrom.
Связки мест
Твоя модель позволяет сделать их намного богаче, чем простое placeAId/placeBId.
Простая парная связка
Fact.type = "works_well_together"
Место A role=place
Место B role=place
Но лучше сразу добавить контекст:
Место A role=place
Место B role=place
Один день role=duration
Семья с детьми role=audience
Сухая погода role=condition
Тогда факт означает:
Эти места хорошо объединяются в один день для семьи с детьми в сухую погоду.
Набор из нескольких мест
N-арная модель особенно полезна:
Fact.type = "recommended_visit_cluster"
Гора role=anchor
Ручей role=optional_stop
Пещера role=optional_stop
Ресторан role=meal_stop
База отдыха role=overnight_base
Полтора дня role=recommended_duration
Это уже полноценный фрагмент маршрута, но ещё не пользовательский маршрут.
Я бы разделил кластер и маршрут
Это две разные сущности.
Кластер
Объективная или редакционная связка:
Эти объекты географически и сценарно образуют единый комплекс посещения.
Тип:
KBConcept.type = "visit_cluster"
Примеры:
- гора + ручей + пещера + ресторан;
- старый город + рынок + набережная;
- остров + пляж + парк развлечений + отель.
Маршрут
Конкретная последовательность:
Сначала гора, затем ручей, потом ресторан, ночёвка на базе.
Тип:
KBConcept.type = "route"
Маршруту нужны упорядоченные сегменты.
Текущая KBFactParticipation сама по себе не хранит порядок. value можно использовать как 1, 2, 3, но это будет не очень чисто.
Я бы добавил в KBFactParticipation:
position Int?
data Json?
Тогда один факт типа route_composition может содержать:
Гора role=stop position=1
Ручей role=stop position=2
Ресторан role=stop position=3
База role=stop position=4
И у каждого участия в data:
{
"arrivalTime": "09:00",
"durationMinutes": 120,
"optional": false
}
Это одно из наиболее практически полезных расширений твоей схемы.
Предпочтения пользователя
Их можно хранить двумя способами.
Как факты пользователя
Пользователь — тоже концепт либо связан с концептом профиля.
Например:
Fact.type = "user_preference"
UserConcept role=subject
Пещеры role=target
Не нравится role=preference
Но лучше формализовать:
likes
dislikes
avoids
requires
prefers
neutral_to
Пример:
Fact.type = "avoids"
Пользователь role=subject
Замкнутые пространства role=target
Тогда исключаются:
- пещеры;
- гроты;
- тоннели;
- подземные храмы;
если они связаны с этим experience attribute.
Как пространство знаний маршрута
Для конкретной поездки можно создать KBKnowledgeSpace:
Поездка во Вьетнам, август 2026
Туда проектируются:
- предпочтения;
- ограничения;
- участники;
- даты;
- бюджет;
- выбранные места;
- текущие факты;
- результаты агента.
Это очень естественно ложится на твою модель.
Не все предпочтения глобальны
Например:
- пользователь вообще любит водопады;
- но сегодня он в вечерней одежде;
- в этой поездке он с маленьким ребёнком;
- завтра у него всего три часа;
- сейчас идёт дождь.
Поэтому нужны разные контексты:
Глобальный профиль пользователя
Конкретная поездка
Конкретный день
Текущая сессия
Именно KnowledgeSpace позволяет не превращать временное условие «сегодня я в вечернем платье» в постоянное свойство пользователя.
Как считать совместимость места с запросом
Можно разложить итоговый score на компоненты:
intent_match
need_coverage
avoidance_conflict
context_fit
time_fit
geographic_fit
route_synergy
weather_fit
novelty
quality
confidence
Примерно:
score =
0.22 * intent_match +
0.18 * need_coverage +
0.15 * geographic_fit +
0.12 * time_fit +
0.12 * route_synergy +
0.08 * weather_fit +
0.08 * quality +
0.05 * novelty
- hard_conflicts
Но я бы не фиксировал единую формулу навсегда. Вес зависит от сценария.
Для запроса «найти ресторан рядом»:
- география — высокая;
- открыто сейчас — высокая;
- гастрономическое соответствие — высокая;
- уникальность — вторична.
Для двухнедельного маршрута:
- разнообразие;
- логистика;
- временная ёмкость;
- сезонность;
- баланс маршрута.
Лучше представить сам профиль оценки как концепт:
restaurant_now_profile
family_day_trip_profile
multi_day_route_profile
formal_evening_profile
А веса — как факты или data.
Жёсткие и мягкие ограничения
Это стоит разделять обязательно.
Жёсткие
Объект не должен попасть в результат:
- пользователь исключил подземные места;
- объект закрыт;
- не помещается по времени;
- недоступен детям нужного возраста;
- невозможно добраться;
- превышает бюджет;
- несовместим с физическими ограничениями.
Мягкие
Объект может попасть ниже:
- дальше желаемого;
- немного дороже;
- менее релевантен;
- похож на уже выбранное место;
- требует дополнительной одежды;
- зависит от погоды.
Твоя KBConstraint подходит для декларативных правил, а KBConflict — для фиксации нарушений.
Например:
constraint:
"Маршрут пользователя, избегающего underground experience,
не должен содержать места с атрибутом underground."
Но я бы не создавал KBConflict для каждого кандидата, отклонённого рекомендательной системой. Конфликт нужен для значимых противоречий знаний или уже сформированного маршрута, а не для обычной фильтрации.
Что добавить в схему минимально
У тебя фундамент уже есть. Я бы не делал большой рефакторинг.
1. Структурированные данные факта
model KBFact {
...
data Json?
}
Нужно для:
- численных значений;
- единиц измерения;
- диапазонов;
- параметров связи;
- алгоритмических признаков.
Например:
{
"minMinutes": 180,
"recommendedMinutes": 360,
"maxMinutes": 720
}
2. Данные и порядок участия
model KBFactParticipation {
...
position Int?
data Json?
}
Нужно для:
- маршрутов;
- кластеров;
- последовательностей;
- разных параметров участников одного факта.
3. Индекс по статусу и времени
Для частых актуальных запросов:
@@index([status])
@@index([validFrom, validTo])
@@index([type, status])
Сейчас у KBFact в приведённой схеме индексируется только type. Для кнопки «Что важно знать сейчас» запросы по валидности и статусу будут очень частыми.
4. Пространственный индекс
Сейчас lat/lng индексируются обычным составным B-tree индексом. Для реального «что рядом» лучше PostGIS:
geography(Point, 4326)
GIST index
Можно сохранить lat/lng для Prisma-удобства, а географическую точку держать дополнительно либо перейти на raw SQL extension.
С твоим опытом это, вероятно, очевидный шаг: для радиусных и маршрутных запросов обычный индекс координат быстро станет ограничением.
5. Стабильный словарь типов
Поля KBConcept.type, KBFact.type, KBFactParticipation.role строковые. Это даёт гибкость, но легко получить:
located_in
located-at
location
belongs_to_city
Поэтому нужен кодовый registry:
export const ConceptType = {
Place: 'place',
PlaceComplex: 'place_complex',
City: 'geo_city',
Need: 'need',
Scenario: 'scenario',
} as const;
То же самое для fact types и roles.
Что не стоит класть в KBConcept.data
Технически можно положить туда всё:
{
"recommendedDuration": 180,
"familyFriendly": true,
"hasRestaurant": true
}
Но тогда граф перестанет нормально объяснять происхождение данных.
Я бы использовал data концепта только для:
- технических метаданных;
- параметров отображения;
- редко используемых структур;
- кэша;
- денормализованных результатов.
А утверждения вроде:
- подходит детям;
- есть ресторан;
- рекомендуется два дня;
- закрыто до сентября;
держал бы как факты.
Потому что только факт имеет:
- источник;
- уверенность;
- время;
- статус проверки;
- конфликты;
- provenance.
Как агент будет собирать страницу места
Для одной карточки запрос может идти слоями.
Базовый слой
Концепт:
- название;
- описание;
- изображение;
- координаты;
- URL.
Таксономия
Факты:
- типы места;
- регион;
- город;
- комплекс;
- активности.
Пользовательская полезность
- закрываемые потребности;
- аудитории;
- ограничения;
- рекомендуемая длительность;
- лучшее время.
Связанные объекты
- части комплекса;
- места рядом;
- совместимые места;
- альтернативы;
- маршруты;
- кластеры.
Актуальность
- действующие временные факты;
- свежие новости;
- погода;
- события;
- конфликты данных.
Персонализация
Через пространство текущей поездки:
- что подходит;
- что исключить;
- куда поставить в маршрут;
- сколько времени выделить;
- какие места добавить вместе.
И всё это можно показать на одной странице, не отправляя пользователя ходить по каталогу.
Сильнейшая сторона твоей схемы
Обычная туристическая база говорит:
Это пещера в такой-то провинции.
Твоя модель потенциально может сказать:
Это природный комплекс возле Дананга, включающий пещеру, ручей, ресторан и кемпинг. Для активной пары он подходит на полтора дня, но не рекомендуется человеку, избегающему замкнутых пространств. В августе посещение ручья зависит от осадков. Комплекс логично объединить с горой X, но не с объектом Y в тот же день.
Причём каждое утверждение будет иметь:
- источник;
- время актуальности;
- уверенность;
- статус;
- участников;
- контекст.
Это уже не CMS и не обычный каталог. Это действительно база рассуждений.
Мой итог
Тебе не нужно проектировать новую туристическую базу поверх существующей.
Достаточно:
- Зафиксировать туристическую онтологию
KBConcept.type. - Создать словарь типов фактов и ролей.
- Добавить
dataвKBFact. - Добавить
positionиdataвKBFactParticipation. - Ввести PostGIS для пространственных связей.
- Разделить:
- тип объекта;
- свойства опыта;
- потребности;
- ограничения;
- комплексы;
- кластеры;
- маршруты.
- Использовать
derived-факты для вычисляемых метрик вроде самодостаточности, маршрутной ёмкости и плотности пользы.
Самое важное: не пытаться заранее записать все парные связи между всеми местами. Хранить стоит фундаментальные признаки и значимые редакционные связки, а большая часть совместимости должна вычисляться из графа под конкретного пользователя и конкретный маршрут.
Не буду цитировать всю переписку с чатгпт, но я полностью переосмыслил суть концептов и фактов и все свелось к тому, что нет смысла делать факты отдельными сущностями в нашей реализации. У нас сами концепты могут быть и фактами.
Вот краткая выжимка как я это вижу:
У нас не банковская система, чтобы строить подробные связи. У нас жизненный цикл информации такой:
1. Создается концепт, которого ранее еще не было. К примеру, Some Homestay.
2. В нем прописывается в обычном текстовом виде все, что хочется передать. Это все то, что потом прочитает пользовать. В нашем проекте по сути это само по себе факт, потому что весь контент создаем мы. И мы сразу указываем качественные признаки что есть что. Со временем, когда эта информация окажется некачественная, мы просто понизим ее качественный признак. Условно, нам достаточно сейчас только одной метрики от 0 до 1 флоэт. Чем выше качество, тем выше видимость. К примеру, у меня на сайте полно старых статей, которые уже не нужны, их надо выкинуть, но я не могу их выкинуть, ибо 404 не круто. Мне надо просто их дальше засунуть
3. Связка с другими концептами. То есть мне нужна просто перелинковка. Как и в классических сайтах, я просто могу добавить ссылки на другие концепты. Это чтобы пользователь мог читать и переходить в попутные карточки. Но это связь внешняя. А мне нужна и обратная. Соответственно, у меня не просто связи ссылки анкор тут нужны, а попутная техническая информация связи (почему я на это ссылаюсь и опять-таки, какой-то качественный признак).
4. При сохранении такого концепта пересчитываются все его эти связи. Те, что были удалены из содержимого, удаляются и из списка связей. А добавленные появляются.
Вот тут нам как раз и нужна плоская таблица связей концептов многие-ко-многим. Тут можно тогда посмотреть общую семантику всей базы знаний.
И тут я вот что думаю, если эту идею развивать. Можно сделать полную сементику на уровне HTML-тегов. Мы в тексте просто вставляем ссылки. При этом у них есть innerText, title, rel, data-атрибуты и т.п. Все это можно зеркалить с нашей индекс-таблицей связей. При этом parent и root остаются как жесткие связки
Вот что чатгпт подытожила:
Да, это уже выглядит очень цельно.
Тогда HTML/Markdown-контент становится первичным семантическим графом, а таблица связей — его материализованным индексом для быстрых запросов и обратной перелинковки.
При этом:
parentId— жёсткая непосредственная иерархия;rootId— принадлежность к одному дереву или разделу;- HTML-ссылки — мягкие смысловые связи многие-ко-многим;
- таблица отношений — зеркальное представление ссылок из контента.
Ссылка как полноценная семантическая связь
Например, в редакторе:
<a
href="/places/som-homestay"
data-concept-id="som-homestay-id"
data-relation="overnight"
data-context="Подходящий ночлег перед ранним посещением пещеры"
data-quality="0.9"
title="Som Homestay — ночлег рядом с пещерой"
>
переночевать в Som Homestay
</a>
Здесь уже содержится почти вся нужная семантика:
href— публичный адрес;data-concept-id— стабильная техническая идентичность;innerText— естественный анкор;title— дополнительное пояснение пользователю;data-relation— тип смысловой связи;data-context— почему ссылка появилась;data-quality— сила или полезность связи.
Из неё создаётся индексная запись:
sourceId: cave-id
targetId: som-homestay-id
relation: overnight
anchor: переночевать в Som Homestay
title: Som Homestay — ночлег рядом с пещерой
context: Подходящий ночлег перед ранним посещением пещеры
quality: 0.9
Что хранить в rel
Я бы осторожно использовал стандартный HTML-атрибут rel.
Он уже имеет браузерную и SEO-семантику:
nofollow;ugc;sponsored;noopener;noreferrer;alternate;author;tag.
Свою предметную семантику безопаснее хранить отдельно:
data-relation="overnight-before"
А стандартный rel использовать по назначению:
rel="ugc nofollow"
Иначе позднее можно столкнуться с конфликтом между собственной моделью и HTML-стандартом.
Минимальный формат ссылки
Для ручной работы в редакторе слишком много атрибутов будет мешать. Поэтому обязательными я бы сделал только:
<a
data-concept-id="..."
data-relation="..."
>
текст ссылки
</a>
Остальное можно определить автоматически:
href— получить черезSiteRoute;anchor— взять изinnerText;title— сгенерировать из связанного концепта;quality— использовать значение по умолчанию;context— извлечь из окружающего предложения;position— вычислить парсером.
Расширенный режим нужен только там, где автор хочет явно переопределить семантику:
<a
data-concept-id="..."
data-relation="overnight"
data-quality="0.95"
data-context="Лучший ночлег перед ранним выездом"
>
Som Homestay
</a>
Окружающий текст тоже несёт семантику
Допустим, написано:
Перед ранним посещением пещеры лучше
<a data-concept-id="som-id">переночевать в Som Homestay</a>,
поскольку дорога утром занимает всего около 30 минут.
Даже без явного data-relation агент или парсер может вывести:
relation: overnight_before
context: ночлег перед ранним посещением пещеры
Но я бы различал:
- явную семантику автора;
- автоматически определённую семантику.
Например:
origin: explicit
или:
origin: inferred
confidence: 0.72
Не обязательно показывать это редактору. Это техническая информация индексной таблицы.
Возможная схема таблицы
enum KBConceptRelationOrigin {
explicit
inferred
generated
}
model KBConceptRelation {
id String @id @default(cuid()) @db.VarChar(36)
createdAt DateTime @default(now()) @db.Timestamp(3)
updatedAt DateTime @default(now()) @updatedAt @db.Timestamp(3)
sourceId String
Source KBConcept @relation(
"ConceptRelationSource",
fields: [sourceId],
references: [id],
onDelete: Cascade
)
targetId String
Target KBConcept @relation(
"ConceptRelationTarget",
fields: [targetId],
references: [id],
onDelete: Cascade
)
relation String? @db.VarChar(100)
anchor String? @db.Text
title String? @db.Text
context String? @db.Text
quality Float @default(0.5)
confidence Float?
origin KBConceptRelationOrigin @default(explicit)
// Стабильный идентификатор конкретной ссылки в редакторе
nodeId String? @db.VarChar(100)
// Положение в содержимом
position Int?
// Исходные или дополнительные HTML-атрибуты
data Json?
@@unique([sourceId, nodeId])
@@index([sourceId])
@@index([targetId])
@@index([sourceId, relation])
@@index([targetId, relation])
@@index([quality])
}
quality и confidence здесь имеют разный смысл:
quality— насколько связь полезна для пользователя;confidence— насколько уверенно определена её семантика.
Для явно заданной ссылки:
origin = explicit
confidence = 1
Для автоматически распознанной:
origin = inferred
confidence = 0.74
На первом этапе confidence можно вообще не добавлять.
Нужен стабильный идентификатор HTML-узла
Если просто использовать sourceId + targetId, возникнет проблема: один текст может ссылаться на один концепт несколько раз по разным причинам.
Например:
<a
data-node-id="link-a17"
data-concept-id="som-id"
data-relation="overnight-before"
>
ночлег перед посещением
</a>
<a
data-node-id="link-b24"
data-concept-id="som-id"
data-relation="cold-weather-warning"
>
зимой здесь может быть холодно
</a>
Это две самостоятельные связи с одной и той же целью.
data-node-id должен создаваться редактором автоматически и сохраняться при перемещении ссылки внутри документа.
Тогда синхронизация при сохранении проста:
- ссылка с тем же
nodeId— обновляется; - новый
nodeId— добавляется; - отсутствующий
nodeId— удаляется.
HTML и индекс должны иметь владельца истины
Здесь желательно избежать двустороннего редактирования.
Я бы зафиксировал:
Контент является источником истины для исходящих редакционных связей.
Таблица — индекс.
Значит:
- пользователь редактирует ссылку в тексте;
- после сохранения индекс пересчитывается;
- прямое редактирование строки индексной таблицы запрещено или ограничено;
- обратные блоки формируются из индексной таблицы;
- изменение входящей связи происходит через исходный концепт.
Иначе появится рассинхронизация: таблица говорит одно, HTML — другое.
Автоматические связи можно хранить отдельно от контентных и не зеркалить в HTML до явного принятия.
Обратная связь
Если в карточке пещеры есть:
<a
data-concept-id="som-id"
data-relation="overnight-after"
>
Som Homestay
</a>
То на странице Som Homestay можно автоматически вывести:
Подходит для ночлега после посещения
Пещера X — автор рекомендует этот хоумстей как ночлег после посещения.
Или встроить в текст динамический блок:
<concept-backlinks
data-relation="overnight-after"
data-limit="5"
></concept-backlinks>
Причём обратное представление не обязано повторять исходный анкор. Оно строится из:
- типа отношения;
- контекста;
- исходного концепта;
- качества связи.
parent и root действительно стоит оставить
Они решают другую задачу.
parentId
Жёсткая принадлежность или основной контейнер:
Тхай Нгуен
└── Som Homestay
либо структура контента:
Жильё
└── Хоумстеи
└── Som Homestay
Нужно только выбрать один смысл parentId и не смешивать несколько иерархий.
Например:
parentIdвсегда означает редакционную принадлежность к основному разделу.
rootId
Ускоряет:
- получение всего дерева;
- поиск внутри раздела;
- уникальность кода;
- управление большой веткой;
- удаление или перенос раздела.
Мягкие ссылки
Всё остальное:
- рекомендуемый ночлег;
- часть комплекса;
- альтернатива;
- следующая точка маршрута;
- связано с погодным риском;
- упоминается в маршруте;
идёт через индекс ссылок.
Полная семантика не обязательно требует сложных тегов
Можно начать с обычной ссылки:
<a data-concept-id="som-id">Som Homestay</a>
А тип отношения определять по трём уровням:
- Явный
data-relation. - Контекст родительского блока.
- Анализ окружающего текста агентом.
Например:
<section data-semantic-context="recommended-overnight">
<p>
Перед поездкой лучше остановиться в
<a data-concept-id="som-id">Som Homestay</a>.
</p>
</section>
Здесь каждая ссылка внутри секции наследует:
relation = recommended_overnight
Это может сильно упростить ручную разметку.
То же самое в Markdown-компонентах:
:::semantic{relation="overnight-before"}
Перед ранним посещением лучше остановиться в
[Som Homestay](concept:som-id).
:::
Семантика может жить на разных уровнях DOM
На ссылке
Для точной индивидуальной связи:
<a data-relation="alternative">
На абзаце
Для общего контекста:
<p data-semantic-context="winter-warning">
На секции
Для группы ссылок:
<section data-semantic-context="where-to-stay">
На кастомном компоненте
Для динамической выборки:
<concept-list
data-relation="overnight-before"
data-source="current"
></concept-list>
Парсер может наследовать семантику сверху вниз:
link.relation
?? paragraph.semanticContext
?? section.semanticContext
?? inferredFromText
?? "reference"
Что получится в итоге
У тебя появляется четыре согласованных уровня:
Жёсткое дерево:
parentId / rootId
Авторский смысл:
HTML-контент и ссылки
Быстрый граф:
KBConceptRelation
Динамическое представление:
обратные ссылки, списки и агентские блоки
Это очень практичная архитектура. Она не требует отдельно описывать весь мир в таблицах: семантика возникает прямо в процессе написания нормального человеческого текста, а база лишь материализует её для поиска, анализа и обратного использования.