Nhiệm vụ: Xem xét lại cấu trúc trang web, cải tiến nó và xây dựng lại nội dung

Xem xét lại cấu trúc trang web, cải tiến nó và xây dựng lại nội dung

13.07.2026vietnamguru.ru

Hiện tại tôi có tất cả các thực thể riêng biệt (được thừa hưởng từ hệ thống cũ):

  • Các tỉnh thành
  • Các thành phố
  • Loại địa điểm (Lặn biển, Thác nước, Chùa chiền, v.v.)
  • Bản thân các địa điểm.

Điều này hiện tại rất hạn chế và ít mang lại giá trị sử dụng. Cần phải suy nghĩ xem nên liên kết các thực thể nào với nhau, hiển thị thêm các thành phần nào trên trang và viết lại toàn bộ nội dung.

Ворклоги

Thêm thông tin từ ChatGPT

Vâng, với mô hình này, phía kỹ thuật không phải là điểm hạn chế. Hơn nữa, bạn đã có sẵn hầu như mọi thứ cần thiết cho một knowledge graph du lịch — chỉ là hiện tại nó đang được mô tả bằng các thuật ngữ tổng quát.

Quan sát chính: không cần phải tạo các bảng riêng cho từng thực thể du lịch. Các tỉnh, thành phố, địa điểm, hoạt động, nhu cầu, kịch bản và khu phức hợp đều có thể là KBConcept, và ý nghĩa mối quan hệ giữa chúng là KBFact với các bên tham gia thông qua KBFactParticipation. Chính vì điều này mà mô hình n-ary của bạn tỏ ra cực kỳ phù hợp.

Cách tôi đọc kiến trúc của bạn

Bạn đã có sẵn năm tầng rất mạnh mẽ.

1. Các thực thể

KBConcept là một nút ngữ nghĩa phổ quát:

  • Việt Nam;
  • Tỉnh Khánh Hòa;
  • Nha Trang;
  • VinWonders;
  • thác nước;
  • lặn biển;
  • nghỉ dưỡng gia đình;
  • thời tiết mưa;
  • chuyến đi ba ngày;
  • hành trình cụ thể.

Đồng thời nó có:

  • type;
  • phân cấp thông qua parentId;
  • gốc thông qua rootId;
  • tọa độ;
  • dữ liệu tùy ý trong data;
  • tên đa ngôn ngữ qua KBLabel;
  • tệp và hình ảnh;
  • URL riêng qua SiteRoute.

Tức là đây đã là nền tảng sẵn sàng cho cả danh mục (catalog) và đồ thị (graph) cùng một lúc.

2. Các mối quan hệ và khẳng định (Assertions)

KBFact không chỉ là một cạnh của đồ thị, mà là một khẳng định hoàn chỉnh:

  • loại quan hệ;
  • biểu diễn văn bản;
  • tính hiệu lực theo thời gian;
  • nguồn gốc;
  • độ tin cậy;
  • trạng thái kiểm tra;
  • mức độ quan trọng;
  • nguồn gốc của sự thật (provenance).

KBFactParticipation cho phép một sự thật liên kết bất kỳ số lượng đối tượng nào với các vai trò khác nhau.

Ví dụ, khẳng định:

VinWonders phù hợp cho gia đình có trẻ em để nghỉ dưỡng trong hai ngày thời tiết khô ráo.

Có thể được thể hiện bằng một sự thật duy nhất:

KBFact.type = "visit_suitability"

Những người tham gia:

VinWonders       role=destination
Gia đình có trẻ em role=audience
Hai ngày         role=recommended_duration
Thời tiết khô ráo role=preferred_condition

Điều này đã biểu cảm hơn rất nhiều so với bảng place_tags thông thường.

3. Tính tạm thời và sự bất định

Đối với du lịch, điều này cực kỳ quan trọng.

Trong mô hình của bạn, một sự thật có thể có:

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

Nghĩa là bạn có thể lưu trữ bình thường:

  • đóng cửa theo mùa;
  • sửa chữa tạm thời;
  • thay đổi giá;
  • lịch trình mới;
  • đường đi xuống cấp;
  • mùa sứa;
  • đóng cửa cáp treo;
  • lễ hội;
  • hạn chế tắm biển tạm thời.

Hơn nữa, tri thức mới không bắt buộc phải ghi đè lên tri thức cũ. Điều này hoàn toàn phù hợp với thông tin du lịch thực tế.

4. Các mâu thuẫn (Conflicts)

KBConflictKBConstraint cho phép bạn không phải giả vờ rằng cơ sở dữ liệu luôn biết sự thật tuyệt đối.

Ví dụ:

  • trang web chính thức nói vé vào cửa là 500.000 VNĐ;
  • đánh giá mới nhất cho biết giá là 600.000 VNĐ;
  • trang tổng hợp hiển thị 550.000 VNĐ.

Thay vì chọn ngẫu nhiên, bạn có thể lưu trữ cả ba sự thật và mở một xung đột value_mismatch.

Đối với nút "Cần biết gì lúc này", điều này cực kỳ có giá trị:

Giá chính thức là 500.000 ₫, nhưng hai nguồn gần đây cho biết giá là 600.000 ₫. Chúng tôi khuyên bạn nên kiểm tra trước khi đi.

Điều này trung thực hơn rất nhiều so với cơ sở dữ liệu thông thường.

5. Không gian tri thức theo ngữ cảnh (Knowledge Spaces)

KBKnowledgeSpaceKBFactProjection cho phép cùng một sự thật được diễn giải khác nhau trong các ngữ cảnh khác nhau.

Ví dụ:

  • cơ sở biên tập của VietnamGuru;
  • không gian cá nhân của người dùng;
  • kết quả tìm kiếm web tạm thời;
  • dữ liệu từ chuyên gia địa phương;
  • tin nhắn của người dùng;
  • không gian của một hành trình cụ thể.

Một sự thật có thể có các mức độ khác nhau về:

  • khả năng hiển thị;
  • độ đáng tin cậy;
  • tầm quan trọng.

Điều này giúp không trộn lẫn dữ liệu được biên tập viên xác thực với các mẹo của người dùng.


Cách chuyển đổi du lịch sang mô hình này

Tôi sẽ không tạo hàng chục mô hình Prisma như Province, City, Place, Activity, Need, Scenario.

Thay vào đó là một từ vựng giới hạn cho các loại KBConcept.type.

Ví dụ:

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

Điều này không nhất thiết phải biến thành enum ngay trong Prisma. Ở giai đoạn đầu, kiểu chuỗi type thậm chí còn hữu ích hơn — nó cho phép phát triển hệ thống ngữ nghĩa (ontology) mà không cần migration schema.

Tuy nhiên, bản thân từ vựng các loại nên được tập trung hóa trong mã nguồn.


Phân cấp địa lý

Đối với cấu trúc hành chính, chỉ cần dùng parentId:

Việt Nam
└── Khánh Hòa
    ├── Nha Trang
    └── Cam Ranh

Nhưng parentId chỉ nên được sử dụng cho phân cấp thực sự.

Không nên dùng nó để thể hiện:

  • địa điểm nằm gần đó;
  • địa điểm nằm trong hành trình;
  • nhà hàng nằm trong khu phức hợp;
  • thành phố là căn cứ để đi du lịch.

Đối với những trường hợp này, bạn cần các sự thật (facts).

Ví dụ:

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

Tại sao không giới hạn ở parentId? Bởi vì một đối tượng có thể đồng thời nằm ở:

  • trong quận;
  • trong thành phố;
  • trong tỉnh;
  • trong khu du lịch;
  • trong khuôn viên khu phức hợp.

Đây không còn là cây (tree) nữa, mà là đồ thị (graph).


Cách thể hiện các loại địa điểm

"Loại địa điểm" hiện tại của bạn là một KBConcept riêng biệt:

Thác nước
Hang động
Hang hốc (Grotto)
Chùa
Sòng bạc
Nhà hàng
Trung tâm lặn biển

Mối liên kết:

Fact.type = "has_type"

place      role=subject
placeType  role=type

Một đối tượng có thể có nhiều loại.

Ví dụ, một khu nghỉ dưỡng:

Khu sinh thái
Khu cắm trại
Quần thể thiên nhiên
Khu vực tắm biển
Tổ hợp nhà hàng

Cách này tốt hơn trường typeId, vì các đối tượng phức hợp hầu như không bao giờ vừa vặn trong một danh mục duy nhất.


Cách thể hiện mối quan hệ ngữ nghĩa giữa các loại

Ví dụ của bạn:

Nếu một người không thích hang động, họ không cần hang hốc (grotto).

Cần liên kết chính các loại với nhau.

Fact.type = "semantic_subtype"

Hang hốc  role=child
Hang động role=parent

Bạn có thể xây dựng một hệ thống ngữ nghĩa như sau:

Các đối tượng tự nhiên ngầm dưới đất
├── Hang động
├── Hang hốc (Grotto)
├── Sông ngầm
├── Đường hầm Karst
└── Tuyến thám hiểm hang động

Nhưng ở đây có một sự phân chia quan trọng.

parentId có thể dùng cho phân loại nghiêm ngặt:

hang hốc là một dạng của đối tượng ngầm.

Còn KBFact dùng cho các mối quan hệ mềm:

similar_to
commonly_combined_with
may_trigger_same_avoidance
alternative_to

Ví dụ:

Địa đạo Củ Chi không phải là hang động, nhưng có thể không phù hợp với người mắc chứng sợ không gian hẹp.

Phân loại học (taxonomy) ở đây sẽ không giúp ích. Cần liên kết với khái niệm:

Không gian chật hẹp / Không gian kín

Sự thật (Fact):

Fact.type = "has_experience_attribute"

Địa đạo Củ Chi          role=subject
Không gian chật hẹp     role=attribute

Khi đó, việc loại trừ của người dùng được áp dụng theo thuộc tính trải nghiệm, chứ không chỉ theo danh mục.


Phải tách biệt loại đối tượng và tính chất trải nghiệm

Đây là điểm cốt lõi.

Ví dụ, "hang động" mô tả một đối tượng vật lý. Nhưng đối với người dùng, điều quan trọng hơn là:

  • tối;
  • chật chội;
  • ẩm ướt;
  • đòi hỏi thể lực;
  • tính phiêu lưu;
  • đi bộ đường dài;
  • nguy cơ bị bẩn;
  • không phù hợp khi mặc đầm dạ hội.

Do đó, cần các khái niệm kiểu experience_attribute:

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

Các mối liên kết:

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

Ví dụ với đầm dạ hội:

Ngữ cảnh người dùng:
formal_clothing = true

Đối tượng:

Thác nước
has_experience_attribute = wet
has_experience_attribute = uneven_terrain
has_experience_attribute = outdoor

Hệ thống suy luận không phải vì "đầm dạ hội không tương thích với thác nước" được viết cứng thủ công, mà thông qua các đặc điểm (attributes).

Trong khi đó, nhà hàng, sòng bạc hay nhà hát lại có:

formal_friendly
indoor
evening_suitable


Nhu cầu của người dùng

Tôi sẽ thiết kế các nhu cầu dưới dạng các khái niệm (concepts):

Ăn uống
Ngủ nghỉ
Tắm biển
Mua quần áo
Giải trí cho trẻ em
Dành buổi tối
Ngắm thiên nhiên
Tránh di chuyển nhiều
Dành trọn ngày mưa
Trải nghiệm năng động
Thư giãn

Mối liên kết:

Fact.type = "satisfies_need"

place  role=provider
need   role=need

Ở phần tham gia (participation), bạn có thể dùng value để chỉ mức độ mạnh yếu:

value = "0.9"

Hoặc localImportance, mặc dù về mặt ngữ nghĩa thì không nên trộn lẫn tầm quan trọng của sự thật và mức độ thỏa mãn nhu cầu. Đối với các giá trị tính toán, tôi sẽ thêm trường data có cấu trúc vào sự thật hoặc một trường số riêng.

Ví dụ:

{
  "strength": 0.9,
  "capacity": "full",
  "notes": "Có thể dành trọn một ngày"
}

Hiện tại KBFact chưa có chung data, và đây là một trong số ít các phần mở rộng mà tôi thực sự cân nhắc.


Khu phức hợp và địa điểm thành phần

Bạn chắc chắn cần ngữ nghĩa của đối tượng phức hợp, nhưng không nhất thiết phải có mô hình Prisma riêng.

Khái niệm:

KBConcept.type = "place_complex"

Ví dụ:

VinWonders Nha Trang
Khu nghỉ dưỡng X
Vườn quốc gia Phong Nha
Cụm nghỉ dưỡng

Các thành phần là các địa điểm hoặc dịch vụ thông thường.

Mối liên kết:

Fact.type = "part_of_complex"

công viên nước role=component
VinWonders     role=complex

Hoặc bạn có thể tạo một sự thật phong phú hơn:

Fact.type = "complex_composition"

Những người tham gia:

VinWonders       role=complex
Công viên nước   role=component
Bãi biển         role=component
Khách sạn        role=component
Nhà hàng         role=component
Show diễn        role=component

Tôi vẫn thích tạo các sự thật riêng cho từng thành phần hơn, bởi vì:

  • các thành phần có giờ hoạt động khác nhau;
  • vé khác nhau;
  • thời hạn hiệu lực khác nhau;
  • nguồn gốc khác nhau;
  • mức độ tin cậy khác nhau.

Ví dụ:

Fact.type = "complex_component"
subject = Công viên nước
container = VinWonders
access_mode = included_ticket

Khi đó statement có thể do con người đọc được, còn các chi tiết nằm trong dữ liệu có cấu trúc.


"Có thể la cà suốt ba ngày" có nghĩa là gì

Đây không phải là một trường duration duy nhất.

Cần phân biệt:

  • thời gian tối thiểu để làm quen;
  • thời lượng điển hình;
  • thời lượng hữu ích tối đa;
  • có chỗ lưu trú hay không;
  • các hoạt động bên trong có đủ phong phú không;
  • có cần thiết phải đi ra ngoài đối tượng hay không.

Ví dụ, đối với VinWonders:

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

Điều này có thể được biểu diễn bằng vài sự thật:

recommended_duration
supports_overnight_stay
self_sufficiency
activity_capacity

self_sufficiency là một chỉ số đặc biệt quan trọng.

Ví dụ:

0.1 — một đài quan sát đơn lẻ
0.4 — điểm tham quan có quán cà phê và bãi đỗ xe
0.7 — khu nghỉ dưỡng trọn gói trong ngày
0.95 — tổ hợp nghỉ dưỡng kéo dài nhiều ngày

Nó có thể được tính toán từ:

  • ăn uống;
  • lưu trú;
  • số lượng hoạt động;
  • sự đa dạng của hoạt động;
  • chương trình buổi tối;
  • cơ sở hạ tầng sinh hoạt;
  • phương tiện di chuyển nội khu;
  • khả năng che chắn thời tiết.

Tôi sẽ không lưu trữ nó hoàn toàn bằng tay. Tốt hơn là lưu trữ các sự thật gốc, còn điểm số tổng hợp sẽ được tạo dưới dạng KBFactType.derived kèm theo derivedFrom.

Bạn đã có sẵn factTypederivedFrom cho việc này.


Các liên kết địa điểm (Sự kết hợp)

Mô hình của bạn cho phép làm cho chúng phong phú hơn rất nhiều so với placeAId/placeBId đơn thuần.

Liên kết cặp đơn giản

Fact.type = "works_well_together"

Địa điểm A  role=place
Địa điểm B  role=place

Nhưng tốt hơn là nên bổ sung ngữ cảnh ngay lập tức:

Địa điểm A        role=place
Địa điểm B        role=place
Một ngày          role=duration
Gia đình có trẻ em role=audience
Thời tiết khô ráo role=condition

Khi đó sự thật có nghĩa là:

Các địa điểm này kết hợp rất tốt trong một ngày cho gia đình có trẻ em vào ngày thời tiết khô ráo.

Tập hợp nhiều địa điểm

Mô hình n-ary đặc biệt hữu ích ở đây:

Fact.type = "recommended_visit_cluster"

Núi           role=anchor
Suối          role=optional_stop
Hang động     role=optional_stop
Nhà hàng      role=meal_stop
Khu nghỉ dưỡng role=overnight_base
Một rưỡi ngày role=recommended_duration

Đây đã là một phân đoạn hành trình hoàn chỉnh, nhưng chưa phải là hành trình của người dùng.


Tôi sẽ tách biệt cụm (cluster) và hành trình (route)

Đây là hai thực thể khác nhau.

Cụm (Cluster)

Sự liên kết khách quan hoặc theo góc nhìn biên tập:

Các đối tượng này về mặt địa lý và kịch bản tạo thành một tổ hợp tham quan thống nhất.

Loại:

KBConcept.type = "visit_cluster"

Ví dụ:

  • núi + suối + hang động + nhà hàng;
  • phố cổ + chợ + bờ sông;
  • đảo + bãi biển + công viên giải trí + khách sạn.

Hành trình (Route)

Thứ tự cụ thể:

Đầu tiên là núi, tiếp theo là suối, sau đó là nhà hàng, ngủ đêm tại khu nghỉ.

Loại:

KBConcept.type = "route"

Hành trình cần các phân đoạn được sắp xếp thứ tự.

KBFactParticipation hiện tại của bạn tự nó không lưu trữ thứ tự. Bạn có thể dùng value như 1, 2, 3, nhưng cách này không được sạch sẽ cho lắm.

Tôi sẽ bổ sung vào KBFactParticipation:

position Int?
data     Json?

Khi đó một sự thật kiểu route_composition có thể chứa:

Núi       role=stop position=1
Suối      role=stop position=2
Nhà hàng  role=stop position=3
Khu nghỉ  role=stop position=4

Và trong data của mỗi lượt tham gia:

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

Đây là một trong những phần mở rộng có tính thực tiễn cao nhất cho schema của bạn.


Sở thích của người dùng

Có thể lưu trữ theo hai cách.

Dưới dạng sự thật của người dùng (User facts)

Người dùng cũng là một khái niệm hoặc được liên kết với khái niệm hồ sơ (profile).

Ví dụ:

Fact.type = "user_preference"

UserConcept       role=subject
Hang động         role=target
Không thích       role=preference

Nhưng tốt hơn là chuẩn hóa thành:

likes
dislikes
avoids
requires
prefers
neutral_to

Ví dụ:

Fact.type = "avoids"

Người dùng        role=subject
Không gian kín    role=target

Khi đó sẽ loại trừ:

  • hang động;
  • hang hốc;
  • đường hầm;
  • đền thờ ngầm;

nếu chúng liên kết với thuộc tính trải nghiệm (experience attribute) này.

Dưới dạng không gian tri thức của hành trình

Đối với một chuyến đi cụ thể, bạn có thể tạo KBKnowledgeSpace:

Chuyến đi Việt Nam, tháng 8 năm 2026

Các thông tin được chiếu (project) vào đây:

  • sở thích;
  • ràng buộc;
  • những người tham gia;
  • ngày tháng;
  • ngân sách;
  • các địa điểm đã chọn;
  • các sự thật hiện tại;
  • kết quả của agent.

Cách này tích hợp cực kỳ tự nhiên vào mô hình của bạn.


Không phải mọi sở thích đều mang tính toàn cục

Ví dụ:

  • người dùng nhìn chung thích thác nước;
  • nhưng hôm nay họ đang mặc trang phục dạ hội;
  • trong chuyến đi này họ đi cùng trẻ nhỏ;
  • ngày mai họ chỉ có ba tiếng;
  • trời đang mưa.

Do đó cần các ngữ cảnh khác nhau:

Hồ sơ người dùng toàn cục
Chuyến đi cụ thể
Ngày cụ thể
Phiên làm việc hiện tại

Chính KnowledgeSpace cho phép không biến điều kiện tạm thời "hôm nay tôi mặc đầm dạ hội" thành thuộc tính vĩnh viễn của người dùng.


Cách tính độ tương thích của địa điểm với truy vấn

Bạn có thể tách điểm số tổng (score) thành các thành phần:

intent_match
need_coverage
avoidance_conflict
context_fit
time_fit
geographic_fit
route_synergy
weather_fit
novelty
quality
confidence

Đại loại như:

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

Nhưng tôi sẽ không cố định một công thức duy nhất mãi mãi. Trọng số phụ thuộc vào kịch bản.

Đối với truy vấn "tìm nhà hàng gần đây":

  • vị trí địa lý — cao;
  • đang mở cửa — cao;
  • phù hợp ẩm thực — cao;
  • tính độc đáo — thứ yếu.

Đối với hành trình hai tuần:

  • sự đa dạng;
  • logic di chuyển (logistics);
  • sức chứa thời gian;
  • tính mùa vụ;
  • cân bằng hành trình.

Tốt hơn là biểu diễn chính hồ sơ đánh giá (scoring profile) dưới dạng một khái niệm:

restaurant_now_profile
family_day_trip_profile
multi_day_route_profile
formal_evening_profile

Và các trọng số dưới dạng sự thật hoặc data.


Ràng buộc cứng và ràng buộc mềm

Đây là điều bắt buộc phải phân tách.

Ràng buộc cứng (Hard constraints)

Đối tượng tuyệt đối không được xuất hiện trong kết quả:

  • người dùng loại trừ các địa điểm ngầm;
  • đối tượng đã đóng cửa;
  • không đủ thời gian;
  • trẻ em không đủ độ tuổi yêu cầu;
  • không thể di chuyển tới;
  • vượt quá ngân sách;
  • không tương thích với hạn chế thể lực.

Ràng buộc mềm (Soft constraints)

Đối tượng có thể xuất hiện ở vị trí thấp hơn:

  • xa hơn mong muốn;
  • đắt hơn một chút;
  • ít liên quan hơn;
  • tương tự với địa điểm đã chọn;
  • yêu cầu trang phục đặc biệt;
  • phụ thuộc vào thời tiết.

KBConstraint của bạn phù hợp cho các quy tắc khai báo (declarative rules), còn KBConflict dùng để ghi nhận các vi phạm.

Ví dụ:

constraint:
"Hành trình của người dùng tránh các trải nghiệm ngầm (underground experience)
không được chứa các địa điểm có thuộc tính underground."

Tuy nhiên, tôi sẽ không tạo KBConflict cho mọi ứng viên bị hệ thống gợi ý loại bỏ. Xung đột chỉ cần thiết cho những mâu thuẫn tri thức quan trọng hoặc một hành trình đã được hình thành sẵn, chứ không phải cho việc lọc thông thường.


Những gì cần thêm vào schema ở mức tối thiểu

Bạn đã có sẵn nền tảng. Tôi sẽ không thực hiện tái cấu trúc lớn.

1. Dữ liệu có cấu trúc cho sự thật (Fact data)

model KBFact {
  ...
  data Json?
}

Cần thiết cho:

  • các giá trị số;
  • đơn vị đo lường;
  • các khoảng giá trị (ranges);
  • tham số liên kết;
  • các đặc trưng thuật toán.

Ví dụ:

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

2. Dữ liệu và thứ tự tham gia (Participation data and order)

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

Cần thiết cho:

  • hành trình (routes);
  • các cụm (clusters);
  • trình tự;
  • các tham số khác nhau của những người tham gia trong cùng một sự thật.

3. Chỉ mục (Index) theo trạng thái và thời gian

Cho các truy vấn hiện trạng thường xuyên:

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

Hiện tại trong schema được dẫn chứng, KBFact chỉ đánh chỉ mục trên type. Đối với nút "Cần biết gì lúc này", các truy vấn theo tính hiệu lực và trạng thái sẽ xuất hiện rất thường xuyên.

4. Chỉ mục không gian (Spatial index)

Hiện tại lat/lng đang được đánh chỉ mục bằng B-tree kết hợp thông thường. Đối với tính năng "có gì ở gần" thực tế, PostGIS sẽ tốt hơn:

geography(Point, 4326)
GIST index

Bạn có thể giữ lat/lng để tiện cho Prisma, đồng thời lưu trữ thêm điểm địa lý (geographic point) hoặc chuyển sang extension raw SQL.

Với kinh nghiệm của bạn, đây có lẽ là bước hiển nhiên: đối với các truy vấn bán kính (radius) và hành trình, chỉ mục tọa độ thông thường sẽ nhanh chóng trở thành điểm nghẽn.

5. Từ vựng loại ổn định (Stable type registry)

Các trường KBConcept.type, KBFact.type, KBFactParticipation.role là dạng chuỗi. Điều này mang lại sự linh hoạt, nhưng rất dễ dẫn đến tình trạng:

located_in
located-at
location
belongs_to_city

Do đó cần có một registry trong mã nguồn:

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

Tương tự cho các loại fact types và roles.


Những gì không nên đặt vào KBConcept.data

Về mặt kỹ thuật, bạn có thể nhét mọi thứ vào đó:

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

Nhưng khi đó đồ thị sẽ ngừng việc giải thích nguồn gốc dữ liệu một cách chính xác.

Tôi chỉ sử dụng data của concept cho:

  • metadata kỹ thuật;
  • tham số hiển thị;
  • các cấu trúc ít khi dùng đến;
  • bộ nhớ đệm (cache);
  • các kết quả đã khử chuẩn hóa (denormalized results).

Còn các khẳng định như:

  • phù hợp cho trẻ em;
  • có nhà hàng;
  • khuyên đi hai ngày;
  • đóng cửa đến tháng Chín;

nên được giữ dưới dạng các sự thật (facts).

Bởi vì chỉ có sự thật mới có:

  • nguồn gốc;
  • độ tin cậy;
  • thời gian;
  • trạng thái kiểm tra;
  • các xung đột;
  • provenance (truy nguyên nguồn gốc).

Cách agent sẽ tổng hợp trang thông tin địa điểm

Đối với một thẻ (card) duy nhất, truy vấn có thể thực hiện theo từng lớp.

Lớp cơ sở (Base layer)

Khái niệm:

  • tên;
  • mô tả;
  • hình ảnh;
  • tọa độ;
  • URL.

Phân loại (Taxonomy)

Các sự thật:

  • loại địa điểm;
  • vùng/tỉnh;
  • thành phố;
  • khu phức hợp;
  • hoạt động.

Tính hữu ích cho người dùng

  • các nhu cầu được đáp ứng;
  • đối tượng phù hợp;
  • các ràng buộc;
  • thời lượng khuyên dùng;
  • thời điểm đẹp nhất.

Các đối tượng liên quan

  • các bộ phận của khu phức hợp;
  • địa điểm ở gần;
  • địa điểm tương thích;
  • các lựa chọn thay thế;
  • hành trình;
  • các cụm (clusters).

Tính cập nhật (Relevance)

  • các sự thật tạm thời đang hiệu lực;
  • tin tức mới nhất;
  • thời tiết;
  • sự kiện;
  • các xung đột dữ liệu.

Cá nhân hóa

Thông qua không gian chuyến đi hiện tại:

  • cái gì phù hợp;
  • cái gì cần loại trừ;
  • đưa vào vị trí nào trong hành trình;
  • nên dành bao nhiêu thời gian;
  • nên thêm những địa điểm nào cùng lúc.

Và tất cả những điều này có thể hiển thị trên một trang duy nhất mà không bắt người dùng phải click vào danh mục để tìm kiếm.


Điểm mạnh nhất trong schema của bạn

Một cơ sở dữ liệu du lịch thông thường nói:

Đây là một hang động ở tỉnh nọ tỉnh kia.

Mô hình của bạn có tiềm năng nói rằng:

Đây là một quần thể thiên nhiên gần Đà Nẵng, bao gồm hang động, suối, nhà hàng và khu cắm trại. Đối với một cặp đôi năng động, nó phù hợp trong một rưỡi ngày, nhưng không khuyến khích cho người có nỗi sợ không gian kín. Vào tháng Tám, việc ghé thăm con suối phụ thuộc vào lượng mưa. Quần thể này hợp lý khi kết hợp với núi X, nhưng không nên kết hợp với đối tượng Y trong cùng một ngày.

Hơn nữa, mỗi khẳng định sẽ có:

  • nguồn gốc;
  • thời gian có hiệu lực;
  • độ tin cậy;
  • trạng thái;
  • các bên tham gia;
  • ngữ cảnh.

Đây không còn là CMS hay một danh mục thông thường nữa. Đây thực sự là một cơ sở dữ liệu suy luận (reasoning database).

Tổng kết của tôi

Bạn không cần phải thiết kế một cơ sở dữ liệu du lịch mới đè lên cái cũ.

Chỉ cần:

  1. Cố định hệ thống ngữ nghĩa du lịch cho KBConcept.type.
  2. Tạo từ vựng các loại sự thật và vai trò.
  3. Thêm data vào KBFact.
  4. Thêm positiondata vào KBFactParticipation.
  5. Đưa PostGIS vào để xử lý quan hệ không gian.
  6. Tách biệt rõ ràng:
    • loại đối tượng;
    • thuộc tính trải nghiệm;
    • nhu cầu;
    • ràng buộc;
    • khu phức hợp;
    • cụm (clusters);
    • hành trình (routes).
  7. Sử dụng các sự thật dạng derived cho các chỉ số tính toán như tính tự cung tự cấp, sức chứa hành trình và mật độ hữu ích.

Điều quan trọng nhất: đừng cố gắng ghi cứng trước tất cả các liên kết cặp giữa mọi địa điểm. Những thứ nên lưu trữ là các đặc trưng nền tảng và các liên kết biên tập có ý nghĩa, còn phần lớn độ tương thích nên được tính toán từ đồ thị theo từng người dùng cụ thể và từng hành trình cụ thể.

Nhân tiện, ở đây chúng ta cần phải suy nghĩ về một hệ thống chỉ số và phân loại đối tượng nào đó, không chỉ theo chất lượng mà còn theo định hướng. Ví dụ, nếu một người không thích hang động, họ chắc chắn không cần hang ngầm (grotto), v.v. Nếu họ đang chọn một chiếc váy dạ hội, họ khó lòng cần vịnh Hạ Long hay lặn biển. Nếu ai đó đang tìm nhà hàng hoặc sòng bạc trong thành phố, họ không cần thác nước. Ngược lại, nếu họ đang đi đến một ngọn núi, tại sao không ghé qua một khu nghỉ dưỡng có cả suối, hang động, nhà hàng và thứ gì đó khác nữa. Nhân tiện, những sự kết hợp địa điểm này thực sự rất cần thiết. Có những khu nghỉ dưỡng vừa có cắm trại, vừa có đồ ăn, vừa có hang động, vừa có chỗ bơi lội. Bạn có thể la cà ở đó vài ngày. Và bạn có thể không rời khỏi Hòn Ngọc ở Nha Trang trong 3 ngày.

Đây là suy nghĩ của ChatGPT:

Đúng vậy — ở đây chúng ta không chỉ cần phân loại địa điểm (taxonomy) đơn thuần, mà cần một hệ thống về khả năng tương thích, ý định và “sức chứa” của đối tượng.

Vấn đề là bản thân loại đối tượng hầu như không nói lên điều gì về việc tại sao con người lại đến đónó có thể giải quyết được bao nhiêu nhu cầu.

Ví dụ:

  • “hang động” là một loại;
  • “hoạt động ngoài trời/thể thao”, “thiên nhiên”, “trong nửa ngày”, “không phù hợp với người sợ không gian kín” — đây đã là định hướng;
  • “tổ hợp du lịch có hang động, nhà hàng, chỗ tắm và nơi ngủ qua đêm” — đây nhìn chung là một đối tượng hỗn hợp có thể thay thế cho nhiều điểm dừng chân riêng lẻ trong lộ trình.

Cần ba tầng phân loại khác nhau

1. Nó là gì

Đây là hệ thống phân loại hiện tại của bạn:

  • hang động;
  • thác nước;
  • nhà hàng;
  • bãi biển;
  • sòng bạc;
  • lặn biển;
  • chùa;
  • công viên;
  • khu cắm trại;
  • khách sạn.

Nó cần thiết, nhưng chưa đủ.

2. Họ đến đó để làm gì

Đây là ý định của người dùng:

  • thiên nhiên;
  • hoạt động ngoài trời;
  • lãng mạn;
  • đi chơi buổi tối;
  • ăn uống;
  • mua sắm;
  • giải trí;
  • đi cùng trẻ em;
  • cuộc sống về đêm;
  • nghỉ dưỡng thư thả;
  • phiêu lưu;
  • điểm chụp ảnh đẹp;
  • chăm sóc sức khỏe (wellness);
  • chương trình văn hóa.

Đây là lúc các ví dụ của bạn xuất hiện:

  • người dùng tìm váy dạ hội → họ cần mua sắm, trung tâm thành phố, nhà hàng, chương trình buổi tối;
  • tìm sòng bạc → họ cần nightlife, nhà hàng, khách sạn, phương tiện đi lại buổi tối;
  • đi đến núi → có thể gợi ý hang động, suối, điểm ngắm cảnh, quán cà phê, khu cắm trại;
  • không thích hang động → loại bỏ hang động, hang ngầm (grotto), đền thờ ngầm và các đối tượng tương tự.

3. Đối tượng hoạt động thế nào trong lịch trình

Đây đã là phân loại theo vận hành:

  • trong 30 phút;
  • trong 2 tiếng;
  • trong nửa ngày;
  • trong một ngày;
  • trong 2–3 ngày;
  • có thể kết hợp;
  • tốt hơn là đi riêng;
  • điểm trung chuyển;
  • điểm xuất phát/cơ sở;
  • điểm đến cuối cùng;
  • tổ hợp tự cung tự cấp;
  • cần ngủ qua đêm;
  • phụ thuộc vào thời tiết;
  • cần đặt chỗ trước;
  • chỉ phù hợp vào một thời điểm nhất định trong ngày.

Điều này cực kỳ quan trọng đối với lộ trình.


Cần có thực thể «tổ hợp» hoặc «cụm» (complex / cluster)

Những gì bạn mô tả về các khu nghỉ dưỡng và Hòn Ngọc không thể được mô hình hóa chỉ đơn giản như một địa điểm thông thường.

Cần phải đưa ra khái niệm:

Đối tượng hỗn hợp / Tổ hợp du lịch

Nó có thể bao gồm:

  • nhà hàng;
  • khách sạn;
  • bãi biển;
  • khu trò chơi/điểm thu hút;
  • hang động;
  • khu cắm trại;
  • hồ bơi;
  • spa;
  • các tuyến đường/đường mòn;
  • chương trình biểu diễn;
  • khu mua sắm;
  • phương tiện di chuyển nội khu.

Ví dụ:

  • khu nghỉ dưỡng (resort);
  • tổ hợp đảo;
  • vườn quốc gia;
  • khu du lịch sinh thái/nghỉ dưỡng;
  • công viên giải trí (theme park);
  • quần thể đền chùa lớn;
  • cụm núi;
  • cụm bãi biển;
  • công viên sinh thái (ecopark).

Nó phải có các thuộc tính riêng của mình:

  • thời gian tối thiểu khuyến nghị;
  • thời gian hợp lý tối đa;
  • có thể ở lại qua đêm không;
  • có thể ăn uống tại chỗ không;
  • có phương tiện nội khu không;
  • có bao nhiêu hoạt động bên trong;
  • có phù hợp để làm một phần độc lập của chuyến đi không.

Ví dụ:

Hòn Ngọc Loại: tổ hợp du lịch Thời lượng khuyến nghị: 1–3 ngày Đáp ứng nhu cầu: bãi biển, giải trí, ăn uống, trẻ em, biểu diễn, lưu trú

Khi đó hệ thống hiểu rằng: đây không chỉ là một điểm đến đơn lẻ trong hai giờ, mà là một container chứa lộ trình.


Mô hình đối tượng hữu ích

Đối với mỗi đối tượng, tôi sẽ bổ sung một tập hợp các đặc điểm.

Đặc điểm chủ đề

  • thiên nhiên;
  • đồ ăn;
  • mua sắm;
  • lịch sử;
  • tôn giáo;
  • phiêu lưu;
  • nước;
  • núi non;
  • đời sống về đêm;
  • giải trí;
  • wellness;
  • trẻ em;
  • lãng mạn;
  • nhiếp ảnh.

Đặc điểm không mong muốn

Điều này quan trọng không kém.

  • không gian chật hẹp/kín;
  • độ cao;
  • gắng sức thể chất;
  • đông đúc;
  • tiếng ồn;
  • nóng bức;
  • nước;
  • động vật;
  • hạn chế tôn giáo;
  • di chuyển xa/lâu;
  • hoạt động mạo hiểm.

Nếu một người nói "tôi không thích hang động", hệ thống phải loại bỏ không chỉ loại hang động mà còn cả các đặc điểm:

  • đối tượng dưới lòng đất;
  • hang ngầm (grotto);
  • đường hầm;
  • không gian chật chội;
  • bộ môn thám hiểm hang động.

Nghĩa là, bạn không chỉ cần các sở thích tích cực mà còn cần cả phản-sở thích (anti-preferences).


Cần các «nhu cầu» mà đối tượng đáp ứng

Đây là một ý tưởng mạnh mẽ riêng biệt.

Mỗi địa điểm có thể đáp ứng một hoặc nhiều nhu cầu:

  • ăn uống;
  • ngủ qua đêm;
  • bơi lội;
  • giải trí cho trẻ em;
  • nghỉ ngơi;
  • đi dạo;
  • ngắm thiên nhiên;
  • mua sắm đồ đạc;
  • trải nghiệm cảm xúc;
  • dành buổi tối;
  • trú mưa;
  • dành cả ngày không cần di chuyển nhiều nơi.

Ví dụ:

Thác nước

Đáp ứng:

  • thiên nhiên;
  • chụp ảnh;
  • đi dạo;
  • bơi lội — đôi khi;
  • hoạt động thể chất.

Sòng bạc

Đáp ứng:

  • buổi tối;
  • giải trí;
  • đời sống về đêm;
  • nhà hàng — đôi khi.

Khu du lịch sinh thái

Đáp ứng:

  • đồ ăn;
  • thiên nhiên;
  • bơi lội;
  • ngủ qua đêm;
  • hoạt động;
  • nghỉ dưỡng 1–2 ngày.

Khi đó, lộ trình có thể được xây dựng không phải xoay quanh số lượng điểm đến, mà xoay quanh sự cân bằng các nhu cầu được đáp ứng.


Cần các chỉ số (metrics) của đối tượng

Tôi sẽ chia chúng thành nhiều nhóm.

Độ hấp dẫn

  • tính độc đáo;
  • giá trị trực quan;
  • mức độ phổ biến;
  • chất lượng trải nghiệm;
  • uy tín;
  • tính thời sự/phù hợp.

Hậu cần (Logistics)

  • khoảng cách;
  • khả năng tiếp cận;
  • thời gian di chuyển;
  • độ khó khi tiếp cận;
  • có bãi đậu xe không;
  • sự phụ thuộc vào phương tiện giao thông.

Sức chứa thời gian

  • thời gian tối thiểu;
  • thời gian khuyến nghị;
  • thời gian hợp lý tối đa;
  • có thể chiếm nửa ngày không;
  • có thể chiếm cả ngày không;
  • có thể ở lại qua đêm không.

Tính cấu thành

  • số lượng hoạt động;
  • số lượng dịch vụ;
  • có đồ ăn không;
  • có chỗ ở không;
  • có hoạt động giải trí không;
  • có cảnh quan thiên nhiên không;
  • tính tự túc/độc lập.

Ngữ cảnh

  • phù hợp vào buổi sáng;
  • phù hợp vào buổi tối;
  • phù hợp khi trời mưa;
  • phù hợp khi trời nóng;
  • phù hợp đi cùng trẻ em;
  • phù hợp khi không có ô tô;
  • phù hợp cho các cặp đôi;
  • phù hợp đi một mình (solo).

«Mật độ lợi ích» rất quan trọng

Có thể đưa ra một chỉ số nội bộ:

Có bao nhiêu nhu cầu khác nhau được đáp ứng tại một điểm

Ví dụ:

Thác nước thông thường

  • thiên nhiên;
  • chụp ảnh;
  • đi dạo.

Mật độ lợi ích: trung bình.

Khu nghỉ dưỡng

  • thiên nhiên;
  • bơi lội;
  • đồ ăn;
  • ngủ qua đêm;
  • hang động;
  • cắm trại;
  • hoạt động.

Mật độ lợi ích: cao.

Tổ hợp đảo lớn

  • bãi biển;
  • trò chơi giải trí;
  • nhà hàng;
  • chương trình biểu diễn;
  • khách sạn;
  • trẻ em;
  • chương trình buổi tối.

Mật độ lợi ích: rất cao.

Những đối tượng như vậy cần được hiển thị khác đi trong lộ trình:

«Điểm này có thể chiếm 2–3 ngày và thay thế cho vài chuyến đi riêng lẻ khác».


Cần các mối liên kết không chỉ giữa các địa điểm mà còn bên trong các tổ hợp

Các mối liên kết có thể là:

  • nằm trong tổ hợp;
  • nằm trên lãnh thổ;
  • chỉ có thể truy cập thông qua tổ hợp;
  • có thể tham quan riêng;
  • bao gồm trong vé chung;
  • cần mua vé riêng;
  • hoạt động vào khung giờ khác;
  • tốt nhất nên tham quan theo một thứ tự nhất định.

Ví dụ:

Hòn Ngọc Bao gồm:

  • công viên nước;
  • bãi biển;
  • nhà hàng;
  • chương trình biểu diễn;
  • cáp treo;
  • khách sạn.

Người dùng có thể:

  • thêm toàn bộ tổ hợp;
  • chỉ thêm một phần;
  • thêm tổ hợp trong 1 ngày;
  • thêm tổ hợp trong 3 ngày.

Cách áp dụng điều này vào các đề xuất

Trước tiên, hệ thống phải xác định ý định của người dùng, sau đó mới chọn các loại đối tượng.

Ví dụ:

Người dùng đang tìm váy dạ hội.

Ý định:

  • mua sắm;
  • thành phố;
  • buổi tối;
  • có thể là nhà hàng;
  • có thể là điểm chụp ảnh đẹp;
  • di chuyển tối thiểu.

Các danh mục không phù hợp:

  • lặn biển;
  • thác nước;
  • hang động;
  • vịnh ở xa.

Sự kết hợp phù hợp:

trung tâm thương mại → salon/boutique → nhà hàng → bar/show

Ví dụ khác:

Người dùng đang đi đến núi.

Ý định:

  • thiên nhiên;
  • hoạt động;
  • ngắm cảnh/panorama;
  • có thể là cả ngày.

Các liên kết bổ sung:

  • suối;
  • hang động;
  • quán cà phê;
  • cắm trại;
  • điểm ngắm cảnh;
  • khu nghỉ dưỡng;
  • ngủ qua đêm.

Và khi đó hệ thống không đề xuất "thêm một quả núi nữa", mà là một cụm trải nghiệm trọn gói.


Phân loại lộ trình

Lộ trình cũng phải có định hướng riêng:

  • đô thị;
  • thiên nhiên;
  • ẩm thực;
  • buổi tối;
  • gia đình;
  • phiêu lưu;
  • lãng mạn;
  • bãi biển;
  • hỗn hợp;
  • không di chuyển nhiều;
  • mật độ hoạt động cao;
  • thư thả.

Và sự cân bằng:

  • 70% thiên nhiên / 30% thành phố;
  • 50% nghỉ ngơi / 50% hoạt động;
  • 20% mua sắm / 30% ăn uống / 50% giải trí.

Điều này sẽ cho phép kiểm tra xem lộ trình có bị "hỏng" hay không.

Ví dụ:

Người dùng muốn nghỉ dưỡng bãi biển yên tĩnh, nhưng lộ trình lại có tới 60% thời gian là di chuyển và hoạt động.

Agent nên nói rằng:

Lộ trình không phù hợp với sở thích của bạn.


Thực thể mới hữu ích nhất

Tôi sẽ bổ sung không chỉ tổ hợp (complex) mà còn cả kịch bản (scenario).

Ví dụ về các kịch bản:

  • ngày trời mưa;
  • buổi tối trong thành phố;
  • hai ngày không rời khỏi tổ hợp;
  • chuyến đi cùng trẻ nhỏ;
  • buổi tối lãng mạn;
  • một ngày hoạt động trên núi;
  • ngày không dùng ô tô;
  • điểm dừng chân trên đường giữa các thành phố.

Kịch bản liên kết các yếu tố sau:

  • ý định;
  • hạn chế;
  • loại địa điểm;
  • tổ hợp;
  • thời gian;
  • trình tự hành động.

Mô hình tối giản

Đối với các thực thể của bạn:

  • tỉnh thành;
  • thành phố;
  • loại địa điểm;
  • địa điểm;

tôi sẽ bổ sung thêm:

  • tổ hợp (complex);
  • kịch bản (scenario);
  • nhu cầu (need);
  • sở thích (interest);
  • phản-sở thích (anti-preference);
  • hoạt động (activity);
  • dịch vụ (service);
  • hồ sơ lộ trình (route profile).

Và các mối liên kết:

  • địa điểm nằm trong tổ hợp;
  • địa điểm đáp ứng nhu cầu;
  • địa điểm phù hợp với kịch bản;
  • địa điểm xung đột với sở thích;
  • địa điểm bổ sung cho địa điểm khác;
  • địa điểm thay thế địa điểm khác;
  • địa điểm đòi hỏi một ngày riêng;
  • địa điểm có thể ghé thăm trên đường đi;
  • tổ hợp được thiết kế cho N ngày.

Kết luận chính

Tốt nhất là xây dựng hệ thống không xoay quanh câu hỏi:

«Đây là địa điểm gì?»

Mà xoay quanh ba câu hỏi:

Tại sao phải đến đó? Nó có thể chiếm bao nhiêu thời gian? Nó đáp ứng những nhu cầu nào?

Và tách riêng các đối tượng hỗn hợp, nơi một người thực sự có thể dành một, hai hoặc ba ngày mà không cần phải di chuyển đến hàng chục điểm rời rạc.

Tất cả những điều này đều liên quan đến worklog này.

Tôi sẽ không trích dẫn toàn bộ đoạn chat với ChatGPT, nhưng tôi đã suy nghĩ lại hoàn toàn bản chất của các khái niệm và sự thật, và tất cả quy về một điểm là không có lý do gì để biến các sự thật thành các thực thể riêng biệt trong cách triển khai của chúng ta. Bản thân các khái niệm của chúng ta cũng có thể là sự thật.

Dưới đây là tóm tắt ngắn gọn về cách tôi nhìn nhận vấn đề:


Chúng ta không phải là một hệ thống ngân hàng để mà xây dựng các mối quan hệ chi tiết. Vòng đời thông tin của chúng ta diễn ra như sau:

1. Một khái niệm chưa từng tồn tại trước đó được tạo ra. Ví dụ: Some Homestay.

2. Mọi thứ bạn muốn truyền tải sẽ được viết bên trong nó dưới dạng văn bản thuần túy. Đây là tất cả những gì người dùng sẽ đọc sau này. Trong dự án của chúng ta, về bản chất thì điều này tự nó đã là một sự thật, bởi vì toàn bộ nội dung đều do chúng ta tự tạo ra. Và chúng ta chỉ định ngay các thuộc tính chất lượng cái nào ra cái nào. Theo thời gian, khi thông tin này trở nên kém chất lượng, chúng ta chỉ cần hạ thấp thuộc tính chất lượng của nó xuống. Tương đối mà nói, hiện tại chúng ta chỉ cần đúng một chỉ số duy nhất từ 0 đến 1 dạng float. Chất lượng càng cao, khả năng hiển thị càng cao. Ví dụ, trang web của tôi đầy ắp những bài viết cũ không còn cần thiết nữa, cần phải vứt bỏ, nhưng tôi không thể vứt bỏ vì lỗi 404 thì không ngầu chút nào. Tôi chỉ cần đẩy chúng xuống sâu hơn.

3. Liên kết với các khái niệm khác. Tức là tôi chỉ cần liên kết chéo (cross-linking). Giống như các trang web cổ điển, tôi có thể đơn giản thêm các liên kết đến các khái niệm khác. Việc này để người dùng có thể đọc và chuyển đến các thẻ liên quan. Nhưng đây là liên kết ngoài. Trong khi tôi cũng cần cả liên kết ngược. Do đó, ở đây chúng ta không chỉ cần các liên kết gắn thẻ neo (anchor link) mà còn cần cả thông tin kỹ thuật đi kèm của liên kết (lý do tại sao tôi liên kết đến đây và một lần nữa, là một thuộc tính chất lượng nào đó).

4. Khi lưu một khái niệm như vậy, tất cả các liên kết của nó sẽ được tính toán lại. Những liên kết bị xóa khỏi nội dung cũng sẽ bị xóa khỏi danh sách liên kết, và các liên kết được thêm vào sẽ xuất hiện.

Đến đây chính là lúc chúng ta cần một bảng liên kết nhiều-nhiều (many-to-many) phẳng giữa các khái niệm. Lúc đó chúng ta có thể xem xét ngữ nghĩa tổng thể của toàn bộ cơ sở tri thức.

Và đây là điều tôi đang suy nghĩ, nếu phát triển ý tưởng này: Chúng ta có thể tạo ra ngữ nghĩa hoàn chỉnh ở cấp độ thẻ HTML. Chúng ta chỉ cần chèn các liên kết vào trong văn bản. Đồng thời chúng có innerText, title, rel, các thuộc tính data-, v.v. Tất cả những thứ này có thể được phản chiếu với bảng chỉ mục liên kết của chúng ta. Trong khi đó, parent và root vẫn giữ nguyên là các liên kết cứng.


Dưới đây là phần tóm tắt của ChatGPT:

Vâng, điều này trông đã rất mạch lạc rồi.

Khi đó, nội dung HTML/Markdown trở thành đồ thị ngữ nghĩa chính, còn bảng liên kết đóng vai trò là chỉ mục được hiện thực hóa (materialized index) của nó để truy vấn nhanh và tạo liên kết ngược.

Đồng thời:

  • parentId — hệ thống cấp bậc trực tiếp cứng;
  • rootId — thuộc về cùng một cây hoặc một chuyên mục;
  • Các liên kết HTML — liên kết ngữ nghĩa mềm nhiều-nhiều;
  • Bảng quan hệ — bản sao phản chiếu các liên kết từ nội dung.

Liên kết như một mối quan hệ ngữ nghĩa đầy đủ

Ví dụ, trong trình soạn thảo:

<a
  href="/places/som-homestay"
  data-concept-id="som-homestay-id"
  data-relation="overnight"
  data-context="Chỗ nghỉ qua đêm thích hợp trước khi ghé thăm hang động từ sớm"
  data-quality="0.9"
  title="Som Homestay — chỗ nghỉ qua đêm gần hang động"
>
  ngủ qua đêm tại Som Homestay
</a>

Phần này đã chứa hầu như tất cả các ngữ nghĩa cần thiết:

  • href — địa chỉ công khai;
  • data-concept-id — định danh kỹ thuật ổn định;
  • innerText — thẻ neo tự nhiên;
  • title — giải thích bổ sung cho người dùng;
  • data-relation — loại quan hệ ngữ nghĩa;
  • data-context — lý do liên kết xuất hiện;
  • data-quality — độ mạnh hoặc tính hữu ích của liên kết.

Từ đó, một bản ghi chỉ mục được tạo ra:

sourceId: cave-id
targetId: som-homestay-id
relation: overnight
anchor: ngủ qua đêm tại Som Homestay
title: Som Homestay — chỗ nghỉ qua đêm gần hang động
context: Chỗ nghỉ qua đêm thích hợp trước khi ghé thăm hang động từ sớm
quality: 0.9

Lưu gì trong rel

Tôi sẽ cẩn thận khi sử dụng thuộc tính HTML chuẩn rel.

Nó đã có sẵn ngữ nghĩa về trình duyệt và SEO:

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

Tốt hơn hết là nên lưu trữ ngữ nghĩa miền (domain semantics) riêng của chúng ta tách biệt:

data-relation="overnight-before"

Và sử dụng rel chuẩn đúng với mục đích của nó:

rel="ugc nofollow"

Nếu không, sau này bạn có thể gặp xung đột giữa mô hình riêng của bạn và chuẩn HTML.

Định dạng liên kết tối thiểu

Đối với công việc thủ công trong trình soạn thảo, quá nhiều thuộc tính sẽ gây vướng víu. Do đó, tôi chỉ coi các mục sau là bắt buộc:

<a
  data-concept-id="..."
  data-relation="..."
>
  văn bản liên kết
</a>

Phần còn lại có thể được xác định tự động:

  • href — lấy thông qua SiteRoute;
  • anchor — lấy từ innerText;
  • title — tạo từ khái niệm được liên kết;
  • quality — sử dụng giá trị mặc định;
  • context — trích xuất từ câu xung quanh;
  • position — tính toán bằng trình phân tích cú pháp (parser).

Chế độ nâng cao chỉ cần thiết khi tác giả muốn ghi đè ngữ nghĩa một cách rõ ràng:

<a
  data-concept-id="..."
  data-relation="overnight"
  data-quality="0.95"
  data-context="Chỗ nghỉ tốt nhất trước khi xuất phát sớm"
>
  Som Homestay
</a>

Văn bản xung quanh cũng mang ngữ nghĩa

Giả sử viết rằng:

Trước khi ghé thăm hang động từ sớm, tốt hơn là nên
<a data-concept-id="som-id">ngủ qua đêm tại Som Homestay</a>,
vì đường đi vào buổi sáng chỉ mất khoảng 30 phút.

Ngay cả khi không có data-relation rõ ràng, một agent hoặc parser vẫn có thể suy ra:

relation: overnight_before
context: chỗ nghỉ qua đêm trước khi ghé thăm hang động từ sớm

Nhưng tôi sẽ phân biệt giữa:

  • ngữ nghĩa rõ ràng của tác giả;
  • ngữ nghĩa được xác định tự động.

Ví dụ:

origin: explicit

hoặc:

origin: inferred
confidence: 0.72

Không nhất thiết phải hiển thị điều này cho biên tập viên. Đây là thông tin kỹ thuật của bảng chỉ mục.

Sơ đồ bảng có thể có

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)

  // Định danh ổn định của một liên kết cụ thể trong trình soạn thảo
  nodeId String? @db.VarChar(100)

  // Vị trí trong nội dung
  position Int?

  // Các thuộc tính HTML gốc hoặc bổ sung
  data Json?

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

qualityconfidence ở đây mang ý nghĩa khác nhau:

  • quality — liên kết hữu ích cho người dùng đến mức nào;
  • confidence — ngữ nghĩa của nó được xác định chắc chắn đến mức nào.

Đối với một liên kết được chỉ định rõ ràng:

origin = explicit
confidence = 1

Đối với liên kết được nhận diện tự động:

origin = inferred
confidence = 0.74

Ở giai đoạn đầu, có thể hoàn toàn chưa cần thêm confidence.

Cần có định danh ổn định cho nút HTML

Nếu chỉ dùng sourceId + targetId, vấn đề sẽ phát sinh: một văn bản có thể liên kết đến một khái niệm nhiều lần vì những lý do khác nhau.

Ví dụ:

<a
  data-node-id="link-a17"
  data-concept-id="som-id"
  data-relation="overnight-before"
>
  ngủ qua đêm trước khi tham quan
</a>

<a
  data-node-id="link-b24"
  data-concept-id="som-id"
  data-relation="cold-weather-warning"
>
  mùa đông ở đây có thể rất lạnh
</a>

Đây là hai mối quan hệ độc lập trỏ đến cùng một đích.

data-node-id phải do trình soạn thảo tự động tạo ra và giữ nguyên khi liên kết được di chuyển bên trong tài liệu.

Khi đó việc đồng bộ hóa khi lưu sẽ rất đơn giản:

  • liên kết có cùng nodeId — được cập nhật;
  • nodeId mới — được thêm vào;
  • thiếu nodeId — bị xóa.

HTML và chỉ mục phải có một nguồn chân lý duy nhất (Single Source of Truth)

Tốt nhất là nên tránh việc chỉnh sửa hai chiều ở đây.

Tôi sẽ chốt lại rằng:

Nội dung là nguồn chân lý cho các liên kết biên tập đi ra (outgoing).

Bảng chỉ là chỉ mục.

Điều đó có nghĩa là:

  • người dùng chỉnh sửa liên kết trong văn bản;
  • sau khi lưu, chỉ mục được tính toán lại;
  • việc chỉnh sửa trực tiếp các dòng trong bảng chỉ mục bị cấm hoặc bị hạn chế;
  • các khối liên kết ngược (backlinks) được hình thành từ bảng chỉ mục;
  • việc thay đổi mối quan hệ đi vào được thực hiện thông qua khái niệm nguồn.

Nếu không sẽ xảy ra tình trạng mất đồng bộ: bảng nói thế này, HTML nói thế khác.

Các liên kết tự động có thể được lưu trữ tách biệt với liên kết nội dung và không phản chiếu vào HTML cho đến khi được chấp nhận rõ ràng.

Liên kết ngược (Backlink)

Nếu trong thẻ hang động có:

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

Thì trên trang của Som Homestay, bạn có thể tự động hiển thị:

Thích hợp để nghỉ qua đêm sau khi tham quan

Hang động X — tác giả đề xuất homestay này làm nơi nghỉ qua đêm sau chuyến tham quan.

Hoặc nhúng một khối động vào văn bản:

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

Hơn nữa, phần hiển thị ngược không bắt buộc phải lặp lại thẻ neo ban đầu. Nó được xây dựng từ:

  • loại quan hệ;
  • ngữ cảnh;
  • khái niệm nguồn;
  • chất lượng liên kết.

parentroot thực sự rất đáng được giữ lại

Chúng giải quyết một bài toán khác.

parentId

Sự trực thuộc cứng hoặc vùng chứa chính:

Thái Nguyên
└── Som Homestay

hoặc cấu trúc nội dung:

Chỗ ở
└── Homestay
    └── Som Homestay

Chỉ cần chọn một ý nghĩa duy nhất cho parentId và không trộn lẫn nhiều hệ thống phân cấp khác nhau.

Ví dụ:

parentId luôn có nghĩa là sự trực thuộc mang tính biên tập vào chuyên mục chính.

rootId

Giúp tăng tốc:

  • lấy toàn bộ cây;
  • tìm kiếm trong chuyên mục;
  • tính duy nhất của mã;
  • quản lý một nhánh lớn;
  • xóa hoặc di chuyển chuyên mục.

Liên kết mềm

Mọi thứ còn lại:

  • chỗ ở được đề xuất;
  • một phần của khu phức hợp;
  • phương án thay thế;
  • điểm dừng tiếp theo trong lộ trình;
  • liên quan đến rủi ro thời tiết;
  • được nhắc đến trong hành trình;

đều đi qua chỉ mục liên kết.

Ngữ nghĩa đầy đủ không nhất thiết đòi hỏi các thẻ phức tạp

Bạn có thể bắt đầu với một liên kết thông thường:

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

Và loại quan hệ được xác định qua ba cấp độ:

  1. data-relation rõ ràng.
  2. Ngữ cảnh của khối cha.
  3. Phân tích văn bản xung quanh bởi agent.

Ví dụ:

<section data-semantic-context="recommended-overnight">
  <p>
    Trước chuyến đi, tốt nhất nên dừng chân tại
    <a data-concept-id="som-id">Som Homestay</a>.
  </p>
</section>

Ở đây, mỗi liên kết bên trong section sẽ kế thừa:

relation = recommended_overnight

Điều này có thể đơn giản hóa rất nhiều việc gắn thẻ thủ công.

Tương tự trong các thành phần Markdown:

:::semantic{relation="overnight-before"}
Trước khi ghé thăm sớm, tốt nhất nên dừng lại ở
[Som Homestay](concept:som-id).
:::

Ngữ nghĩa có thể tồn tại ở các cấp độ DOM khác nhau

Trên liên kết

Dành cho mối quan hệ cá thể chính xác:

<a data-relation="alternative">

Trên đoạn văn

Dành cho ngữ cảnh chung:

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

Trên section

Dành cho một nhóm liên kết:

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

Trên custom component

Dành cho việc truy vấn động:

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

Parser có thể kế thừa ngữ nghĩa từ trên xuống dưới:

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

Kết quả thu được cuối cùng

Bạn sẽ có bốn tầng đồng bộ với nhau:

Cây cứng:
parentId / rootId

Ý nghĩa của tác giả:
Nội dung HTML và các liên kết

Đồ thị nhanh:
KBConceptRelation

Biểu diễn động:
liên kết ngược, danh sách và các khối agent

Đây là một kiến trúc rất thực tế. Nó không đòi hỏi phải mô tả lại toàn bộ thế giới trong các bảng cơ sở dữ liệu một cách rời rạc: ngữ nghĩa xuất hiện ngay trong quá trình viết văn bản bình thường của con người, và cơ sở dữ liệu chỉ đơn thuần hiện thực hóa nó để phục vụ tìm kiếm, phân tích và tái sử dụng.

Đã thêm một tính năng AI rất hay :-) - chỉnh sửa nội dung kèm theo liên kết chéo nội bộ. Điểm chính là truyền toàn bộ tập hợp các liên kết nội bộ cho tác nhân (agent) và nó sẽ tự động thay thế văn bản thuần túy thành các liên kết trong nội dung. Nhưng như thường lệ, AI đôi khi hoạt động chưa hoàn toàn chính xác và tự bịa ra các liên kết không tồn tại. Tuy nhiên, điều này có thể khắc phục được - tôi chỉ cần thêm bước xử lý hậu kỳ (post-processing) cho nội dung và nó sẽ làm sạch nội dung khỏi các liên kết nội bộ bị hỏng (may mắn là toàn bộ nội dung ở định dạng Markdown nên việc này dễ hơn rất nhiều so với HTML thuần).

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
}

Dưới đây là một ví dụ về bài viết đã được xử lý: https://vietnamguru.ru/content/reference/aktualniy-spisok-provintsiy-vetnama

Ban đầu bài viết được viết mà không có lấy một liên kết nội bộ nào. Trong khi lẽ ra phải có rất nhiều liên kết ở đó, vì đây là danh sách các tỉnh (và đôi khi là cả các thành phố). Tự tay chỉnh sửa nó khá là phiền phức (bạn không chỉ phải tìm các liên kết trên trang web của mình mà còn phải kiểm tra xem tỉnh đó có thực sự tồn tại trong danh mục của chúng tôi hay không). Nhân tiện, đây cũng là một điểm cộng phụ - chúng ta có ngay danh sách các tỉnh còn thiếu, nhờ đó biết rõ những gì cần bổ sung vào danh mục. Và kết quả là chúng ta thu được nội dung như thế này: