Task: Redesign the website structure, refine it, and rebuild the content

Redesign the website structure, refine it, and rebuild the content

13.07.2026vietnamguru.ru

Right now I have all entities separately (inherited them):

  • Provinces
  • Cities
  • Place types (Diving, Waterfall, Pagoda, etc.)
  • Places themselves.

This is currently very limited and of little use. We need to figure out which entities to link together, what additional page components to display, and rewrite all the content.

Ворклоги

More info from ChatGPT

Yes, with this schema, the technical side is not a limitation. Moreover, you already have almost everything needed for a tourism knowledge graph — it's just currently described in universal terms.

The main observation: you don't need to create separate tables for each tourism entity. Provinces, cities, places, activities, needs, scenarios, and complexes can all be KBConcept, and the meaning of the relationships between them can be KBFact with participants via KBFactParticipation. This is precisely what your n-ary model is particularly good for.

How I read your architecture

You already have five very strong layers.

1. Entities

KBConcept is a universal semantic node:

  • Vietnam;
  • Khanh Hoa province;
  • Nha Trang;
  • VinWonders;
  • waterfall;
  • diving;
  • family vacation;
  • rainy weather;
  • three-day trip;
  • specific itinerary.

At the same time, it has:

  • type;
  • hierarchy via parentId;
  • root via rootId;
  • coordinates;
  • arbitrary data in data;
  • multilingual names via KBLabel;
  • files and images;
  • its own URL via SiteRoute.

This means it is already a ready-made foundation for both a catalog and a graph simultaneously.

2. Relationships and Assertions

KBFact is not just a graph edge, but a full-fledged assertion:

  • relation type;
  • textual representation;
  • temporal validity;
  • source;
  • confidence;
  • verification status;
  • importance;
  • fact provenance.

And KBFactParticipation allows a single fact to link any number of entities with different roles.

For example, the assertion:

VinWonders is suitable for a family with children for a two-day stay in dry weather.

Can be expressed as a single fact:

KBFact.type = "visit_suitability"

Participants:

VinWonders       role=destination
Family with children role=audience
Two days         role=recommended_duration
Dry weather      role=preferred_condition

This is already much more expressive than a standard place_tags table.

3. Temporality and Uncertainty

For tourism, this is critical.

In your model, a fact can have:

  • validFrom;
  • validTo;
  • knownSince;
  • confidence;
  • status;
  • source.

This means you can properly store:

  • seasonal closures;
  • temporary repairs;
  • price changes;
  • new schedules;
  • road deterioration;
  • jellyfish season;
  • cable car closures;
  • festivals;
  • temporary swimming restrictions.

Moreover, new knowledge does not have to overwrite old knowledge. This perfectly matches real-world tourism information.

4. Conflicts

KBConflict and KBConstraint allow you not to pretend that the database always knows the truth.

For example:

  • the official website says admission is 500,000 VND;
  • a recent review mentions a price of 600,000;
  • an aggregator shows 550,000.

Instead of making a random choice, you can store all three facts and open a value_mismatch conflict.

For the "What to know now" button, this is especially valuable:

The official price is 500,000 ₫, but two recent sources indicate 600,000 ₫. We recommend checking before you go.

This is much more honest than a regular database.

5. Contextual Knowledge Spaces

KBKnowledgeSpace and KBFactProjection allow the same fact to be interpreted differently in various contexts.

For example:

  • VietnamGuru editorial base;
  • user's personal space;
  • temporary web search result;
  • data from a local expert;
  • user messages;
  • space of a specific itinerary.

A single fact can have different:

  • visibility;
  • trustworthiness;
  • importance.

This prevents mixing editorially verified data with user tips.


How to map tourism onto this model

I wouldn't create dozens of Prisma models like Province, City, Place, Activity, Need, Scenario.

Instead, use a restricted dictionary of KBConcept.type.

For example:

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

This doesn't necessarily have to be a Prisma enum right away. At an early stage, a string type is even more useful — it allows evolving the ontology without schema migrations.

However, the dictionary of types itself should be centralized in code.


Geographic Hierarchy

For the administrative structure, parentId is sufficient:

Vietnam
└── Khanh Hoa
    ├── Nha Trang
    └── Cam Ranh

But parentId should only be used for a true hierarchy.

You shouldn't use it to express:

  • a place is located nearby;
  • a place is part of an itinerary;
  • a restaurant is located inside a complex;
  • a city is a base for trips.

For these, you need facts.

For example:

Fact.type = "located_in"
place       role=subject
city        role=container

Why not limit it to parentId? Because an entity can simultaneously be located:

  • in a district;
  • in a city;
  • in a province;
  • in a tourist zone;
  • on the territory of a complex.

This is no longer a tree, but a graph.


How to express place types

Your current "place type" is a separate KBConcept:

Waterfall
Cave
Grotto
Pagoda
Casino
Restaurant
Diving center

Relationship:

Fact.type = "has_type"

place      role=subject
placeType  role=type

A single object can have multiple types.

For example, a recreation center:

Eco-park
Camping site
Natural complex
Swimming zone
Restaurant complex

This is better than a typeId field because composite objects almost never fit into a single category.


How to express semantic type affinity

Your example:

If a person doesn't want caves, they don't need grottos.

You need to link the types themselves.

Fact.type = "semantic_subtype"

Grotto    role=child
Cave      role=parent

You can build an ontology like this:

Underground natural features
├── Cave
├── Grotto
├── Underground river
├── Karst tunnel
└── Speleological route

But there is an important distinction here.

parentId can be used for strict classification:

a grotto is a variety of an underground feature.

And KBFact for soft relationships:

similar_to
commonly_combined_with
may_trigger_same_avoidance
alternative_to

For example:

Cu Chi Tunnels are not a cave, but may be undesirable for someone with claustrophobia.

Taxonomy won't help here. You need a link to the concept:

Enclosed spaces

Fact:

Fact.type = "has_experience_attribute"

Cu Chi Tunnels          role=subject
Enclosed spaces         role=attribute

Then the user exclusion applies based on the experience attribute, not just the category.


Object type and experience character must be separated

This is a key point.

For example, "cave" describes a physical object. But to the user, what matters more is:

  • dark;
  • tight;
  • humid;
  • requires physical exertion;
  • adventurous nature;
  • long walk;
  • risk of getting dirty;
  • not suitable for an evening dress.

Therefore, you need concepts like experience_attribute:

indoor
outdoor
underground
water_based
physically_demanding
formal_friendly
muddy
crowded
quiet
romantic
family_friendly
weather_sensitive

Relationships:

Fact.type = "has_experience_attribute"
Fact.type = "requires_condition"
Fact.type = "conflicts_with_condition"

Example with an evening dress:

User context:
formal_clothing = true

Object:

Waterfall
has_experience_attribute = wet
has_experience_attribute = uneven_terrain
has_experience_attribute = outdoor

The system infers this not because "evening dress is incompatible with a waterfall" is hardcoded, but through attributes.

Meanwhile, a restaurant, casino, or theater has:

formal_friendly
indoor
evening_suitable


User needs

I would model needs as concepts:

Eat
Sleep
Swim
Buy clothes
Entertain children
Spend the evening
View nature
Avoid long travels
Spend a rainy day
Get an active experience
Relax

Relationship:

Fact.type = "satisfies_need"

place  role=provider
need   role=need

For participation, you can use value for strength:

value = "0.9"

Or localImportance, although semantically it's better not to mix fact importance and need satisfaction strength. For computed values, I would add structured data to the fact or a separate numeric field.

For example:

{
  "strength": 0.9,
  "capacity": "full",
  "notes": "You can spend a full day here"
}

Currently, KBFact doesn't have a general data field, and this is one of the few extensions I would actually consider.


Complexes and composite places

You definitely need composite object semantics, but not necessarily a separate Prisma model.

Concept:

KBConcept.type = "place_complex"

For example:

VinWonders Nha Trang
Recreation base X
Phong Nha National Park
Resort cluster

Components are regular places or services.

Relationship:

Fact.type = "part_of_complex"

waterpark    role=component
VinWonders   role=complex

Or you can create a richer fact:

Fact.type = "complex_composition"

Participants:

VinWonders       role=complex
Waterpark        role=component
Beach            role=component
Hotel            role=component
Restaurant       role=component
Show             role=component

I would still prefer a separate fact per component because:

  • components have different hours;
  • different tickets;
  • different validity periods;
  • different sources;
  • different confidence levels.

For example:

Fact.type = "complex_component"
subject = Waterpark
container = VinWonders
access_mode = included_ticket

Here statement can be human-readable, while the details reside in structured data.


What "can hang out for three days" means

This is not a single duration field.

You need to distinguish between:

  • minimum time for an introduction;
  • typical duration;
  • maximum useful duration;
  • availability of overnight stay;
  • sufficiency of internal activities;
  • necessity of going outside the object.

For example, for VinWonders:

minimum_meaningful_duration = 1 day
recommended_duration = 2 days
maximum_stay_without_repetition = 3 days
overnight_available = true
self_contained = true

This can be represented by multiple facts:

recommended_duration
supports_overnight_stay
self_sufficiency
activity_capacity

self_sufficiency is a particularly important metric.

For example:

0.1 — a standalone viewpoint
0.4 — an attraction with a cafe and parking
0.7 — a full-day recreation center
0.95 — a multi-day resort complex

It can be computed from:

  • food;
  • accommodation;
  • number of activities;
  • variety of activities;
  • evening program;
  • infrastructure;
  • internal transport;
  • weather protection.

I wouldn't store it solely manually. It's better to store the source facts and create the final score as a KBFactType.derived with derivedFrom.

You already have factType and derivedFrom for this.


Place pairings

Your model allows making them much richer than a simple placeAId/placeBId.

Simple pairwise link

Fact.type = "works_well_together"

Place A  role=place
Place B  role=place

But it's better to add context right away:

Place A           role=place
Place B           role=place
One day           role=duration
Family with kids  role=audience
Dry weather       role=condition

Then the fact means:

These places combine well into a single day for a family with kids in dry weather.

