# Semantic layer: vì sao text-to-SQL trả sai “doanh thu” và cách định nghĩa chỉ số một lần

> Câu SQL chạy không lỗi, con số trông hợp lý, nhưng vẫn sai, vì chưa ai cho model biết khách hàng của bạn tính MRR theo cách nào.

Bản gốc: https://fdetimes.net/vi/bach-khoa/semantic-layer-dinh-nghia-doanh-thu-cho-llm-cua-khach/

Thử hình dung thế này. Agent bạn vừa deploy cho một công ty SaaS nhận câu hỏi "MRR tháng 1 là bao nhiêu?" từ đội tài chính. Nó trả về một con số kèm câu SQL gọn gàng, chạy không lỗi. Đến chiều, con số đó lệch hẳn so với báo cáo của finance, và người bị gọi vào phòng họp là bạn.

Gần như FDE nào làm dự án "hỏi dữ liệu bằng ngôn ngữ tự nhiên" cũng sẽ gặp cảnh này. Gốc của nó không nằm ở model, cũng không nằm ở prompt. Gốc của nó là định nghĩa "doanh thu" chưa từng được viết ra ở chỗ nào máy đọc được.

Bài này hướng dẫn cách sửa từ gốc: viết định nghĩa chỉ số một lần trong semantic layer, rồi cho cả LLM lẫn báo cáo của khách hàng dùng chung định nghĩa đó.

## SQL đúng cú pháp vẫn có thể trả lời sai câu hỏi

Đội Omni mô tả vấn đề rất trúng. Theo họ, LLM không hẳn viết SQL tồi. Cái khó là chúng viết ra những câu SQL trông hợp lý nhưng mang nghĩa sai. Ví dụ Omni đưa ra là câu hỏi về MRR: model join thẳng bảng orders với products, bỏ qua bảng subscription, rồi trả về tổng giá trị đơn hàng. Không có thông báo lỗi nào.

Cube chỉ ra lý do model mắc lỗi này: schema của warehouse không cho biết công ty bạn định nghĩa doanh thu ra sao. Tên cột `amount` không nói được đó là tiền đã thu, tiền đã ghi nhận hay tiền sẽ thu hằng tháng. Mấy quy tắc đó nằm trong đầu đội finance, trong một file Excel, hoặc trong một view mà chỉ một người còn nhớ.

Ở site khách hàng, đây chính là phần việc của FDE: chuyển định nghĩa từ lời của người làm nghiệp vụ thành thứ máy chạy được. Model không làm thay bạn phần này.

## Cùng một khách hàng, hai con số

Lấy một ví dụ cụ thể (số liệu minh họa). Khách hàng A mua gói năm 12.000 USD, trả hết trong tháng 1. Nếu cộng giá trị đơn hàng theo kiểu đi tắt orders → products, tháng 1 có 12.000 USD và mười một tháng sau bằng 0. Nếu đi qua bảng subscription, MRR là 12.000 / 12 = 1.000 USD cho mỗi tháng trong năm.

Hai câu SQL đều chạy được, nhưng chỉ câu thứ hai trả lời đúng câu hỏi mà finance đặt ra. Một gói năm đã làm MRR tháng 1 bị phóng lên 12 lần. Nếu khách hàng có vài trăm hợp đồng như vậy, con số sai sẽ rất khó phát hiện bằng mắt.

Lỗi thứ hai hay gặp là fan-out. Một đơn 100 USD có 3 dòng sản phẩm. Join orders với order_items rồi `SUM(order_total)` sẽ ra 300 USD, vì tổng tiền của đơn bị lặp ở mỗi dòng. Tài liệu dbt gọi nhóm lỗi này là fan-out join và chasm join, và đây là thứ MetricFlow được thiết kế để tránh khi chọn đường join.

## Viết định nghĩa một lần, gọi theo tên ở mọi nơi

Semantic layer xử lý hai lỗi trên bằng cách khóa đường join ngay trong model. Omni cho biết khi hỏi lại câu MRR trên một semantic layer, đường join đã được định nghĩa sẵn, nên chỉ số được tính giống nhau mỗi lần. Cách của Cube cũng vậy: agent truy vấn measure và dimension có tên, thay vì tự dựng lại logic nghiệp vụ từ tên bảng và tên cột.

Trong hệ dbt, MetricFlow là engine làm việc này. Nó vừa đặt ra quy cách định nghĩa semantic model và metric, vừa tự sinh SQL từ định nghĩa đó. Mỗi semantic model có ba thành phần. Entity là khóa join, tức các cạnh nối giữa các model. Dimension là các trục để cắt lát dữ liệu. Metric được dựng bên trên.

Dưới đây là bản minh họa cho MRR. Cú pháp cụ thể còn tùy phiên bản dbt bạn dùng:

```yaml
semantic_models:
  - name: subscriptions
    model: ref('fct_subscriptions')
    defaults:
      agg_time_dimension: billing_month
    entities:
      - name: subscription
        type: primary
        expr: subscription_id
      - name: customer
        type: foreign
        expr: customer_id
    dimensions:
      - name: billing_month
        type: time
        type_params:
          time_granularity: month
      - name: plan_tier
        type: categorical
    measures:
      - name: mrr_amount
        agg: sum
        expr: monthly_amount

metrics:
  - name: mrr
    label: Monthly Recurring Revenue
    type: simple
    type_params:
      measure: mrr_amount
```

Chỗ cần để ý là `monthly_amount`. Việc chia gói năm cho 12 đã nằm trong bảng `fct_subscriptions`, đã được review và có người chịu trách nhiệm. Từ đây LLM chỉ cần gọi `mrr` theo `billing_month` và `plan_tier`. Dashboard của finance cũng gọi đúng metric đó, nên agent và finance không còn mỗi bên một con số.

## Sai mà báo lỗi vẫn hơn sai trong im lặng

Năm 2026, dbt Labs chạy benchmark gồm 11 câu hỏi trên bộ dữ liệu ACME Insurance, mỗi câu 20 lần, trên nhiều LLM khác nhau. Phát hiện đáng nhớ nhất không phải tỷ lệ đúng mà là kiểu thất bại. Text-to-SQL thất bại bằng một câu trả lời nghe hợp lý nhưng sai. Semantic layer thất bại bằng một thông báo lỗi.

Với FDE, khác biệt này rất quan trọng. Một thông báo lỗi khiến người dùng hỏi lại hoặc tìm bạn. Một con số sai mà trông hợp lý thì có thể nằm im trong slide gửi hội đồng quản trị cả quý.

Benchmark này cũng có giới hạn cần biết. Để text-to-SQL chạy được, nhóm tác giả nạp toàn bộ schema vào context, và chính họ thừa nhận cách đó không khả thi với dataset lớn hơn.

Atlan, khi tóm tắt benchmark này, cũng lưu ý đây là một vendor tự kiểm tra sản phẩm của mình. Vì thế, hãy coi kết quả này là gợi ý, còn eval thì tự chạy trên dữ liệu của khách hàng.

Lời khuyên của dbt cũng không bắt chọn một trong hai. Con số nào đi tới hội đồng quản trị, kiểm toán, OKR, KPI hay báo cáo tuần thì nối LLM vào semantic layer. Text-to-SQL để dành cho các câu hỏi khám phá ad hoc.

**Điểm mấu chốt:** Một con số sai mà trông hợp lý nguy hiểm hơn nhiều so với một thông báo lỗi.

## Làm từ đâu khi đến site khách hàng

Đừng bắt đầu bằng việc mô hình hóa cả warehouse. Hãy lấy báo cáo KPI hằng tuần hoặc bộ slide gửi hội đồng quản trị gần nhất, rồi lọc ra những chỉ số xuất hiện nhiều nhất. Danh sách đó là phạm vi đợt đầu.

Tiếp theo, ngồi với người thật sự sở hữu từng định nghĩa, thường là finance hoặc RevOps. Viết định nghĩa bằng lời trước khi viết YAML, chẳng hạn: "MRR tính gói năm chia 12, không tính phí setup, ghi nhận theo tháng thanh toán". Ghi rõ tên người đã duyệt từng định nghĩa.

Sau đó viết semantic model và chạy metric cho vài tháng đã đóng sổ. So từng con số với báo cáo hiện hành của khách hàng. Lệch chỗ nào thì quay lại hỏi người sở hữu định nghĩa, đừng tự đoán.

Chỉ khi các con số khớp mới nối LLM vào. Lúc đó hãy định tuyến rõ ràng: câu hỏi chạm tới chỉ số đã định nghĩa thì đi qua semantic layer, còn lại mới đến text-to-SQL.

## Những cái bẫy hay gặp

Bẫy đầu tiên là định nghĩa chỉ số theo tên cột. Cột `revenue` trong warehouse chưa chắc là doanh thu theo cách kế toán hiểu. Bẫy thứ hai là khi semantic layer báo lỗi thì âm thầm fallback sang text-to-SQL. Làm vậy là bạn tự tay vứt đi ưu điểm lớn nhất của semantic layer: lỗi được lộ ra thay vì nằm im.

Bẫy thứ ba là để LLM tự tạo metric mới ngay lúc chạy. Một metric mới nên đi qua code review như mọi thay đổi khác. Bẫy cuối cùng là tin benchmark của vendor mà không có bộ eval riêng gồm các câu hỏi thật của khách hàng, mỗi câu kèm đáp án đã được finance xác nhận.

## Kỹ năng này nên hiện lên CV thế nào?

Khi đọc JD FDE hay solutions engineer, hãy để ý các từ khóa như dbt, MetricFlow, Cube, semantic layer hay "metrics definitions". Đó là dấu hiệu công ty cần người giữ cho con số đúng, chứ không chỉ cần người nối LLM vào database.

Trong CV, một dòng kiểu "chuẩn hóa định nghĩa các chỉ số KPI cốt lõi trong semantic layer, đối chiếu khớp với báo cáo finance trước khi mở cho agent" có sức nặng hơn nhiều so với "xây chatbot text-to-SQL".

LLM sẽ còn viết SQL giỏi hơn. Nhưng biết công ty khách hàng tính doanh thu thế nào vẫn là việc phải đi hỏi con người, rồi ghi lại vào đúng một chỗ.

**Thử ngay tuần này:**

- Chọn một chỉ số khách hàng hay hỏi nhất (ví dụ MRR), bắt LLM viết SQL cho nó 5 lần rồi so con số với báo cáo của đội finance
- Viết YAML semantic model cho đúng chỉ số đó, có entity, dimension và measure, rồi kiểm tra con số khớp với báo cáo hiện hành
- Lập một bảng: mỗi chỉ số kèm định nghĩa bằng lời, người chịu trách nhiệm định nghĩa và bảng nguồn

## Nguồn

- [Why text-to-SQL fails](https://omni.co/blog/why-text-to-sql-fails)

- [Introducing Cube's Claude Connector and Skills](https://cube.dev/blog/cube-connector-and-skills-for-claude)

- [About MetricFlow | dbt Developer Hub](https://docs.getdbt.com/docs/build/about-metricflow)

- [Semantic Layer vs. Text-to-SQL: 2026 Benchmark Update](https://docs.getdbt.com/blog/semantic-layer-vs-text-to-sql-2026)
