<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Вадим Мамаев</title>
    <description>The latest articles on DEV Community by Вадим Мамаев (@__11f7ea740fa6).</description>
    <link>https://dev.to/__11f7ea740fa6</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4049642%2F4439cdb3-4f28-47f3-8d38-e4f80d2b7847.jpg</url>
      <title>DEV Community: Вадим Мамаев</title>
      <link>https://dev.to/__11f7ea740fa6</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/__11f7ea740fa6"/>
    <language>en</language>
    <item>
      <title>How we stopped a local LLM from inventing JOINs across a 900-table schema</title>
      <dc:creator>Вадим Мамаев</dc:creator>
      <pubDate>Mon, 27 Jul 2026 14:08:00 +0000</pubDate>
      <link>https://dev.to/__11f7ea740fa6/how-we-stopped-a-local-llm-from-inventing-joins-across-a-900-table-schema-6o0</link>
      <guid>https://dev.to/__11f7ea740fa6/how-we-stopped-a-local-llm-from-inventing-joins-across-a-900-table-schema-6o0</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4ma15jrd6gckzcuu1p82.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4ma15jrd6gckzcuu1p82.png" alt="Local LLM constrained to trusted join paths across a 900-table warehouse" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Practical notes from building an internal text-to-SQL agent. Everything runs on-prem: a local model behind a LiteLLM proxy, bge-m3 embeddings, Postgres with pgvector for retrieval, and ClickHouse as the target warehouse.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: English is not my native language. I wrote the original draft in Russian and used an LLM to help edit and translate it. The system, implementation details, and conclusions described below are my own.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reliable text-to-SQL over a large schema is not a prompting problem alone.&lt;/li&gt;
&lt;li&gt;Finding the right tables and joining them safely are different problems, so we separated them.&lt;/li&gt;
&lt;li&gt;We retrieve tables with vector search plus &lt;code&gt;pg_trgm&lt;/code&gt;, fused with Reciprocal Rank Fusion.&lt;/li&gt;
&lt;li&gt;We store known relationships in a trust-weighted join graph and choose an allowed path by minimizing an internal risk penalty.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;We have an internal agent that answers questions against a large ClickHouse warehouse with roughly 900 tables. A user asks in Russian, “show me unused storage volumes” or “what does this system cost,” and the agent is expected to find the data, write SQL, run it, and return an answer.&lt;/p&gt;

&lt;p&gt;The entire pipeline stays inside our network. Questions, schema, and data never leave the perimeter.&lt;/p&gt;

&lt;p&gt;This looks easy in a demo: connect an LLM to the database, put the schema in the prompt, and let it work. On a real warehouse, that setup breaks in two predictable places:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The model selects the wrong tables.&lt;/li&gt;
&lt;li&gt;Even when it finds the right ones, it does not know how to join them safely.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The second failure mode hurt more. The agent would start probing the schema: &lt;code&gt;DESCRIBE&lt;/code&gt; one table, &lt;code&gt;SELECT&lt;/code&gt; from another, retry with a neighboring column, then change one &lt;code&gt;WHERE&lt;/code&gt; clause and try again. A single question could generate dozens of near-identical queries before timing out.&lt;/p&gt;

&lt;p&gt;We were not paying a cloud token bill—the model is local—but we were paying in latency, occupied GPU time, and pointless load on ClickHouse.&lt;/p&gt;

&lt;p&gt;The key lesson was simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;“Which tables do I need?” and “How should I join them?” are two different kinds of knowledge. They should not be buried in one giant prompt.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The pipeline now looks like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F86ss5610xrmu4v3q82n1.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F86ss5610xrmu4v3q82n1.jpeg" alt="Constrained text-to-SQL pipeline" width="800" height="462"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;By “correct SQL,” I do not just mean SQL that parses. It must only touch allowed tables and columns, answer the actual question, and avoid corrupting the result through a wrong join or mismatched grain.&lt;/p&gt;

&lt;p&gt;This post focuses on table selection and joins. SQL validation and safe execution are a separate layer—and probably a separate post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping schema knowledge in Markdown
&lt;/h2&gt;

&lt;p&gt;The first decision was where to keep what the agent knows about the warehouse.&lt;/p&gt;

&lt;p&gt;A mega-prompt was hard to maintain, review, and diff. We also did not want to invent a custom configuration language. We settled on plain Markdown, one file per domain.&lt;/p&gt;

&lt;p&gt;A simplified example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;storage&lt;/span&gt;
&lt;span class="na"&gt;keywords_ru&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;СХД&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;том&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;LDEV&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;RAID-группа&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;пул&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;задержка&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;keywords_en&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;volume&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;ldev&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;raid group&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;pool&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;latency&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;question_examples&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;show&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;unused&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;volumes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;by&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;array"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;which&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;volume&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;growing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;fastest"&lt;/span&gt;
&lt;span class="na"&gt;tables_primary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;warehouse.array_ldev_config&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;warehouse.array_iops_stats&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="c1"&gt;# What the fields mean&lt;/span&gt;
&lt;span class="nn"&gt;...&lt;/span&gt;

&lt;span class="c1"&gt;# How the tables join&lt;/span&gt;
&lt;span class="nn"&gt;...&lt;/span&gt;

&lt;span class="c1"&gt;# Easy ways to get this domain wrong&lt;/span&gt;
&lt;span class="nn"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The indexer compiles each domain file into small table cards:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Table card: &lt;code&gt;warehouse.array_ldev_config&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Domain:&lt;/strong&gt; storage&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Synonyms:&lt;/strong&gt; СХД, том, storage, volume, LDEV&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Purpose:&lt;/strong&gt; storage volume configuration&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Keys:&lt;/strong&gt; &lt;code&gt;serial&lt;/code&gt;, &lt;code&gt;ldev_id&lt;/code&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Grain:&lt;/strong&gt; one row per volume&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Join:&lt;/strong&gt; stats → config on (&lt;code&gt;serial&lt;/code&gt;, &lt;code&gt;ldev_id&lt;/code&gt;), N:1&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Constraint:&lt;/strong&gt; filter stats to the relevant &lt;code&gt;load_date&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The format works for very ordinary reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a domain expert can fix a description without changing code;&lt;/li&gt;
&lt;li&gt;every edit is a normal Git diff;&lt;/li&gt;
&lt;li&gt;adding a domain means adding a file;&lt;/li&gt;
&lt;li&gt;hashing lets us re-embed only cards that changed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One small addition made a noticeable difference. Users ask questions in Russian, while table and column names are in English. Before embedding a card, we explicitly prepend Russian and English synonyms.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://arxiv.org/abs/2402.03216" rel="noopener noreferrer"&gt;bge-m3&lt;/a&gt; is multilingual, but multilingual embeddings alone were not enough for our corpus. The Russian acronym “СХД,” for example, does not necessarily map cleanly to a table literally named &lt;code&gt;array_ldev_config&lt;/code&gt;. Explicit bridges such as “СХД ↔ storage array” improved retrieval recall in practice.&lt;/p&gt;

&lt;p&gt;Each card is embedded with bge-m3 and stored in Postgres with pgvector. If the Markdown hash has not changed, we do not recompute the embedding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding tables: two searches and RRF
&lt;/h2&gt;

&lt;p&gt;Vector search alone was not enough.&lt;/p&gt;

&lt;p&gt;It was good at meaning, but it could blur exact identifiers such as serial numbers, ticket codes, and literal column names. We therefore run two searches in parallel:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;vector search over embeddings for semantic similarity;&lt;/li&gt;
&lt;li&gt;trigram search with &lt;code&gt;pg_trgm&lt;/code&gt; for exact and near-exact strings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Their raw scores are not directly comparable. Cosine distance and trigram similarity live on different scales, so adding them is arbitrary. Instead, we use Reciprocal Rank Fusion (RRF): discard the raw scores and combine the ranks.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftywaz4vfduixce9h0j9w.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftywaz4vfduixce9h0j9w.jpeg" alt="Hybrid table retrieval with Reciprocal Rank Fusion" width="800" height="462"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A document benefits from ranking highly in either list and gets an extra boost when it performs well in both.&lt;/p&gt;

&lt;p&gt;We use &lt;code&gt;k = 60&lt;/code&gt;, the value the authors fixed during a pilot investigation in the &lt;a href="https://cormack.uwaterloo.ca/cormacksigir09-rrf.pdf" rel="noopener noreferrer"&gt;original RRF paper&lt;/a&gt;. We have not tuned it for our corpus, so I would not claim it is optimal. Plain RRF has usually been sufficient for us. An LLM reranker can be added on top, but it is disabled by default because the gain has not yet justified the extra latency.&lt;/p&gt;

&lt;p&gt;At this point, we have a list of relevant tables. We still have not answered the more dangerous question: how should they be joined?&lt;/p&gt;

&lt;h2&gt;
  
  
  The join graph
&lt;/h2&gt;

&lt;p&gt;We model the schema as a graph:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;nodes are tables;&lt;/li&gt;
&lt;li&gt;edges are allowed relationships;&lt;/li&gt;
&lt;li&gt;edge metadata contains keys, direction, cardinality, grain, and temporal conditions;&lt;/li&gt;
&lt;li&gt;edge weight is an internal risk penalty.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lower weight means we have more reason to trust the relationship:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Weight&lt;/th&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;Basis&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;confirmed&lt;/td&gt;
&lt;td&gt;a foreign key or a verified data-catalog relationship&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;validated on data&lt;/td&gt;
&lt;td&gt;the join was checked and has appeared in successful queries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;unconfirmed&lt;/td&gt;
&lt;td&gt;key names and types match, but the semantics are unverified&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;weak hypothesis&lt;/td&gt;
&lt;td&gt;the relationship is inferred from meaning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;model guess&lt;/td&gt;
&lt;td&gt;the relationship has not been verified&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are not probabilities or calibrated confidence scores. They are engineering penalties.&lt;/p&gt;

&lt;p&gt;Because lower means safer, we can look for a suitable chain with a standard shortest-path algorithm:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqboe65lj9rlbwmu0ckm8.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqboe65lj9rlbwmu0ckm8.jpeg" alt="Choosing a join path by trust penalty" width="736" height="462"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Path A → D → B wins because it has the lower total penalty.&lt;/p&gt;

&lt;p&gt;We use Dijkstra, cap the maximum weight of any individual edge, and limit the number of hops. This is a heuristic, not a proof of correctness. The minimum-penalty path is simply the path our system has the most reason to prefer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Join keys are not enough
&lt;/h3&gt;

&lt;p&gt;An edge that only says &lt;code&gt;table_a.id = table_b.id&lt;/code&gt; is not safe.&lt;/p&gt;

&lt;p&gt;For every trusted relationship, we also want to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cardinality: &lt;code&gt;1:1&lt;/code&gt;, &lt;code&gt;1:N&lt;/code&gt;, or &lt;code&gt;N:M&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;direction;&lt;/li&gt;
&lt;li&gt;the grain of both tables;&lt;/li&gt;
&lt;li&gt;temporal rules such as &lt;code&gt;load_date&lt;/code&gt; or &lt;code&gt;valid_from/valid_to&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;the risk of row multiplication.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Otherwise, a syntactically valid join can fan rows out and silently corrupt every downstream &lt;code&gt;SUM&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A non-empty result does not prove that a join is correct. You can join two tables incorrectly and still get millions of rows. Successful execution is therefore only one signal. High-trust edges also need confirmed semantics, cardinality checks, and controls for unexpected row amplification.&lt;/p&gt;

&lt;p&gt;The inverse is also true: an empty &lt;code&gt;INNER JOIN&lt;/code&gt; does not prove that the relationship is fake. Filters, mismatched time windows, or incomplete data can all produce an empty result. We treat it as a reason to lower confidence and inspect the edge, not as an automatic verdict.&lt;/p&gt;

&lt;p&gt;A background job reads the audit log and records joins that appear in successfully executed queries. This is only a usage signal: “the query ran” is not the same as “the answer was semantically correct.”&lt;/p&gt;

&lt;h2&gt;
  
  
  What the model receives
&lt;/h2&gt;

&lt;p&gt;When retrieval returns two or more candidate tables, we find an allowed path in the graph and add it to the model context.&lt;/p&gt;

&lt;p&gt;The wording depends on the trust level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Confirmed relationship:
  Use warehouse.array_iops_stats AS s
  JOIN warehouse.array_ldev_config AS c
    ON s.serial = c.serial AND s.ldev_id = c.ldev_id
  Cardinality: many-to-one.
  Select the relevant load_date before aggregating.

Medium confidence:
  Likely relationship: (...)
  Validate cardinality and row amplification first.

Weak hypothesis:
  Do not present it to the model as a recommended join.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is what reduced the blind column probing. The model no longer receives the whole schema plus “figure it out.” It gets a small set of relevant tables and explicit constraints for joining them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What improved—and what we have not proved yet
&lt;/h2&gt;

&lt;p&gt;In one illustrative storage query, the agent used to reach 51 SQL attempts. After we started pinning joins from the graph, it dropped to 18. Moving schema knowledge out of the system prompt and into cards and edges also reduced the prompt by roughly 70%.&lt;/p&gt;

&lt;p&gt;Those are useful observations, not a benchmark. One before/after example does not answer the questions that actually matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What percentage of generated queries execute?&lt;/li&gt;
&lt;li&gt;What percentage of answers are semantically correct?&lt;/li&gt;
&lt;li&gt;How good is table retrieval (&lt;code&gt;Recall@k&lt;/code&gt;)?&lt;/li&gt;
&lt;li&gt;What are the median and p95 SQL-attempt counts?&lt;/li&gt;
&lt;li&gt;What is the end-to-end latency?&lt;/li&gt;
&lt;li&gt;How often does a join run successfully but corrupt an aggregation?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We are still building that evaluation harness. The honest claim today is narrower: this approach substantially reduced probing in our working scenarios and made failures easier to diagnose, but we still need a fixed evaluation set to measure the overall quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why two systems are easier to operate than one prompt
&lt;/h2&gt;

&lt;p&gt;Table retrieval and join selection do not just require different methods; they fail differently.&lt;/p&gt;

&lt;p&gt;If the agent misses a table, we inspect its card, synonyms, and retrieval results. If it finds the right tables but joins them incorrectly, we inspect the edge, cardinality, and grain. We do not have to swap the model or rewrite the entire system prompt after every failure.&lt;/p&gt;

&lt;p&gt;Most of the system is also just data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;domain descriptions live in Markdown under Git;&lt;/li&gt;
&lt;li&gt;cards and embeddings live in Postgres/pgvector;&lt;/li&gt;
&lt;li&gt;relationships are Postgres rows editable through a small UI;&lt;/li&gt;
&lt;li&gt;adding a domain means adding descriptions and a handful of verified edges, without fine-tuning the model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Numerical answers should come only from SQL the agent actually executed, and the query should be retained for inspection. That does not make an answer automatically true, but it does make it reproducible and auditable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this approach falls short
&lt;/h2&gt;

&lt;p&gt;The main limitation is coverage.&lt;/p&gt;

&lt;p&gt;If a table has not been described, retrieval may miss it. If a relationship is absent from the graph, the system cannot safely recommend that join. A new domain still requires expert work: document the tables, record their grain, and verify the main relationships.&lt;/p&gt;

&lt;p&gt;There are other failure modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;heuristic weights do not replace semantic validation;&lt;/li&gt;
&lt;li&gt;old successful queries can reinforce old mistakes;&lt;/li&gt;
&lt;li&gt;schema or business-logic changes can make an edge stale;&lt;/li&gt;
&lt;li&gt;a long chain of individually valid joins can still distort the result;&lt;/li&gt;
&lt;li&gt;we have not tuned RRF's &lt;code&gt;k = 60&lt;/code&gt; for our corpus;&lt;/li&gt;
&lt;li&gt;the LLM reranker is disabled by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The graph does not eliminate metadata management. It turns expert knowledge from prose “somewhere in the wiki” into a structure the agent can use while generating SQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I would reuse
&lt;/h2&gt;

&lt;p&gt;I would not start by copying a particular model or writing a larger prompt.&lt;/p&gt;

&lt;p&gt;I would copy the separation of responsibilities:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Retrieve the smallest useful set of tables.&lt;/li&gt;
&lt;li&gt;Independently find an allowed, low-risk way to join them.&lt;/li&gt;
&lt;li&gt;Give the model only that constrained slice of the schema.&lt;/li&gt;
&lt;li&gt;Retain the executed SQL so the answer can be inspected.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For us, this was much more practical than expecting the model to reconstruct the warehouse from column names.&lt;/p&gt;

&lt;p&gt;If there is interest, I can follow up with the edge-table schema, the SQL for RRF, or the evaluation harness. I would also be curious to hear how others handle cardinality and stale joins in large warehouses.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>sql</category>
      <category>database</category>
    </item>
  </channel>
</rss>