Multi-place set

The n-ary model is especially useful here:

Fact.type = "recommended_visit_cluster"

Mountain      role=anchor
Stream        role=optional_stop
Cave          role=optional_stop
Restaurant    role=meal_stop
Resort base   role=overnight_base
One and a half days role=recommended_duration

This is already a full itinerary fragment, though not yet a user itinerary.


I would separate cluster and itinerary

These are two different entities.

Cluster

An objective or editorial bundle:

These objects geographically and scenario-wise form a single visitation complex.

Type:

KBConcept.type = "visit_cluster"

Examples:

  • mountain + stream + cave + restaurant;
  • old town + market + waterfront;
  • island + beach + amusement park + hotel.

Itinerary

A specific sequence:

First the mountain, then the stream, then the restaurant, overnight at the base.

Type:

KBConcept.type = "route"

An itinerary needs ordered segments.

Your current KBFactParticipation does not store order by itself. value could be used as 1, 2, 3, but that's not very clean.

I would add to KBFactParticipation:

position Int?
data     Json?

Then a single route_composition fact can contain:

Mountain    role=stop position=1
Stream      role=stop position=2
Restaurant  role=stop position=3
Base        role=stop position=4

And in each participation's data:

{
  "arrivalTime": "09:00",
  "durationMinutes": 120,
  "optional": false
}

This is one of the most practically useful extensions to your schema.


User preferences

They can be stored in two ways.

As user facts

A user is also a concept or linked to a profile concept.

For example:

Fact.type = "user_preference"

UserConcept       role=subject
Caves             role=target
Dislikes          role=preference

But it's better to formalize:

likes
dislikes
avoids
requires
prefers
neutral_to

Example:

Fact.type = "avoids"

User                  role=subject
Enclosed spaces       role=target

Then the following are excluded:

  • caves;
  • grottos;
  • tunnels;
  • underground temples;

if they are linked to this experience attribute.

As an itinerary knowledge space

For a specific trip, you can create a KBKnowledgeSpace:

Trip to Vietnam, August 2026

Projected into it:

  • preferences;
  • constraints;
  • participants;
  • dates;
  • budget;
  • selected places;
  • current facts;
  • agent results.

This maps very naturally onto your model.


Not all preferences are global

For example:

  • the user generally likes waterfalls;
  • but today they are wearing evening clothes;
  • on this trip they have a toddler;
  • tomorrow they only have three hours;
  • it is raining right now.

Therefore, different contexts are needed:

Global user profile
Specific trip
Specific day
Current session

It is KnowledgeSpace that prevents turning the temporary condition "today I'm wearing an evening dress" into a permanent user property.


How to calculate place compatibility with a query

You can break down the final score into components:

intent_match
need_coverage
avoidance_conflict
context_fit
time_fit
geographic_fit
route_synergy
weather_fit
novelty
quality
confidence

Roughly:

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

But I wouldn't fix a single formula forever. The weights depend on the scenario.

For a "find a restaurant nearby" query:

  • geography — high;
  • open now — high;
  • gastronomic match — high;
  • uniqueness — secondary.

For a two-week itinerary:

  • variety;
  • logistics;
  • time capacity;
  • seasonality;
  • itinerary balance.

It is better to represent the scoring profile itself as a concept:

restaurant_now_profile
family_day_trip_profile
multi_day_route_profile
formal_evening_profile

And the weights as facts or data.


Hard and soft constraints

These must definitely be separated.

Hard constraints

The object must not appear in the results:

  • user excluded underground places;
  • object is closed;
  • does not fit time-wise;
  • unavailable for children of a certain age;
  • impossible to reach;
  • exceeds budget;
  • incompatible with physical limitations.

Soft constraints

The object can appear lower down:

  • further than desired;
  • slightly more expensive;
  • less relevant;
  • similar to an already selected place;
  • requires special clothing;
  • weather-dependent.

Your KBConstraint is suitable for declarative rules, and KBConflict for recording violations.

For example:

constraint:
"An itinerary for a user avoiding underground experiences
must not contain places with the underground attribute."

However, I wouldn't create a KBConflict for every candidate rejected by the recommendation system. Conflicts are needed for significant knowledge contradictions or an already formed itinerary, not for routine filtering.


Minimal schema additions

You already have the foundation. I wouldn't do a large refactoring.

1. Structured fact data

model KBFact {
  ...
  data Json?
}

Needed for:

  • numeric values;
  • units of measurement;
  • ranges;
  • relation parameters;
  • algorithmic features.

For example:

{
  "minMinutes": 180,
  "recommendedMinutes": 360,
  "maxMinutes": 720
}

2. Participation data and order

model KBFactParticipation {
  ...
  position Int?
  data     Json?
}

Needed for:

  • itineraries;
  • clusters;
  • sequences;
  • different parameters of participants in the same fact.

3. Index on status and time

For frequent real-time queries:

@@index([status])
@@index([validFrom, validTo])
@@index([type, status])

Currently, in the provided schema, only type is indexed on KBFact. For the "What to know now" button, queries by validity and status will be very frequent.

4. Spatial index

Currently, lat/lng are indexed with a standard composite B-tree index. For true "what's nearby", PostGIS is better:

geography(Point, 4326)
GIST index

You can keep lat/lng for Prisma convenience while maintaining the geographic point additionally or switching to a raw SQL extension.

With your experience, this is probably an obvious step: for radius and itinerary queries, a standard coordinate index will quickly become a bottleneck.

5. Stable type registry

The fields KBConcept.type, KBFact.type, KBFactParticipation.role are strings. This provides flexibility, but it's easy to end up with:

located_in
located-at
location
belongs_to_city

Therefore, a code-level registry is needed:

export const ConceptType = {
  Place: 'place',
  PlaceComplex: 'place_complex',
  City: 'geo_city',
  Need: 'need',
  Scenario: 'scenario',
} as const;

The same goes for fact types and roles.


What not to put in KBConcept.data

Technically, you can put everything there:

{
  "recommendedDuration": 180,
  "familyFriendly": true,
  "hasRestaurant": true
}

But then the graph stops properly explaining the origin of the data.

I would use concept data only for:

  • technical metadata;
  • display parameters;
  • rarely used structures;
  • cache;
  • denormalized results.

And assertions like:

  • suitable for kids;
  • has a restaurant;
  • recommended two days;
  • closed until September;

should be kept as facts.

Because only a fact has:

  • a source;
  • confidence;
  • time;
  • verification status;
  • conflicts;
  • provenance.

How an agent will assemble a place page

For a single card, the query can proceed in layers.

Base layer

Concept:

  • name;
  • description;
  • image;
  • coordinates;
  • URL.

Taxonomy

Facts:

  • place types;
  • region;
  • city;
  • complex;
  • activities.

User utility

  • satisfied needs;
  • audiences;
  • constraints;
  • recommended duration;
  • best time.

Related objects

  • complex parts;
  • nearby places;
  • compatible places;
  • alternatives;
  • itineraries;
  • clusters.

Relevance

  • active temporary facts;
  • fresh news;
  • weather;
  • events;
  • data conflicts.

Personalization

Via the current trip space:

  • what fits;
  • what to exclude;
  • where to place it in the itinerary;
  • how much time to allocate;
  • what places to add together.

And all of this can be shown on a single page without sending the user to browse through a catalog.


The strongest side of your schema

A regular tourism database says:

This is a cave in such-and-such province.

Your model can potentially say:

This is a natural complex near Da Nang, including a cave, a stream, a restaurant, and a camping site. For an active couple, it is suitable for a day and a half, but not recommended for someone avoiding enclosed spaces. In August, visiting the stream depends on precipitation. It makes logical sense to combine the complex with mountain X, but not with object Y on the same day.

Moreover, every assertion will have:

  • a source;
  • validity time;
  • confidence;
  • status;
  • participants;
  • context.

This is no longer a CMS or a regular catalog. It is truly a reasoning database.

My conclusion

You don't need to design a new tourism database on top of the existing one.

It is enough to:

  1. Fix the tourism ontology for KBConcept.type.
  2. Create a dictionary of fact types and roles.
  3. Add data to KBFact.
  4. Add position and data to KBFactParticipation.
  5. Introduce PostGIS for spatial relationships.
  6. Separate:
    • object type;
    • experience attributes;
    • needs;
    • constraints;
    • complexes;
    • clusters;
    • itineraries.
  7. Use derived facts for computed metrics like self-sufficiency, itinerary capacity, and utility density.

Most importantly: do not try to pre-record all pairwise connections between all places. You should store fundamental attributes and significant editorial links, while most compatibility should be computed from the graph for a specific user and a specific itinerary.

Here, by the way, we need to think through some system of metrics and object classification, not only by quality, but also by focus/orientation. For example, if a person doesn't want caves, they definitely don't need grottoes, etc. If they are choosing an evening dress, they hardly need Ha Long Bay or diving. If someone is looking for a restaurant or casino in the city, they don't need a waterfall. Conversely, if they are heading towards a mountain, why not drop by a resort base that has a stream, a cave, a restaurant, and something else as well. By the way, these combinations of places are definitely needed. There are recreation centers that have camping, food, a cave, and a place to swim. You can hang out there for a couple of days. And you can not leave the Pearl Island in Nha Trang for 3 days.

Here are ChatGPT's thoughts:

Yes — here we need not just a taxonomy of places, but a system of compatibility, intent, and object "capacity".

The problem is that the type of object itself says almost nothing about why a person is going there and how many needs it can fulfill.

For example:

  • "cave" is a type;
  • "active recreation", "nature", "for half a day", "not suitable for claustrophobia" — this is already orientation/focus;
  • "tourist complex with a cave, restaurant, swimming, and overnight stay" — this is generally a composite object that can replace several separate route points.

Three different classification layers are needed

1. What it is

This is your current typology:

  • cave;
  • waterfall;
  • restaurant;
  • beach;
  • casino;
  • diving;
  • pagoda;
  • park;
  • camping;
  • hotel.

It is necessary, but not sufficient.

2. Why people go there

This is user intent:

  • nature;
  • active recreation;
  • romance;
  • evening outing;
  • food;
  • shopping;
  • entertainment;
  • traveling with children;
  • nightlife;
  • relaxing vacation;
  • adventure;
  • photogenic spot;
  • wellness;
  • cultural program.

This is where your examples come in:

  • a person is looking for an evening dress → they need shopping, the city center, a restaurant, an evening program;
  • looking for a casino → they need nightlife, restaurants, hotels, evening transport;
  • going to a mountain → they can be offered a cave, a stream, a viewpoint, a cafe, a campsite;
  • doesn't like caves → caves, grottoes, underground temples, and similar objects are excluded.

3. How the object behaves in the route

This is already operational classification:

  • for 30 minutes;
  • for 2 hours;
  • for half a day;
  • for a day;
  • for 2–3 days;
  • can be combined;
  • better separately;
  • transit point;
  • base point;
  • destination point;
  • self-sufficient complex;
  • requires overnight stay;
  • depends on weather;
  • requires booking;
  • suitable only at a certain time of day.

This is critically important for the route.


The entity "complex" or "cluster" is needed

What you describe with recreation bases and Pearl Island cannot be modeled simply as a regular place.

We need to introduce the concept:

Composite object / tourist complex

It can include:

  • restaurants;
  • hotels;
  • beaches;
  • attractions;
  • caves;
  • camping;
  • swimming pool;
  • spa;
  • routes;
  • shows;
  • retail;
  • internal territory transport.

Examples:

  • resort;
  • island complex;
  • national park;
  • recreation base;
  • theme park;
  • large temple complex;
  • mountain cluster;
  • beach cluster;
  • eco-park.

It should have its own properties:

  • minimum recommended time;
  • maximum reasonable time;
  • can you stay overnight;
  • can you eat on-site;
  • is there internal transport;
  • how many activities are inside;
  • is it suitable as a standalone part of the trip.

For example:

Pearl Island Type: tourist complex Recommended duration: 1–3 days Fulfills needs: beach, entertainment, food, children, show, accommodation

Then the system understands: this is not just a single point for two hours, but a route container.


Useful object model

For each object, I would add a set of attributes.

Thematic attributes

  • nature;
  • food;
  • shopping;
  • history;
  • religion;
  • adventure;
  • water;
  • mountains;
  • nightlife;
  • entertainment;
  • wellness;
  • children;
  • romance;
  • photography.

Undesirable attributes

This is equally important.

  • confined spaces;
  • height;
  • physical exertion;
  • crowds;
  • noise;
  • heat;
  • water;
  • animals;
  • religious restrictions;
  • long transfer;
  • extreme activity.

If a person says "I don't like caves", the system should exclude not only the cave type, but also the attributes:

  • underground object;
  • grotto;
  • tunnel;
  • tight space;
  • speleology.

That is, you need not only positive interests, but also anti-preferences.


"Needs" fulfilled by the object are needed

This is a separate strong idea.

Each place can fulfill one or more needs:

  • eat;
  • stay overnight;
  • swim;
  • entertain children;
  • relax;
  • walk;
  • view nature;
  • buy things;
  • get emotions;
  • spend the evening;
  • take shelter from rain;
  • spend a whole day without moving places.

For example:

Waterfall

Fulfills:

  • nature;
  • photos;
  • walk;
  • swimming — sometimes;
  • activity.

Casino

Fulfills:

  • evening;
  • entertainment;
  • nightlife;
  • restaurant — sometimes.

Tourist base

Fulfills:

  • food;
  • nature;
  • swimming;
  • overnight stay;
  • activity;
  • 1–2 day vacation.

Then the route can be built not around the number of points, but around the balance of fulfilled needs.


Object metrics are needed

I would divide them into several groups.

Attractiveness

  • uniqueness;
  • visual value;
  • popularity;
  • quality of experience;
  • reputation;
  • relevance.

Logistics

  • remoteness;
  • accessibility;
  • travel time;
  • access difficulty;
  • availability of parking;
  • transport dependence.

Time capacity

  • minimum time;
  • recommended time;
  • maximum reasonable time;
  • can it occupy half a day;
  • can it occupy a whole day;
  • can you stay for the night.

Composition

  • number of activities;
  • number of services;
  • presence of food;
  • presence of accommodation;
  • presence of entertainment;
  • presence of natural objects;
  • self-sufficiency.

Context

  • suitable in the morning;
  • suitable in the evening;
  • suitable in rain;
  • suitable in heat;
  • suitable with children;
  • suitable without a car;
  • suitable for a couple;
  • suitable for solo.

"Utility density" is very important

You can introduce an internal metric:

How many different needs are fulfilled in a single point

For example:

Regular waterfall

  • nature;
  • photos;
  • walk.

Utility density: medium.

Recreation base

  • nature;
  • swimming;
  • food;
  • overnight stay;
  • cave;
  • camping;
  • activity.

Utility density: high.

Large island complex

  • beach;
  • attractions;
  • restaurants;
  • shows;
  • hotels;
  • children;
  • evening program.

Utility density: very high.

Such objects need to be displayed differently in the route:

"This point can take 2–3 days and replace several separate trips."


Connections are needed not only between places, but also inside complexes

Connections can be as follows:

  • part of the complex;
  • located on the territory;
  • available only through the complex;
  • can be visited separately;
  • included in a general ticket;
  • requires a separate ticket;
  • works at different hours;
  • best visited in a certain sequence.

For example:

Pearl Island Includes:

  • water park;
  • beach;
  • restaurants;
  • shows;
  • cable car;
  • hotels.

The user can:

  • add the whole complex;
  • add only a part;
  • add the complex for 1 day;
  • add the complex for 3 days.

How to apply this to recommendations

The system should first determine the user's intent, and only then select object types.

Example:

The user is looking for an evening dress.

Intentions:

  • shopping;
  • city;
  • evening;
  • possibly restaurant;
  • possibly photogenic spot;
  • minimal travel.

Inappropriate categories:

  • diving;
  • waterfalls;
  • caves;
  • remote bays.

Appropriate combination:

shopping mall → salon/boutique → restaurant → bar/show

Another example:

The user is going to a mountain.

Intentions:

  • nature;
  • activity;
  • panoramas;
  • possibly a full day.

Additional connections:

  • stream;
  • cave;
  • cafe;
  • camping;
  • viewpoint;
  • recreation base;
  • overnight stay.

And then the system offers not "another mountain", but a ready-made cluster of impressions.


Route classification

The route should also have a focus/orientation:

  • urban;
  • natural;
  • gastronomic;
  • evening;
  • family;
  • adventure;
  • romantic;
  • beach;
  • mixed;
  • without transfers;
  • with high activity density;
  • relaxed.

And balance:

  • 70% nature / 30% city;
  • 50% rest / 50% activity;
  • 20% shopping / 30% food / 50% entertainment.

This will make it possible to check whether the route has "broken".

For example:

The user wants a quiet beach holiday, but the route consists 60% of travel and activities.

The agent should say:

The route does not match your preferences.


The most useful new entity

I would add not only complex, but also scenario.

Scenario examples:

  • rainy day;
  • evening in the city;
  • two days without leaving the complex;
  • trip with a child;
  • romantic evening;
  • active day in the mountains;
  • car-free day;
  • stopover on the way between cities.

The scenario connects:

  • intentions;
  • limitations;
  • place types;
  • complexes;
  • time;
  • sequence of actions.

Minimal model

To your entities:

  • province;
  • city;
  • place type;
  • place;

I would add:

  • complex;
  • scenario;
  • need;
  • interest;
  • anti-preference;
  • activity;
  • service;
  • route profile.

And connections:

  • place is part of the complex;
  • place fulfills a need;
  • place fits a scenario;
  • place conflicts with a preference;
  • place complements another place;
  • place replaces another place;
  • place requires a separate day;
  • place can be visited on the way;
  • complex is designed for N days.

Main conclusion

It is better to build the system not around the question:

"What kind of place is this?"

But around three questions:

Why go there? How much time can it take? What needs does it fulfill?

And separately highlight composite objects where a person can actually spend a day, two, or three without having to travel to dozens of scattered points.

This is all relevant to this worklog.

I won't quote the entire chat with ChatGPT, but I've completely rethought the essence of concepts and facts, and it all boils down to the fact that there's no point in making facts separate entities in our implementation. Our concepts themselves can also be facts.

Here is a brief summary of how I see it:


We don't have a banking system to build detailed relationships. Our information lifecycle works like this:

1. A concept that didn't exist before is created. For example, Some Homestay.

2. Everything you want to convey is written inside it in plain text. This is all the stuff the user will read later. In our project, this is essentially a fact in itself, because we create all the content ourselves. And we immediately specify qualitative attributes for what is what. Over time, when this information turns out to be of poor quality, we will simply lower its quality attribute. Conceptually, we only need a single float metric from 0 to 1 right now. The higher the quality, the higher the visibility. For example, my website is full of old articles that are no longer needed and should be thrown away, but I can't throw them away because 404s aren't cool. I just need to push them further down.

3. Linking with other concepts. That means I just need cross-linking. Just like on classic websites, I can simply add links to other concepts. This is so the user can read and navigate to related cards. But this is an external link. I also need the reverse link. Accordingly, I don't just need anchor links here, but also accompanying technical link information (why I'm linking to this and, once again, some quality attribute).

4. When saving such a concept, all its relationships are recalculated. Those that were removed from the content are also removed from the relationship list, and added ones appear.

This is precisely where we need a flat many-to-many concept relationship table. Here, we can then look at the overall semantics of the entire knowledge base.

And here is what I am thinking, if we develop this idea further: we can create complete semantics at the HTML tag level. We simply insert links into the text. At the same time, they have innerText, title, rel, data-attributes, etc. All of this can be mirrored with our index relationship table. Meanwhile, parent and root remain as hard links.


Here is what ChatGPT summarized:

Yes, this is already looking very cohesive.

Then HTML/Markdown content becomes the primary semantic graph, and the relationship table acts as its materialized index for fast queries and back-linking.

At the same time:

  • parentId — hard direct hierarchy;
  • rootId — belonging to a single tree or section;
  • HTML links — soft semantic many-to-many relationships;
  • relationship table — a mirrored representation of links from the content.

Link as a full-fledged semantic relation

For example, in the editor:

<a
  href="/places/som-homestay"
  data-concept-id="som-homestay-id"
  data-relation="overnight"
  data-context="Suitable overnight stay before an early visit to the cave"
  data-quality="0.9"
  title="Som Homestay — overnight stay near the cave"
>
  stay overnight at Som Homestay
</a>

This already contains almost all the necessary semantics:

  • href — public address;
  • data-concept-id — stable technical identity;
  • innerText — natural anchor;
  • title — additional explanation for the user;
  • data-relation — type of semantic relationship;
  • data-context — why the link appeared;
  • quality — strength or usefulness of the link.

An index entry is created from it:

sourceId: cave-id
targetId: som-homestay-id
relation: overnight
anchor: stay overnight at Som Homestay
title: Som Homestay — overnight stay near the cave
context: Suitable overnight stay before an early visit to the cave
quality: 0.9

What to store in rel

I would be careful about using the standard HTML rel attribute.

It already has browser and SEO semantics:

  • nofollow;
  • ugc;
  • sponsored;
  • noopener;
  • noreferrer;
  • alternate;
  • author;
  • tag.

It is safer to store your domain-specific semantics separately:

data-relation="overnight-before"

And use standard rel for its intended purpose:

rel="ugc nofollow"

Otherwise, you might later run into a conflict between your own model and the HTML standard.

Minimal link format

For manual work in the editor, too many attributes will get in the way. Therefore, I would make only the following mandatory:

<a
  data-concept-id="..."
  data-relation="..."
>
  link text
</a>

The rest can be determined automatically:

  • href — retrieve via SiteRoute;
  • anchor — take from innerText;
  • title — generate from the linked concept;
  • quality — use the default value;
  • context — extract from the surrounding sentence;
  • position — calculate via parser.

Advanced mode is only needed where the author wants to explicitly override the semantics:

<a
  data-concept-id="..."
  data-relation="overnight"
  data-quality="0.95"
  data-context="Best place to stay before an early departure"
>
  Som Homestay
</a>

Surrounding text also carries semantics

Suppose it is written:

Before an early visit to the cave, it's better to
<a data-concept-id="som-id">stay overnight at Som Homestay</a>,
since the drive in the morning takes only about 30 minutes.

Even without an explicit data-relation, an agent or parser can infer:

relation: overnight_before
context: overnight stay before an early visit to the cave

But I would distinguish between:

  • explicit author semantics;
  • automatically determined semantics.

For example:

origin: explicit

or:

origin: inferred
confidence: 0.72

It's not necessary to show this to the editor. This is technical information for the index table.

Possible table schema

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)

  // Stable identifier of a specific link in the editor
  nodeId String? @db.VarChar(100)

  // Position within the content
  position Int?

  // Original or additional HTML attributes
  data Json?

  @@unique([sourceId, nodeId])
  @@index([sourceId])
  @@index([targetId])
  @@index([sourceId, relation])
  @@index([targetId, relation])
  @@index([quality])
}

quality and confidence have different meanings here:

  • quality — how useful the connection is for the user;
  • confidence — how confidently its semantics are determined.

For an explicitly specified link:

origin = explicit
confidence = 1

For an automatically recognized one:

origin = inferred
confidence = 0.74

At the initial stage, confidence can be omitted altogether.

A stable HTML node identifier is needed

If you simply use sourceId + targetId, a problem arises: a single text might link to the same concept multiple times for different reasons.

For example:

<a
  data-node-id="link-a17"
  data-concept-id="som-id"
  data-relation="overnight-before"
>
  overnight stay before the visit
</a>

<a
  data-node-id="link-b24"
  data-concept-id="som-id"
  data-relation="cold-weather-warning"
>
  it can get cold here in winter
</a>

These are two independent relationships with the exact same target.

data-node-id should be generated automatically by the editor and preserved when moving the link within the document.

Then synchronization upon saving is simple:

  • link with the same nodeId — updated;
  • new nodeId — added;
  • missing nodeId — deleted.

HTML and the index must have a single source of truth

It is advisable to avoid two-way editing here.

I would establish that:

Content is the source of truth for outgoing editorial links.

The table is an index.

This means:

  • the user edits the link in the text;
  • after saving, the index is recalculated;
  • direct editing of index table rows is prohibited or restricted;
  • backlink blocks are generated from the index table;
  • modifying an incoming relationship is done through the source concept.

Otherwise, desynchronization will occur: the table says one thing, HTML says another.

Automatic links can be stored separately from content links and not mirrored into HTML until explicitly accepted.

Backlinks

If the cave card contains:

<a
  data-concept-id="som-id"
  data-relation="overnight-after"
>
  Som Homestay
</a>

Then on the Som Homestay page, you can automatically display:

Suitable for staying overnight after the visit

Cave X — the author recommends this homestay as a place to stay after the visit.

Or embed a dynamic block into the text:

<concept-backlinks
  data-relation="overnight-after"
  data-limit="5"
></concept-backlinks>

Moreover, the backlink representation does not have to repeat the original anchor. It is built from:

  • relationship type;
  • context;
  • source concept;
  • link quality.

parent and root are indeed worth keeping

They solve a different problem.

parentId

Hard ownership or main container:

Thai Nguyen
└── Som Homestay

or content structure:

Accommodation
└── Homestays
    └── Som Homestay

You just need to choose a single meaning for parentId and not mix multiple hierarchies.

For example:

parentId always means editorial belonging to the main section.

rootId

Speeds up:

  • retrieving the entire tree;
  • searching within a section;
  • code uniqueness;
  • managing a large branch;
  • deleting or moving a section.

Soft links

Everything else:

  • recommended overnight stay;
  • part of a complex;
  • alternative;
  • next route point;
  • associated with weather risk;
  • mentioned in the itinerary;

goes through the link index.

Full semantics do not necessarily require complex tags

You can start with a regular link:

<a data-concept-id="som-id">Som Homestay</a>

And determine the relationship type across three levels:

  1. Explicit data-relation.
  2. Context of the parent block.
  3. Analysis of the surrounding text by an agent.

For example:

<section data-semantic-context="recommended-overnight">
  <p>
    Before the trip, it's best to stop at
    <a data-concept-id="som-id">Som Homestay</a>.
  </p>
</section>

Here, every link within the section inherits:

relation = recommended_overnight

This can greatly simplify manual markup.

The same thing in Markdown components:

:::semantic{relation="overnight-before"}
Before an early visit, it's best to stay at
[Som Homestay](concept:som-id).
:::

Semantics can live at different DOM levels

On the link

For precise individual relationships:

<a data-relation="alternative">

On the paragraph

For general context:

<p data-semantic-context="winter-warning">

On the section

For a group of links:

<section data-semantic-context="where-to-stay">

On a custom component

For dynamic fetching:

<concept-list
  data-relation="overnight-before"
  data-source="current"
></concept-list>

The parser can inherit semantics from top to bottom:

link.relation
  ?? paragraph.semanticContext
  ?? section.semanticContext
  ?? inferredFromText
  ?? "reference"

What you get in the end

You end up with four aligned levels:

Hard tree:
parentId / rootId

Author meaning:
HTML content and links

Fast graph:
KBConceptRelation

Dynamic representation:
backlinks, lists, and agent blocks

This is a very practical architecture. It doesn't require describing the entire world in tables separately: semantics emerge right in the process of writing normal human text, and the database merely materializes it for search, analysis, and reuse.

Added a really cool AI feature :-) - content editing with internal cross-linking. The main highlight is passing the entire set of internal links to the agent, and it automatically replaces plain text with links within the content. But as usual, the AI doesn't work quite correctly at times and invents non-existent links. However, this is fixable - I simply added post-processing for the content, and it cleans up the content from broken internal links (fortunately, the content is entirely in Markdown, which makes this much easier than with pure HTML).

import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkMdx from 'remark-mdx'
import remarkStringify from 'remark-stringify'
import { visit } from 'unist-util-visit'
import type { Link, Text } from 'mdast'
import type { Parent } from 'unist'

export interface ConceptLink {
  uri: string
}

export interface InvalidLink {
  url: string
  position?: {
    start: { line: number; column: number }
    end: { line: number; column: number }
  }
}

export interface ValidateInternalLinksResult {
  invalidLinks: InvalidLink[]
}

function isInternalLink(url: string): boolean {
  return url.startsWith('/')
}

export function buildValidUrisSet(concepts: ConceptLink[]): Set<string> {
  return new Set(concepts.map((c) => c.uri))
}

export async function removeInvalidLinks(
  content: string,
  validUris: Set<string>,
): Promise<string> {
  const tree = unified().use(remarkParse).use(remarkMdx).parse(content)

  visit(tree, 'link', (node: Link, index, parent: Parent | undefined) => {
    if (!parent || index === undefined) {
      return
    }

    const url = node.url

    if (isInternalLink(url) && !validUris.has(url)) {
      if (process.env.NODE_ENV === 'development') {
        console.error('Invalid url', url)
      }

      const textNode: Text = {
        type: 'text',
        value: node.children
          .filter((child): child is Text => child.type === 'text')
          .map((child) => child.value)
          .join(''),
      }

      parent.children.splice(index, 1, textNode)
    }
  })

  const result = unified()
    .use(remarkStringify, {
      bullet: '-',
      emphasis: '_',
      strong: '*',
    })
    .stringify(tree)

  return result
}

Here is an example of a processed article: https://vietnamguru.ru/content/reference/aktualniy-spisok-provintsiy-vetnama

It was originally written without a single internal link. Yet there should be many links there, as it is a list of provinces (and sometimes cities as well). Editing it manually by yourself is quite tedious (you not only need to find the links on your own website, but also check if such a province actually exists in our catalog). By the way, there is a side bonus here - we immediately got a list of missing provinces, making it instantly clear what needs to be added to the catalog. And so we ended up with content like this: