Dev.to AI ๐Ÿค– Ai ๐Ÿ‘ 0 ๐Ÿ“– 17 min read

Build a Knowledge Layer for SQL Agents with OKF (Part 2)

You connect an AI agent to your shop's database and ask a simple question: "How many active customers do we have?" The agent looks at the tables: customers (customer_id, name, status, created_at) orders (order_id,

Build a Knowledge Layer for SQL Agents with OKF (Part 2)

None

You connect an AI agent to your shop's database and ask a simple question: "How many active customers do we have?"

The agent looks at the tables:

customers (customer_id, name, status, created_at)
orders    (order_id, customer_id, amount, paid, ordered_at)

It spots a status column with the value 'active', writes a query, and gives you a number in two seconds:

SELECT COUNT(*) FROM customers WHERE status = 'active';

The number is wrong.

In your company, status = 'active' only means the account hasn't been closed. When finance says "active customer", they mean someone who placed a paid order in the last 90 days. Everyone on the data team knows this. It's written in a wiki page and built into the revenue dashboard. But it's not in the database, so the agent never saw it.

SELECT COUNT(DISTINCT customer_id)
FROM orders
WHERE paid = TRUE
  AND ordered_at >= CURRENT_DATE - INTERVAL '90 days';

This is the real problem with SQL agents. The database stores your data, but not the rules for reading it. Those rules live in wikis, dashboards, dbt models and people's heads. A new analyst picks them up by asking around. An agent can only read what you give it, and when a rule is missing, it guesses. The guess runs without errors, so nobody notices.

The agent sees the database. The definitions live everywhere else.

Is this a real problem?

It's fair to ask whether the shop example is realistic or just made up to prove a point. A public benchmark puts agents in exactly this situation.

LiveSQLBench tests how well AI agents turn questions into SQL. It contains 18 databases on different topics, from disaster response to crypto trading. Each database comes with three files:

  • the schema: tables and columns,
  • a description of every column,
  • a separate file of business rules.

The questions use business terms, and many of those terms are defined only in the rules file. It's the same situation as our shop: the database holds the data, and the definitions live somewhere else.

Here's one case from the disaster-response database. Two of its tables look like this (only the relevant columns are shown):

operations                (opsregistry, emerglevel, ...)
coordinationandevaluation (coordopsref, safetyranking, secincidentcount, ...)

Ask an agent for "high-risk operations", and it will spot the safetyranking column, which has a value called High Risk, and filter on it, just as our shop agent filtered on status. But the rules file defines a high-risk operation as one where emerglevel is Red or Black, safetyranking is High Risk, and secincidentcount is more than 50. That's three conditions across two tables. The obvious guess catches one.

It's a hard test. At the time of writing, the best system on the LiveSQLBench leaderboard answers 48% of its tasks correctly.

What we'll build

So how do you give an agent the rules it's missing? Teams usually try one of two things.

The first is to paste everything into the prompt: every table, every column description, every rule. That works for a small database. But you pay for all those tokens on every question, and for a large database the documents don't fit in the model's context window at all.

The second is to let the agent guess from the schema alone. It's cheap, and it gives you the wrong answers we just saw.

This series builds a third option: a knowledge layer. We take the schema, the column descriptions and the business rules, and store them as an OKF bundle: a folder of small markdown files called concept files, one per table and one per business rule, with an index file in each folder.

An agent can read an OKF bundle in more than one way. It can search the files, follow the links between them like a graph, or walk through the index files. We start with the simplest, called progressive disclosure: the agent reads the top index first, opens only the concept files the question needs, and then writes SQL. OKF's index files exist for exactly this, although they're optional and other readers can work without them.

From raw docs to a scored agent: the four parts of this series.

Raw docs: schema, column descriptions, business rules
        โ”‚   compiler
        โ–ผ
OKF bundle: concept files + index files
        โ”‚   agent tools (plain Python, then MCP)
        โ–ผ
Agent opens what it needs โ†’ writes SQL
        โ”‚   evaluation harness
        โ–ผ
Score on LiveSQLBench

