Задача Переосмыслить структуру сайта, доработать ее и пересобрать контент

Переосмыслить структуру сайта, доработать ее и пересобрать контент

13.07.2026vietnamguru.ru

У меня сейчас все сущности отдельно (достались в наследство):

  • Провинции
  • Города
  • Типы мест (Дайвинг, Водопад, Пагода и т.п.)
  • Непосредственно места.

Это сейчас очень ограничено и мало имеет полезности. Надо подумать какие сущности с чем связать, какие компоненты на странице дополнительные вывести и переписать весь контент.

Ворклоги

Вот тут, кстати, надо продумать какую-то систему метрик и классификации объектов, не только по качеству, но и по направленности. К примеру, если человек не хочет пещеры, то ему точно не надо гроты и т.п. Если он выбирает вечернее платье, вряд ли ему надо в бухту Ха Лонга или дайвинг. Если ре ищет в городе ресторан или казино, ему не нужен водопад. В свою очередт если он ещет к горе, то почему не заскочить на базу, где есть и ручей, и пещера, и ресторан и еще что-либо. Кстати, вот эти связки мест точно нужны. Есть вот базы отдыха, где и кемпинг, и поесть, и пещера, и покупаться. Там можно на пару дней зависнуть. А жемчужный остров в Ня Чанге можно 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 и не обычный каталог. Это действительно база рассуждений.

Мой итог

Тебе не нужно проектировать новую туристическую базу поверх существующей.

Достаточно:

  1. Зафиксировать туристическую онтологию KBConcept.type.
  2. Создать словарь типов фактов и ролей.
  3. Добавить data в KBFact.
  4. Добавить position и data в KBFactParticipation.
  5. Ввести PostGIS для пространственных связей.
  6. Разделить:
    • тип объекта;
    • свойства опыта;
    • потребности;
    • ограничения;
    • комплексы;
    • кластеры;
    • маршруты.
  7. Использовать 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>

А тип отношения определять по трём уровням:

  1. Явный data-relation.
  2. Контекст родительского блока.
  3. Анализ окружающего текста агентом.

Например:

<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

Динамическое представление:
обратные ссылки, списки и агентские блоки

Это очень практичная архитектура. Она не требует отдельно описывать весь мир в таблицах: семантика возникает прямо в процессе написания нормального человеческого текста, а база лишь материализует её для поиска, анализа и обратного использования.