The series has four parts. Each one ends with something you can run on your laptop:

  1. The problem and your first bundle (this post). Set up the project, explore one LiveSQLBench database, write a small OKF bundle by hand, and validate it.
  2. The compiler. A Python script that turns a database's raw files into a full OKF bundle automatically, for all 18 databases.
  3. The agent. Run the benchmark database locally in Docker, then build an agent that reads the bundle through progressive disclosure and writes SQL: first in plain Python, then over MCP (Model Context Protocol), so any MCP-compatible agent can use the same knowledge.
  4. The honest test. Run the same benchmark questions four ways with open models: schema only, everything in the prompt, progressive disclosure over the bundle, and search over the same bundle. Then compare accuracy, tokens and cost, whatever the results show.

Set up the project

You need Python 3.11 or newer (the OKF validator we use later in this post requires it) and Git. Step-by-step setup for macOS and Windows is in the tutorial guide in the project repo. (If you'd rather run the finished code than build it yourself, follow the README instead.)

Following the guide, you create a project folder with this structure:

okf-sql-knowledge/
โ”œโ”€โ”€ data/       raw LiveSQLBench files
โ””โ”€โ”€ bundles/    the OKF bundles we write

Both folders start empty. We fill data/ next and bundles/ later in this post.

Get the data

LiveSQLBench comes in several releases. We use Base-Lite, the smallest one: 18 databases and 270 questions. Download it into data/ as shown in the tutorial guide. It's only a few megabytes.

Here's what you get:

data/livesqlbench-base-lite/
โ”œโ”€โ”€ README.md
โ”œโ”€โ”€ livesqlbench_data.jsonl
โ”œโ”€โ”€ alien/
โ”œโ”€โ”€ archeology/
โ”œโ”€โ”€ credit/
โ”œโ”€โ”€ disaster/
โ”œโ”€โ”€ ...              (18 database folders in total)
โ””โ”€โ”€ virtual/

There are two kinds of things here:

  • One folder per database (alien, credit, disaster, โ€ฆ). Each folder describes one database: its tables, its columns and its business rules.
  • livesqlbench_data.jsonl: the questions, one per line, 15 for each database.

Here's the first question for the disaster database, shortened to the fields that matter now:

{
  "instance_id": "disaster_1",
  "selected_database": "disaster",
  "query": "I need to analyze all distribution hubs based on their Resource Utilization Ratio. Please show the hub registry ID, the calculated RUR value, and their Resource Utilization Classification. Sort the results by RUR from highest to lowest.",
  "sol_sql": []
}

Notice two things.

First, the question asks for a "Resource Utilization Ratio". No column has that name. Its formula lives in the business rules, which is exactly the problem from the start of this post.

Second, sol_sql, the correct answer, is empty. The benchmark's authors hold back the answers so automated web crawlers can't collect them. You can request them by email, as the dataset page explains. We won't need them until we test the agent.

From here on, we work with the disaster database. Its folder has three files, each around 20 KB:

data/livesqlbench-base-lite/disaster/
โ”œโ”€โ”€ disaster_schema.txt
โ”œโ”€โ”€ disaster_column_meaning_base.json
โ””โ”€โ”€ disaster_kb.jsonl

Read the three raw files

We'll follow the question we just saw. To answer it, an agent needs the distribution hubs table, the formula for the Resource Utilization Ratio, and the rule that turns that ratio into a classification. Each piece lives in a different file. (For a full tour of every file and field, see data/README.md in the repo.)

The schema: disaster_schema.txt

Plain text, one CREATE TABLE statement per table, each followed by three sample rows. The table we need has 11 columns:

CREATE TABLE "distributionhubs" (
hubregistry character varying NOT NULL,
disteventref character varying NULL,
hubcaptons numeric NULL,
hubutilpct numeric NULL,
storecapm3 numeric NULL,
storeavailm3 numeric NULL,
coldstorecapm3 numeric NULL,
coldstoretempc numeric NULL,
warehousestate USER-DEFINED NULL,
invaccpct numeric NULL,
stockturnrate numeric NULL,
    PRIMARY KEY (hubregistry),
    FOREIGN KEY (disteventref) REFERENCES disasterevents(distregistry)
);

The schema gives names, types and joins. It doesn't say what hubutilpct or storeavailm3 mean.

The column descriptions: disaster_column_meaning_base.json

One sentence per column, keyed by database|table|column:

"disaster|distributionhubs|hubutilpct": "A DECIMAL(7,3) showing the percentage of hub capacity currently utilized (e.g., 85.300)."

Now hubutilpct makes sense: it's the percentage of the hub's capacity in use.

The business rules: disaster_kb.jsonl

This is the file our agent was missing in the opening. It holds 54 rules, one JSON object per line. Here are the two our question needs, formatted for reading:

{
  "id": 10,
  "knowledge": "Resource Utilization Ratio (RUR)",
  "description": "Measures how effectively hub capacity is being used relative to available resources",
  "definition": "RUR = \\frac{hubutilpct}{100} \\times \\frac{storecapm3}{storeavailm3 + 1}",
  "type": "calculation_knowledge",
  "children_knowledge": -1
}
{
  "id": 50,
  "knowledge": "Resource Utilization Classification",
  "description": "Categorizes distribution hubs based on their Resource Utilization Ratio (RUR) values",
  "definition": "High Utilization (RUR > 5) indicates potentially overloaded hubs that may need resource expansion; Moderate Utilization (2 โ‰ค RUR โ‰ค 5) represents optimal resource usage balance; Low Utilization (RUR < 2) indicates underutilized hubs with potential efficiency gains through resource reallocation",
  "type": "domain_knowledge",
  "children_knowledge": [10]
}

Three fields matter most for what we build next:

  • type says what kind of rule it is: a formula (calculation_knowledge), a business definition (domain_knowledge), or an explanation of a column's values (value_illustration).
  • definition is the rule itself. Rule 10 is a LaTeX formula: RUR = (hubutilpct / 100) ร— (storecapm3 / (storeavailm3 + 1)).
  • children_knowledge lists the rules this one depends on. Rule 50 lists [10], because you can't classify a hub without first computing its RUR.

Notice what's missing. Rule 10 names three columns but not their table. To learn that they belong to distributionhubs, you have to go back to the schema.

What we learned

No file is enough on its own:

File Answers Example
Schema What tables and columns exist, and how they join distributionhubs has hubutilpct
Column descriptions What each column means hubutilpct is the percentage of capacity in use
Business rules What the business terms mean RUR is a formula over three of those columns

To answer one question, an agent needs pieces from all three files.

Write your first concept files by hand

Now we turn those pieces into OKF. A concept file is a markdown file with a block of YAML at the top, called frontmatter, and a normal markdown body below it. (What each frontmatter field means is covered in the introduction to OKF; here we focus on using them.)

We'll write three concept files: one for the table, and one for each rule. Your project will look like this:

okf-sql-knowledge/
โ”œโ”€โ”€ data/
โ”‚   โ””โ”€โ”€ livesqlbench-base-lite/
โ”œโ”€โ”€ bundles/
โ”‚   โ””โ”€โ”€ disaster/
โ”‚       โ”œโ”€โ”€ tables/
โ”‚       โ”‚   โ””โ”€โ”€ distributionhubs.md
โ”‚       โ””โ”€โ”€ knowledge/
โ”‚           โ”œโ”€โ”€ resource-utilization-ratio.md
โ”‚           โ””โ”€โ”€ resource-utilization-classification.md
โ”œโ”€โ”€ requirements.txt
โ””โ”€โ”€ README.md

Tables go in tables/, rules go in knowledge/. OKF doesn't require any particular folder names; this split simply makes the bundle easy to browse.

The table concept

Create bundles/disaster/tables/distributionhubs.md:

---
type: PostgreSQL Table
title: distributionhubs
description: One row per disaster-relief distribution hub, with its capacity, storage space, cold storage, warehouse condition and inventory measures.
tags: [disaster, logistics]
generated: { by: human:your-name, at: 2026-10-02T17:00:00+05:30 }
sources:
  - id: schema
    resource: https://huggingface.co/datasets/birdsql/livesqlbench-base-lite/blob/main/disaster/disaster_schema.txt
    title: disaster schema (LiveSQLBench)
  - id: column-meanings
    resource: https://huggingface.co/datasets/birdsql/livesqlbench-base-lite/blob/main/disaster/disaster_column_meaning_base.json
    title: disaster column descriptions (LiveSQLBench)
---

# Schema

| Column | Type | Meaning |
|---|---|---|
| `hubregistry` | varchar, primary key | Hub ID, for example `HUB_HS0I`. |
| `disteventref` | varchar | The disaster event this hub serves. |
| `hubcaptons` | numeric | Maximum capacity of the hub, in tons. |
| `hubutilpct` | numeric | Percentage of the hub's capacity currently in use. |
| `storecapm3` | numeric | Total storage capacity, in cubic meters. |
| `storeavailm3` | numeric | Storage still available, in cubic meters. |
| `coldstorecapm3` | numeric | Cold-storage capacity, in cubic meters. |
| `coldstoretempc` | numeric | Temperature of the cold storage, in ยฐC. |
| `warehousestate` | enum | Warehouse condition: `Fair`, `Excellent`, `Good` or `Poor`. |
| `invaccpct` | numeric | Inventory accuracy, as a percentage. |
| `stockturnrate` | numeric | How often the inventory is turned over in a period. |

# Joins

`disteventref` references `distregistry` in [disasterevents](/tables/disasterevents.md).

# Related knowledge

* [Resource Utilization Ratio (RUR)](/knowledge/resource-utilization-ratio.md) is computed from `hubutilpct`, `storecapm3` and `storeavailm3`.

Replace your-name with your own name, and the date with the current time.

This one file now holds what used to be split across two raw files: the columns and types from the schema, and their meanings from the column descriptions. A few things to notice:

  • type is the only required field. OKF has no fixed list of types, so we pick a name that says what the concept is.
  • description is the line an agent sees before it opens the file. We'll use it in the index files in the next step, so it should say clearly what's in the table.
  • generated records who wrote the file and when, and sources points back to the raw files it came from. human: marks a person as the author.
  • Links use paths that start with /. They point from the root of the bundle, so they stay correct wherever the file sits.
  • The link to disasterevents points to a file we haven't written. That's on purpose. OKF allows links to knowledge that doesn't exist yet, and we'll see how the validator treats it.

The rule concepts

Create bundles/disaster/knowledge/resource-utilization-ratio.md for rule 10:

---
type: Calculation
title: Resource Utilization Ratio (RUR)
description: Measures how effectively hub capacity is being used relative to available resources.
tags: [disaster, logistics]
generated: { by: human:your-name, at: 2026-10-02T17:00:00+05:30 }
sources:
  - id: kb
    resource: https://huggingface.co/datasets/birdsql/livesqlbench-base-lite/blob/main/disaster/disaster_kb.jsonl
    title: disaster business rules (LiveSQLBench), rule 10
---

# Definition

RUR = (hubutilpct / 100) ร— (storecapm3 / (storeavailm3 + 1))

# Columns used

All three columns are in [distributionhubs](/tables/distributionhubs.md): `hubutilpct`, `storecapm3` and `storeavailm3`.

# Used by

* [Resource Utilization Classification](/knowledge/resource-utilization-classification.md)

And bundles/disaster/knowledge/resource-utilization-classification.md for rule 50:

---
type: Business Rule
title: Resource Utilization Classification
description: Categorizes distribution hubs based on their Resource Utilization Ratio (RUR) values.
tags: [disaster, logistics]
generated: { by: human:your-name, at: 2026-10-02T17:00:00+05:30 }
sources:
  - id: kb
    resource: https://huggingface.co/datasets/birdsql/livesqlbench-base-lite/blob/main/disaster/disaster_kb.jsonl
    title: disaster business rules (LiveSQLBench), rule 50
---

# Definition

| Class | Condition | Meaning |
|---|---|---|
| High Utilization | RUR > 5 | Possibly overloaded; may need more resources. |
| Moderate Utilization | 2 โ‰ค RUR โ‰ค 5 | Balanced use of resources. |
| Low Utilization | RUR < 2 | Underused; resources could be moved elsewhere. |

# Depends on

* [Resource Utilization Ratio (RUR)](/knowledge/resource-utilization-ratio.md), which must be computed first.

What changed compared with the raw rules:

  • Each rule type got a readable name. calculation_knowledge became Calculation, and domain_knowledge became Business Rule.
  • The formula is plain math instead of LaTeX, and the classification is a table instead of one long sentence. The content is the same; it's just easier to read.
  • The rule now says where its columns live. The raw rule 10 named three columns but not their table. The "Columns used" section links to distributionhubs, so an agent no longer has to search the schema.
  • The dependency is a link in both directions. "Depends on" in rule 50 replaces children_knowledge: [10]. "Used by" in rule 10 is the reverse link, so an agent that starts from either rule can find the other.

Three concept files and the links between them. The dashed link points to knowledge we haven't written yet.

Together, these three files hold everything the disaster_1 question needs. But an agent still has to find them. That's the job of index files, which we add next.

Add index files and a log

Our bundle has three concept files. The full disaster database will have 64: 10 tables and 54 rules. An agent can't open all of them for every question; that would be no better than pasting everything into the prompt.

It needs a menu first. In OKF, that menu is a file called index.md. Each folder can have one, and it lists what's in that folder, one line per item, with a short description. The agent reads the menu, then opens only what it needs.

The root index

Create bundles/disaster/index.md:

---
okf_version: "0.2"
---

# disaster database

* [Tables](tables/index.md) - PostgreSQL tables of the disaster-response database, with column meanings and joins.
* [Knowledge](knowledge/index.md) - Business rules: formulas and definitions used in questions about this database.

This is the agent's front door. It points to the index of each folder and says what that folder holds.

The block at the top is optional. It declares which OKF version the bundle follows, which is useful because the spec can change between versions. It's the only frontmatter an index file may have, and only in the index at the root of the bundle. The other index files have no frontmatter at all.

One index per folder

Create bundles/disaster/tables/index.md:

# Tables

* [distributionhubs](distributionhubs.md) - One row per disaster-relief distribution hub, with its capacity, storage space, cold storage, warehouse condition and inventory measures.

And bundles/disaster/knowledge/index.md, grouped by rule type:

# Calculations

* [Resource Utilization Ratio (RUR)](resource-utilization-ratio.md) - Measures how effectively hub capacity is being used relative to available resources.

# Business rules

* [Resource Utilization Classification](resource-utilization-classification.md) - Categorizes distribution hubs based on their Resource Utilization Ratio (RUR) values.

Each line is the concept's title, a link to its file, and its description, copied from the frontmatter. This is why the description matters so much: in the index, it's the only thing the agent sees before deciding whether to open a file.

What the indexes make possible

With these files in place, everything the disaster_1 question needs can be reached from the root index in a few hops: from the knowledge index to the two rules, and from the rules to the table they use. Whether a real agent actually takes that path is something we'll check later in the series, when we build the agent and log what it opens.

The three index files together are under 1 KB. For comparison, the three raw files for this database total about 63 KB.

What the agent reads before it can start: 63 KB of raw files vs under 1 KB of index files.

There's a catch. Every time you add a concept, you also have to add its line to the right index, by hand. With three files that's easy. With 64, it's tedious and easy to get wrong. We'll automate it in the next part.

The log

Finally, create bundles/disaster/log.md:

# Update Log

## 2026-10-02
* **Creation**: Added the [distributionhubs](/tables/distributionhubs.md) table, the [Resource Utilization Ratio](/knowledge/resource-utilization-ratio.md) and the [Resource Utilization Classification](/knowledge/resource-utilization-classification.md), written by hand from LiveSQLBench `disaster`.

The log is a short history of changes to the bundle, newest first, grouped under dates. It has no frontmatter. Humans read it to see what changed and when; an agent can read it too, but it doesn't need it to answer questions.

Your project now looks like this:

okf-sql-knowledge/
โ”œโ”€โ”€ data/
โ”‚   โ””โ”€โ”€ livesqlbench-base-lite/
โ”œโ”€โ”€ bundles/
โ”‚   โ””โ”€โ”€ disaster/
โ”‚       โ”œโ”€โ”€ index.md
โ”‚       โ”œโ”€โ”€ log.md
โ”‚       โ”œโ”€โ”€ tables/
โ”‚       โ”‚   โ”œโ”€โ”€ index.md
โ”‚       โ”‚   โ””โ”€โ”€ distributionhubs.md
โ”‚       โ””โ”€โ”€ knowledge/
โ”‚           โ”œโ”€โ”€ index.md
โ”‚           โ”œโ”€โ”€ resource-utilization-ratio.md
โ”‚           โ””โ”€โ”€ resource-utilization-classification.md
โ”œโ”€โ”€ requirements.txt
โ””โ”€โ”€ README.md

Validate the bundle, then break it on purpose

Before any agent reads a bundle, whoever wrote it should check that it follows the OKF rules. A typo in the frontmatter is easy to miss by eye, and an agent won't tell you about it.

We'll use the validator from okf-skills, an open-source (MIT) toolkit for OKF. It's a single Python file. Download it as shown in the tutorial guide; the link is pinned to a fixed version, so you see the same output as below. Then run it from the project folder, with your virtual environment active:

python tools/okf_validate.py bundles/disaster
OKF v0.2 conformance โ€” bundles/disaster
  concepts: 3   index.md: 3   log.md: 1
  ! warn   tables/distributionhubs.md: cross-link target not found: `/tables/disasterevents.md` (tolerated under ยง6.1)
  โœ“ conformant (1 warning(s))

The bundle is conformant: it follows the OKF v0.2 rules. There's one warning, for the link to disasterevents that we left in on purpose.

That's the key difference between the two kinds of findings:

  • An error breaks a rule the spec requires. The bundle is not valid until you fix it.
  • A warning points at something worth a look, but the spec tells readers of a bundle to cope with it. A link to a file that doesn't exist yet is fine: it may simply be knowledge nobody has written.

The best way to learn the rules is to break them, so let's do that twice.

Break 1: remove the type

Open knowledge/resource-utilization-ratio.md and delete the line type: Calculation. Run the validator again:

OKF v0.2 conformance โ€” bundles/disaster
  concepts: 3   index.md: 3   log.md: 1
  โœ— ERROR  knowledge/resource-utilization-ratio.md: ยง11.2 missing or empty required `type` field
  ! warn   tables/distributionhubs.md: cross-link target not found: `/tables/disasterevents.md` (tolerated under ยง6.1)
  โœ— non-conformant (1 error(s))

Now it's an error, and the whole bundle is non-conformant. type is the one field every concept must have; everything else in the frontmatter is optional. Put the line back before moving on.

Break 2: add frontmatter to a folder index

Earlier we said that only the root index.md may have frontmatter. Let's test that. Add this block to the very top of tables/index.md:

---
title: Tables
---

Run the validator:

OKF v0.2 conformance โ€” bundles/disaster
  concepts: 3   index.md: 3   log.md: 1
  ! warn   tables/index.md: ยง8 index.md should contain no frontmatter
  ! warn   tables/distributionhubs.md: cross-link target not found: `/tables/disasterevents.md` (tolerated under ยง6.1)
  โœ“ conformant (2 warning(s))

It's only a warning, so the bundle still counts as conformant. But the OKF spec is clear that folder index files have no frontmatter, so remove the block again. If you're ever unsure about a format rule, check the official OKF spec.

Run the validator once more. When you see just the one expected warning, your first OKF bundle is done.

What's next

You now have a small, valid OKF bundle: one table and two rules, linked to each other and listed in index files. Along the way, we saw three things:

  • The schema isn't enough. Business definitions live elsewhere, and an agent without them guesses.
  • Links and indexes make knowledge findable. An agent can start from one small file and reach what a question needs.
  • A clear description matters most. It's what an agent sees before opening a file.

Three files by hand was easy. The disaster database alone needs 64, and all 18 databases need over a thousand. In the next part, we write a Python script that generates them all, with every link and index, and validates the result.

The code for this series is in the okf-sql-knowledge repository.

Sources

๐Ÿ“ฐ Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.