<?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: Ashish sinha</title>
    <description>The latest articles on DEV Community by Ashish sinha (@ashish_sinha_5241c7673d93).</description>
    <link>https://dev.to/ashish_sinha_5241c7673d93</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%2F4114397%2F3524a3c8-07e7-4ac0-a016-fd931fa4e63d.jpg</url>
      <title>DEV Community: Ashish sinha</title>
      <link>https://dev.to/ashish_sinha_5241c7673d93</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ashish_sinha_5241c7673d93"/>
    <language>en</language>
    <item>
      <title>Moving AWS Glue jobs to OCI Data Flow: a working map</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Sun, 27 Sep 2026 20:04:58 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/moving-aws-glue-jobs-to-oci-data-flow-a-working-map-34a0</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/moving-aws-glue-jobs-to-oci-data-flow-a-working-map-34a0</guid>
      <description>&lt;p&gt;If you run Spark ETL on AWS Glue and need to move it to Oracle Cloud, the good news is that most of it maps cleanly. The bad news is the few parts that don't, and they are exactly the parts Glue made easy. Here is the map I use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why teams move: cost
&lt;/h2&gt;

&lt;p&gt;Most teams I talk to move for one reason: the bill. OCI list prices for compute, block storage and Autonomous Database are often lower than the AWS equivalents, and OCI includes the first 10 TB of outbound data transfer each month at no charge, which matters for pipelines that ship data out. Don't take my word for it, though: price your own workload. That is one reason the tool at the end of this post shows the monthly cost of every part before it builds anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;AWS&lt;/th&gt;
&lt;th&gt;OCI&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Glue ETL job (PySpark)&lt;/td&gt;
&lt;td&gt;OCI Data Flow application&lt;/td&gt;
&lt;td&gt;Managed Spark. You upload the script to Object Storage and run it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3&lt;/td&gt;
&lt;td&gt;Object Storage&lt;/td&gt;
&lt;td&gt;Paths change from &lt;code&gt;s3://bucket/key&lt;/code&gt; to &lt;code&gt;oci://bucket@namespace/key&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Glue Data Catalog&lt;/td&gt;
&lt;td&gt;OCI Data Catalog (as a Hive metastore)&lt;/td&gt;
&lt;td&gt;Data Flow can use a Data Catalog metastore for your tables.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Glue crawlers&lt;/td&gt;
&lt;td&gt;Data Catalog harvesting&lt;/td&gt;
&lt;td&gt;Harvest a bucket to discover the schema.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Glue triggers / workflows&lt;/td&gt;
&lt;td&gt;Airflow (or another scheduler)&lt;/td&gt;
&lt;td&gt;Your DAGs keep working. Point the operators at Data Flow.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda glue code around the job&lt;/td&gt;
&lt;td&gt;OCI Functions&lt;/td&gt;
&lt;td&gt;Small handlers move over with little change.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What changes in the script
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Drop the Glue wrappers.&lt;/strong&gt; &lt;code&gt;GlueContext&lt;/code&gt;, &lt;code&gt;DynamicFrame&lt;/code&gt; and &lt;code&gt;job.commit()&lt;/code&gt; are Glue-only. Data Flow runs plain Spark, so use a normal &lt;code&gt;SparkSession&lt;/code&gt; and DataFrames.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Before (Glue)
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;awsglue.context&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;GlueContext&lt;/span&gt;
&lt;span class="n"&gt;glue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GlueContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SparkContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOrCreate&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;glue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_dynamic_frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_catalog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;raw&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toDF&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# After (Data Flow)
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pyspark.sql&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SparkSession&lt;/span&gt;
&lt;span class="n"&gt;spark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SparkSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getOrCreate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parquet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;oci://raw@mynamespace/events/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Change the paths.&lt;/strong&gt; Every &lt;code&gt;s3://&lt;/code&gt; path becomes &lt;code&gt;oci://bucket@namespace/&lt;/code&gt;. Put the bucket and namespace in job arguments, not in the code, so the same script runs in dev and prod.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Replace job bookmarks.&lt;/strong&gt; Glue bookmarks remember what was already processed. Data Flow has no built-in equivalent, so keep a small watermark yourself: a table or file with the last processed date or file name, read at the start of the run and written at the end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Iceberg.&lt;/strong&gt; If Glue was writing Iceberg tables, Spark on Data Flow can write them too. Add the Iceberg runtime to the application and point the catalog at Object Storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stays the same
&lt;/h2&gt;

&lt;p&gt;Your Spark logic. Joins, window functions, UDFs, partitioning: all of it is Spark, not Glue, and runs unchanged. In my experience the transformation code is the smallest part of the move. The paths, the catalog and the scheduling are where the work is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the move before you commit to it
&lt;/h2&gt;

&lt;p&gt;The hardest part of a migration is not the code, it is getting a place to try it. A ticket, a week, a compartment, and then nobody deletes it.&lt;/p&gt;

&lt;p&gt;So I built a chat for that on OCI. You paste a mapping like the table above, or a Git folder with your DAG and Spark job. It builds the bucket, the Data Flow application, the Data Catalog, Airflow with your DAG loaded, and an Autonomous Database for the gold tables, shows you the monthly price of each part first, and deletes all of it when the sandbox expires (1 to 30 days).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;3-minute demo: &lt;a href="https://youtu.be/PGnDhkchqUY" rel="noopener noreferrer"&gt;https://youtu.be/PGnDhkchqUY&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Overview, cost breakdown and FAQ: &lt;a href="https://ashishsinha1602.github.io/oci-sandbox-factory/" rel="noopener noreferrer"&gt;https://ashishsinha1602.github.io/oci-sandbox-factory/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Free one-click install in your own tenancy: &lt;a href="https://github.com/ashishsinha1602/oci-sandbox-factory" rel="noopener noreferrer"&gt;https://github.com/ashishsinha1602/oci-sandbox-factory&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Worked example (Airflow + Spark, "move this to OCI"): &lt;a href="https://github.com/ashishsinha1602/oci-sandbox-factory/tree/main/examples/telemetry-pipeline" rel="noopener noreferrer"&gt;https://github.com/ashishsinha1602/oci-sandbox-factory/tree/main/examples/telemetry-pipeline&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have moved Glue jobs to OCI and hit something this map misses, tell me in the comments. I'll add it.&lt;/p&gt;

</description>
      <category>oracle</category>
      <category>aws</category>
      <category>dataengineering</category>
      <category>cloud</category>
    </item>
    <item>
      <title>I built a chat that spins up Oracle Cloud sandboxes and deletes them when you're done</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Sat, 26 Sep 2026 23:22:51 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/i-built-a-chat-that-spins-up-oracle-cloud-sandboxes-and-deletes-them-when-youre-done-4jgl</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/i-built-a-chat-that-spins-up-oracle-cloud-sandboxes-and-deletes-them-when-youre-done-4jgl</guid>
      <description>&lt;p&gt;Every team I've worked with has the same problem with cloud sandboxes: they take a ticket to get, a week to arrive, and nobody ever deletes them. So I built a factory for them on Oracle Cloud. You describe what you need in a chat, it builds it in your tenancy with Terraform, hands you the links and credentials, and destroys it when its lifetime ends.&lt;/p&gt;

&lt;p&gt;Here it is, start to finish, in under 2 minutes:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/nuHfzOqG4io" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Full write-up, cost breakdown and FAQ: &lt;a href="https://ashishsinha1602.github.io/oci-sandbox-factory/" rel="noopener noreferrer"&gt;https://ashishsinha1602.github.io/oci-sandbox-factory/&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the video shows
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A chat with nothing running.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grafana from one sentence.&lt;/strong&gt; "Deploy Grafana for my team from the public image on port 3000." The assistant plans the container, prices it from Oracle's published price list, and notices the code needs an admin password. It asks for it in a box on the page. The value never reaches the AI. One click, and it's building.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A migration question.&lt;/strong&gt; "I have Airflow writing to S3 and Glue building Iceberg tables. Move it to OCI." It maps each piece: S3 to Object Storage, Glue jobs to Spark on Data Flow, the Glue catalog to Data Catalog, and Airflow stays Airflow. Your DAGs run unchanged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The repository.&lt;/strong&gt; Paste a GitHub folder link. It reads the DAG and the Spark job and builds the pipeline: a bucket for raw and gold data, a Data Flow application, a Data Catalog, Airflow with the DAG loaded, and an Autonomous Database for the gold tables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The tour.&lt;/strong&gt; Grafana, signed in with the password you gave. Airflow with the DAG green in four steps. The gold tables served as REST straight away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask the data.&lt;/strong&gt; A chat UI over the database, and an MCP endpoint so Claude, Cursor or any agent can discover the tools, pick the tables and run the query.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What it will build
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ask for&lt;/th&gt;
&lt;th&gt;You get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A database to explore or to wire into agents&lt;/td&gt;
&lt;td&gt;Autonomous Database with Select AI and REST on, a chat UI, an MCP endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Your app from a Git folder with a Dockerfile&lt;/td&gt;
&lt;td&gt;The image built inside OCI, served on HTTPS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An AWS Lambda, or any small handler&lt;/td&gt;
&lt;td&gt;An OCI Function. Lambda code runs unchanged, optionally on a schedule&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A Glue or Spark job&lt;/td&gt;
&lt;td&gt;A Data Flow application, optionally writing Iceberg tables, with a query app to read them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An Airflow plus Spark pipeline&lt;/td&gt;
&lt;td&gt;Airflow with your DAGs, Data Flow, Data Catalog, gold tables in Oracle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queues, NoSQL, Kafka, buckets&lt;/td&gt;
&lt;td&gt;OCI Queue, NoSQL tables, a Kafka cluster, Object Storage&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It builds the design you name and nothing you didn't ask for. If you say "no database", there is no database. If you describe code you don't have yet, it writes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parts I'm proud of
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Honest pricing.&lt;/strong&gt; Every proposal comes with a cost table from Oracle's live price list, per part, with the free allowances and the per-use services called out. A paid Autonomous Database is about $500 a month while it exists, so the assistant defaults to the pay-per-use pieces and prices the database as an option you can drop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It follows your architecture.&lt;/strong&gt; Paste a mapping and it builds exactly that list. It may say in one sentence that another design would be better. Then it builds yours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sandboxes die.&lt;/strong&gt; Each one has a lifetime of 1 to 30 days and a budget. A reaper destroys it when time is up. Every card has a Destroy button for sooner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing leaves your tenancy.&lt;/strong&gt; The install is one Resource Manager stack. The workers, the database that runs the app, the AI calls to OCI Generative AI, all of it stays inside your account. Users sign in to the app with their own login, no OCI account needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install it
&lt;/h2&gt;

&lt;p&gt;One click. It needs a tenancy administrator and a Pay-As-You-Go or paid account, and it takes about 15 minutes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://cloud.oracle.com/resourcemanager/stacks/create?zipUrl=https://github.com/ashishsinha1602/oci-sandbox-factory/releases/latest/download/sandbox-factory-foundation.zip" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Foci-resourcemanager-plugin.plugins.oci.oraclecloud.com%2Flatest%2Fdeploy-to-oracle-cloud.svg" alt="Deploy to Oracle Cloud" width="207" height="30"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Terraform, the prerequisites and the example workloads are on GitHub: &lt;a href="https://github.com/ashishsinha1602/oci-sandbox-factory" rel="noopener noreferrer"&gt;ashishsinha1602/oci-sandbox-factory&lt;/a&gt;. Read the prerequisites first; they list exactly what the install creates and what it grants.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it's built
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Terraform on OCI Resource Manager&lt;/strong&gt; for everything, the install and every sandbox. No local tooling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;APEX on an Autonomous Database&lt;/strong&gt; for the app. The assistant runs inside the database and calls OCI Generative AI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Container instances&lt;/strong&gt; as workers. They turn requests into stacks, build images inside OCI with kaniko, and never hold a laptop's credentials.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Select AI&lt;/strong&gt; switched on in every database, so any table can be asked in plain English.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An end-to-end suite&lt;/strong&gt; that runs inside OCI and builds every sandbox type for real, checks it live, and destroys it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;RAG and agents with only what Oracle provides: Select AI's vector index crawling a documents bucket, images described as text first so pictures and scans are searchable, Select AI Agents over the documents and the tables, and Oracle's own MCP server.&lt;/p&gt;

&lt;p&gt;Try it, break it, and tell me what you'd want it to build next.&lt;/p&gt;

&lt;p&gt;Overview and install: &lt;a href="https://ashishsinha1602.github.io/oci-sandbox-factory/" rel="noopener noreferrer"&gt;https://ashishsinha1602.github.io/oci-sandbox-factory/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>oracle</category>
      <category>cloud</category>
      <category>terraform</category>
      <category>ai</category>
    </item>
    <item>
      <title>Same question, same database, two callers</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Wed, 23 Sep 2026 02:34:49 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/same-question-same-database-two-callers-4df4</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/same-question-same-database-two-callers-4df4</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%2F8wdei96rg063ng0qnsyr.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%2F8wdei96rg063ng0qnsyr.png" alt="schemagate -- the model only sees the tables this caller is allowed to read, before any SQL exists" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most text-to-SQL stacks send the model the whole schema and put the access check after the query is written. That ordering is the bug, and it is easier to show than to argue about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole idea in one picture
&lt;/h2&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%2Fneieeeh8shleeprqiuxz.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%2Fneieeeh8shleeprqiuxz.png" alt="Two identical questions, one without the payroll role and one with it" width="800" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Left&lt;/strong&gt; -- the caller holds no roles. &lt;code&gt;hr_compensation&lt;/code&gt; never reaches the model at all: 9 of 42 objects selected, 570 prompt tokens instead of 2,511, one object hidden from this caller.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Right&lt;/strong&gt; -- same question, same schema, same database. The caller now holds the payroll role, so &lt;code&gt;hr_compensation&lt;/code&gt; is the &lt;em&gt;first&lt;/em&gt; table in the prompt, and nothing is hidden.&lt;/p&gt;

&lt;p&gt;Nothing about the question changed. The identity did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running against a real schema
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh99d9i9kyx3dn8arhqnm.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh99d9i9kyx3dn8arhqnm.gif" alt="The Studio selecting 14 of 51 objects on a claims schema" width="800" height="538"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is the Studio on a claims schema: 14 of 51 objects selected, 1,212 prompt tokens against 3,237 for the full schema, and three objects withheld because this caller has neither &lt;code&gt;actuarial&lt;/code&gt; nor &lt;code&gt;phi&lt;/code&gt;. The restricted ones are listed on the left, so you can see what was withheld rather than guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;schemagate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SQLite, PostgreSQL 16, Oracle 26ai, SQL Server 2022 and MySQL 8.4 are each certified against a live instance. There is also an MCP server -- now in the official MCP registry -- a LangChain retriever, and a CLI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part
&lt;/h2&gt;

&lt;p&gt;A selection step is only as good as its retrieval, and mine is not state of the art. On Spider pooled into one catalog (876 tables, no hint about which database the question belongs to), every gold table is present 82.6% of the time at top 10. On Spider 2.0-lite, which is real BigQuery and Snowflake schemas rather than benchmark ones, it is 64.0% at top 10 across all 247 usable questions. Both numbers, the harness, and the two measurement errors I had to correct along the way are in BENCHMARKS.md.&lt;/p&gt;

&lt;p&gt;Apache-2.0: &lt;a href="https://github.com/ashishsinha1602/schemagate" rel="noopener noreferrer"&gt;github.com/ashishsinha1602/schemagate&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>database</category>
      <category>llm</category>
      <category>sql</category>
    </item>
    <item>
      <title>38% of an analyst's questions get "no records found" when the data is right there</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Wed, 23 Sep 2026 01:04:31 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/38-of-an-analysts-questions-get-no-records-found-when-the-data-is-right-there-5edm</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/38-of-an-analysts-questions-get-no-records-found-when-the-data-is-right-there-5edm</guid>
      <description>&lt;p&gt;Your text-to-SQL agent has a failure mode that logs nothing, alerts nothing, and returns a confidently wrong answer. I finally put a number on how often it can happen, and on a small schema with an ordinary role split the number is 38.5%.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure
&lt;/h2&gt;

&lt;p&gt;Someone asks a question whose answer lives in a table they are not allowed to read.&lt;/p&gt;

&lt;p&gt;The model is handed the whole schema, because that is what nearly every text-to-SQL stack does. It writes a perfectly correct query against that table. The query executes. Row-level security removes every row. The application receives an empty result set and reports, truthfully as far as it knows, that there are no records.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"No rows matched"&lt;/em&gt; and &lt;em&gt;"you are not allowed to see the rows that matched"&lt;/em&gt; arrive at the application as the same thing: an empty list. There is no exception to catch, so no alert fires, no retry triggers, and nothing appears in the logs to review later.&lt;/p&gt;

&lt;p&gt;And the two readings lead somewhere different. "No unpaid invoices" is an answer someone might act on. "You cannot see the unpaid invoices" is a reason to go ask someone else. Collapsing the second into the first is not a degraded answer. It is a confidently wrong one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nobody measures it
&lt;/h2&gt;

&lt;p&gt;Because there is nothing to count. Every observable signal -- exit status, row count, latency, logs -- is identical to the honest empty result.&lt;/p&gt;

&lt;p&gt;So I stopped trying to catch it at runtime and defined it structurally instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A question is unanswerable for a caller when the tables its correct answer needs include at least one the caller may not read.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That needs gold labels -- question to tables -- which is the same thing Spider and BIRD already give you. What it does &lt;em&gt;not&lt;/em&gt; need is an LLM. No model is run and no SQL is executed. The rate is a property of your schema, your role model and your question mix, not of whichever model you happen to have wired up this week.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;silent-denial rate = unanswerable questions / total questions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
  
  
  The number
&lt;/h2&gt;

&lt;p&gt;A 42-object demo schema. Thirteen labelled questions. An unremarkable role model: finance tables to a &lt;code&gt;finance&lt;/code&gt; role, salary to &lt;code&gt;payroll&lt;/code&gt;, everything else open.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  caller           answerable  unanswerable  silent-denial
  analyst                   8             5         38.5%
  finance                  12             1          7.7%
  hr                        9             4         30.8%
  cfo                      13             0          0.0%

  detected before any SQL runs:  naive 0 of 10   scoped 10 of 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two questions in five that the analyst asks come back "no records found" while the data sits there. Not because retrieval failed. Not because the model is weak. Because the permission check happens after generation instead of before it.&lt;/p&gt;

&lt;p&gt;The last line is the part I care about most. A full-schema pipeline detects &lt;strong&gt;none&lt;/strong&gt; of them, because an empty result is its only signal and the empty result is indistinguishable from the honest one. A pipeline that scopes the schema by caller identity detects &lt;strong&gt;all ten&lt;/strong&gt;, for free, before any SQL is written: the table the answer needs is simply not in the set this caller may see, so the system knows the question is unanswerable and can say so.&lt;/p&gt;

&lt;p&gt;That is not a smarter model. It is the same information, one step earlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you have no gold labels
&lt;/h2&gt;

&lt;p&gt;Most databases do not have them, and a useful answer is still available.&lt;/p&gt;

&lt;p&gt;For each restricted table, build a probe question out of that table's &lt;em&gt;own&lt;/em&gt; words -- its name, its hint, its description -- and ask whether an unscoped selection puts it in the top k. If it does, then a question phrased the way your schema describes itself lands on a table the caller cannot read, and a full-schema pipeline will write SQL against it.&lt;/p&gt;

&lt;p&gt;On the same schema, all five restricted tables come back at &lt;strong&gt;rank 1&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The probe is generated from the schema rather than written by hand, deliberately. A hand-written probe proves only that its author could think of a question, which makes the result a property of the author.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug I shipped in the first draft
&lt;/h2&gt;

&lt;p&gt;Worth writing down, because it is the failure this whole area keeps producing.&lt;/p&gt;

&lt;p&gt;My first reachability run reported &lt;strong&gt;0 of 5 reachable&lt;/strong&gt;. Total confidence, clean output, completely wrong.&lt;/p&gt;

&lt;p&gt;The cause: I ran the probe with &lt;code&gt;principal=None&lt;/code&gt;. But &lt;code&gt;None&lt;/code&gt; is not "unscoped" -- a caller with no roles is &lt;em&gt;denied&lt;/em&gt;, because absence of a role is absence of permission. So the probe removed exactly the tables it was looking for, and reported that nothing was reachable.&lt;/p&gt;

&lt;p&gt;A measurement whose apparatus deletes the thing being measured will report zero, every time, with no error. Which is the same shape as the bug the tool exists to find. There is now a named regression test for it, and every test in the suite is paired: one case where the metric must be zero, one where it must not, so the metric can be shown to move at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not claim
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A reachability "no" is weak evidence.&lt;/strong&gt; It means a table is hard to reach by its own vocabulary, not that no question reaches it. Only a gold set gives you a rate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No rate here is a model score.&lt;/strong&gt; Whether a particular LLM picks the denied table on a given run is a different question. This measures whether the path exists, which is the part you can actually fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A bad label is not a finding.&lt;/strong&gt; Gold tables the catalog has never heard of are reported separately and excluded from every rate, so a typo cannot inflate your number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This is not a security control.&lt;/strong&gt; It is a measurement. Your database's grants and RLS policies remain the thing that enforces access. What scoping buys you is that the model stops generating queries RLS then has to reject -- which is where the silence comes from.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;The whole measurement is about fifteen lines on top of schemagate, which is the thing that already knows your catalogue and its roles. Nothing else is needed and no model is called:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;schemagate&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Catalog&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Principal&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;schemagate.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;unanswerable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;catalog&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Questions whose gold tables include one this caller may not read.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;leaf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rsplit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;denied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;leaf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_docs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
              &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;needs&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;gold&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;leaf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;needs&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;denied&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# your database, your roles, your labelled questions
&lt;/span&gt;&lt;span class="n"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Catalog&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;bootstrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql://host/db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;restrict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hr_compensation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payroll&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;gold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;salary and pay grade by employee&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hr_compensation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hr_employee&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;headcount per department&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v_employee_headcount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})]&lt;/span&gt;

&lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;unanswerable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Principal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;okta:analyst&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;frozenset&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;silent-denial rate: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gold&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; = &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gold&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the two questions above that prints &lt;code&gt;1/2 = 50.0%&lt;/code&gt; for the analyst and &lt;code&gt;0/2 = 0.0%&lt;/code&gt; for a caller holding &lt;code&gt;payroll&lt;/code&gt;, which is the sanity check worth running first: if the number does not move when you change the roles, it is not measuring the roles.&lt;/p&gt;

&lt;p&gt;The fuller version I ran for the table -- multiple callers, the unlabelled reachability mode, and the guard that stops a mistyped gold label inflating the rate -- is a few hundred lines around that core. Say so in the comments if you want it packaged and I will put it on PyPI; I would rather know someone will run it than publish another thing nobody installs.&lt;/p&gt;

&lt;p&gt;If you want the fix rather than the measurement, the scoping in that last column is what &lt;a href="https://github.com/ashishsinha1602/schemagate" rel="noopener noreferrer"&gt;schemagate&lt;/a&gt; does -- it takes the caller's identity at the schema-selection step instead of filtering rows afterwards. &lt;code&gt;pip install schemagate&lt;/code&gt;, Apache-2.0.&lt;/p&gt;

&lt;p&gt;I would genuinely like to see this number from a real warehouse rather than a demo schema. If you run it on yours, post what you get -- especially if it is 0%, because I would like to know what a role model that avoids this looks like.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>ai</category>
      <category>database</category>
      <category>security</category>
    </item>
    <item>
      <title>My retrieval counted the same text three times</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Sat, 19 Sep 2026 14:49:19 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/my-retrieval-counted-the-same-text-three-times-4pd8</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/my-retrieval-counted-the-same-text-three-times-4pd8</guid>
      <description>&lt;p&gt;A week ago I published a measurement: I had generated descriptions for 1,245 database objects to make retrieval better, and retrieval got worse. The post did well by my standards. Thirty-seven comments, several from people who had seen the same thing in their own systems.&lt;/p&gt;

&lt;p&gt;The measurement was real. The conclusion I drew from it was wrong, and this week I found out why. It was my code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers said
&lt;/h2&gt;

&lt;p&gt;Spider 2.0-lite is a text-to-SQL benchmark built from real BigQuery and Snowflake databases, and unlike the older benchmarks it ships the warehouses' own documentation. That makes it the one place I could test the claim on somebody else's prose rather than my generator's.&lt;/p&gt;

&lt;p&gt;So I deleted the descriptions and re-ran. Removing them won at every cut, on both embedders, five of six cells significant, and the widest cell was &lt;strong&gt;thirteen questions fixed against one broken&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Read on its own, that table says &lt;em&gt;delete your descriptions&lt;/em&gt;. Which should have been the tell. A design whose whole premise is that prose helps should not lose to deleting the prose. The question was not whether to believe the table. It was what the table was pointing at.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it was pointing at
&lt;/h2&gt;

&lt;p&gt;The same column comment was being indexed three times.&lt;/p&gt;

&lt;p&gt;It went into &lt;code&gt;_prose_text&lt;/code&gt;, which feeds the prose channel. It also went into &lt;code&gt;embed_text()&lt;/code&gt; — which feeds &lt;strong&gt;the body channel and the vectors&lt;/strong&gt;. Three of the four ranking signals, all carrying the same words.&lt;/p&gt;

&lt;p&gt;A Spider 2.0 table carries a description per column. So any table with many columns matched on three of four channels for any question that shared a single word with any one of its columns. The widest tables became magnets. They crowded out the narrow, correct table on question after question.&lt;/p&gt;

&lt;p&gt;Deleting the descriptions "helped" because it removed two thirds of a triple-count. It was never evidence about prose. It was evidence about my scoring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decomposing it
&lt;/h2&gt;

&lt;p&gt;The useful thing about a triple-count is that you can take it apart one channel at a time. Same 212 questions, one source removed per run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;remove column comments from the &lt;strong&gt;prose channel&lt;/strong&gt; alone → recovers &lt;strong&gt;4 questions&lt;/strong&gt; at k=10&lt;/li&gt;
&lt;li&gt;remove them from &lt;strong&gt;&lt;code&gt;embed_text()&lt;/code&gt;&lt;/strong&gt; alone → recovers &lt;strong&gt;11 questions&lt;/strong&gt; at k=10&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Eleven of the fifteen were in the embedding path. That is where the damage was, and it is the one I would not have guessed — the prose channel was the obvious suspect because it is the one named after prose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and the constraint that shaped it
&lt;/h2&gt;

&lt;p&gt;Take column comments out of the body channel and the prose channel. &lt;strong&gt;Leave &lt;code&gt;embed_text()&lt;/code&gt; alone.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That last part is not laziness. The vectors are a published, pinned guarantee: change what goes into them and every stored index built by anyone using the library silently becomes wrong. A retrieval fix that invalidates your users' indexes is not a fix, it is a migration you didn't announce. So the damage gets removed from the two channels that can change freely, and the embedding path — where most of the effect lived — is left byte-identical.&lt;/p&gt;

&lt;p&gt;It works anyway. Re-run paired, same 212 questions, with the fix in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  k    with prose        without prose      b   c     p
  5    143/212 67.5%     143/212 67.5%      6   6   1.0000
  10   177/212 83.5%     177/212 83.5%      2   2   1.0000
  20   188/212 88.7%     187/212 88.2%      2   3   1.0000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thirteen-to-one became two-to-two. The penalty is gone. Descriptions now neither help nor hurt on this benchmark — which is the floor a fielded design was supposed to guarantee and mine did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  A second bug, found on the way
&lt;/h2&gt;

&lt;p&gt;While sampling the raw files I found that &lt;code&gt;description&lt;/code&gt; in Spider 2.0's schema JSON is not a table description at all. It is a &lt;strong&gt;per-column list&lt;/strong&gt;, aligned by index to &lt;code&gt;nested_column_names&lt;/code&gt; when a table has nested fields and to &lt;code&gt;column_names&lt;/code&gt; otherwise, entries sometimes null and never a string — 150 of 150 sampled. My loader had been joining that list into a paragraph and indexing it as a table description.&lt;/p&gt;

&lt;p&gt;The same loader built columns from &lt;code&gt;column_names&lt;/code&gt; alone, so a nested table — gnomAD's &lt;code&gt;v3_genomes__chr7&lt;/code&gt;, 61 top-level columns and 181 flattened — arrived missing the very columns its gold SQL reads.&lt;/p&gt;

&lt;p&gt;Fixing that moved the resolvable comparison set from 203 questions to 212, and k=20 from 67.2% to 71.3% over the 247-question denominator. It also moved k=10 &lt;strong&gt;down&lt;/strong&gt;, 77.8% to 77.4%, because the nine questions that joined are harder than the average of the 203. Both directions recorded, because quoting only the cut that rose is how you end up publishing the last thing I published.&lt;/p&gt;

&lt;p&gt;The format question is filed upstream as a documentation request rather than a data defect, because the files are consistent — the field name just doesn't mean what it looks like: &lt;a href="https://github.com/xlang-ai/Spider2/issues/222" rel="noopener noreferrer"&gt;Spider2 issue #222&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take from this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A measurement can be completely true and still not mean what you think.&lt;/strong&gt; Mine was reproducible, it replicated across embedders, and the direction was stable. All of that was real. None of it made the conclusion right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If a result argues against your design's core premise, suspect the implementation before the premise.&lt;/strong&gt; Not because premises are always right, but because you can check the implementation in an afternoon and the premise takes a research programme.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch for one field reaching more than one ranking signal.&lt;/strong&gt; This is the generic version and it is easy to do accidentally: a field that goes into a lexical channel and also into the text you embed is counted twice, and if a third channel derives from the same text, three times. Nothing errors. Your tests pass. Retrieval just quietly prefers whatever documents have the most of that field.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix should not invalidate your users' indexes.&lt;/strong&gt; If it has to, that is a version bump and a note, not a patch release.&lt;/p&gt;

&lt;p&gt;Shipped in 0.1.57. The full McNemar grid, the channel decomposition and the re-measured numbers are in BENCHMARKS.md in the repo, along with the withdrawn claim, which stays visible rather than deleted.&lt;/p&gt;

</description>
      <category>rag</category>
      <category>python</category>
      <category>ai</category>
      <category>database</category>
    </item>
    <item>
      <title>Replacing an AWS Glue ingestion job with DBMS_CLOUD_PIPELINE in Autonomous Database: lessons learned</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Thu, 17 Sep 2026 16:59:00 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/replacing-an-aws-glue-ingestion-job-with-dbmscloudpipeline-in-autonomous-database-lessons-learned-32od</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/replacing-an-aws-glue-ingestion-job-with-dbmscloudpipeline-in-autonomous-database-lessons-learned-32od</guid>
      <description>&lt;p&gt;Our pipeline ran Mongo → S3 parquet → Glue/Spark → Iceberg → Oracle ADB. Autonomous Database can read parquet from S3 natively, so the Spark step was mostly paying to move files.&lt;/p&gt;

&lt;p&gt;We replaced it with this:&lt;/p&gt;

&lt;p&gt;S3 parquet → DBMS_CLOUD_PIPELINE → landing table (all VARCHAR2) → PL/SQL MERGE every 5 min → curated table&lt;/p&gt;

&lt;p&gt;It now covers 13 collections and 24 feeds, with tables up to ~30M rows. Row counts match Iceberg, freshness is about 10 minutes, and no Spark is involved.&lt;/p&gt;

&lt;p&gt;Things that caught us, in case they save someone time:&lt;/p&gt;

&lt;p&gt;The pipeline load is positional, with an exact column count. Schemaless sources produce files with different column sets, which fail with ORA-00913 or ORA-00947. We land every column as VARCHAR2(4000) and apply types by name in the MERGE.&lt;br&gt;
TO_DATE('15-SEP-26','YYYY-MM-DD') returns year 0015 with no error. Classify each value's shape with a regex first, then parse it with FX.&lt;br&gt;
A pipeline's location can't change while it runs, and a reset forgets which files were loaded. Regex locations loaded 0 files. Wildcards picked up every older matching folder. What worked was creating one pipeline per date folder ahead of time (tomorrow's at 22:00) and dropping old ones after a grace period.&lt;br&gt;
Check completeness by outcome. Every 15 minutes, list the S3 files, compare them with the files the pipeline recorded as loaded, and load any missing file by name.&lt;br&gt;
Full re-pulls re-stamp unchanged records. Guard updates and deletes on version, and reconcile on content rather than timestamps.&lt;/p&gt;

&lt;p&gt;Is anyone else using DBMS_CLOUD_PIPELINE for date-partitioned S3 folders? I'm curious whether there's a cleaner pattern than one pipeline per day.&lt;/p&gt;

</description>
      <category>oci</category>
      <category>aws</category>
      <category>dataengineering</category>
      <category>oracle</category>
    </item>
    <item>
      <title>"Money we gave back to shoppers" matches no table name. Four searches find it anyway.</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Thu, 17 Sep 2026 05:08:50 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/money-we-gave-back-to-shoppers-matches-no-table-name-four-searches-find-it-anyway-5h7m</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/money-we-gave-back-to-shoppers-matches-no-table-name-four-searches-find-it-anyway-5h7m</guid>
      <description>&lt;p&gt;Someone asks your text-to-SQL agent: &lt;strong&gt;"money we gave back to shoppers."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The table that answers it is called &lt;code&gt;billing_credit_note&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Those two strings share no word. Not a low score — zero overlap. Any retriever that works on table names is blind here, and the schema has 42 tables, so guessing is not an option either.&lt;/p&gt;

&lt;p&gt;Here is what four separate searches do with that question, measured on a real run rather than described.&lt;/p&gt;

&lt;h2&gt;
  
  
  One question, four rankings
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;names&lt;/strong&gt; — BM25 over table and column names only.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nothing matched
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero. "money", "gave", "back", "shoppers" appear in no identifier in the schema. This is the channel that scores 100% recall@6 when people phrase questions in the schema's own vocabulary, and it contributes exactly nothing to this one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;prose&lt;/strong&gt; — BM25 over the generated descriptions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1  v_supplier_spend
2  billing_credit_note      &amp;lt;- the answer
3  v_monthly_revenue
4  sup_supplier
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second place. The description reads &lt;em&gt;"Records refunds or corrections issued against a specific invoice."&lt;/em&gt; The word &lt;code&gt;refunds&lt;/code&gt; is the bridge from "gave back" to &lt;code&gt;credit_note&lt;/code&gt; — a bridge that exists nowhere in the DDL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;vector&lt;/strong&gt; — embedding similarity.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1  v_monthly_revenue
2  v_supplier_spend
3  ship_carrier
...
22 billing_credit_note
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty-second. It got the neighbourhood right — revenue, spend, money-shaped things — and the specific table wrong. That is the characteristic dense-retrieval failure, and the reason it is hard to debug: there is no per-term number to inspect. You just get a ranking that feels slightly off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;body&lt;/strong&gt; — BM25 over everything concatenated: names, columns, types, comments, descriptions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1  billing_credit_note
2  v_supplier_spend
3  v_monthly_revenue
4  sup_supplier
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First. Best of the four here — and also the exact configuration that collapsed on a 1,245-object schema, because with everything in one bag the descriptions dilute the identifiers. More on that below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Merging them: rank, not score
&lt;/h2&gt;

&lt;p&gt;You cannot add these scores together. A cosine distance and a BM25 score are different units; 0.83 plus 14.2 is not a number that means anything.&lt;/p&gt;

&lt;p&gt;Positions are comparable, so positions are what get added. Reciprocal Rank Fusion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;contribution&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;K&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="n"&gt;K&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First place pays 1/61, second 1/62, twenty-second 1/82, absent pays nothing. For &lt;code&gt;billing_credit_note&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;names    absent    0.00000
prose    2nd       0.01613
vector   22nd      0.01220
body     1st       0.01639
                   -------
                   0.04472   -&amp;gt; top 6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important line is the first one. &lt;strong&gt;A channel that is completely blind costs the answer nothing.&lt;/strong&gt; It contributes zero and the other three carry it. That is the entire argument for having four: they are not redundant, they fail on different questions, and it is rare for all four to miss at once.&lt;/p&gt;

&lt;p&gt;The cost of rank-based fusion is the mirror image, and a commenter on my last post caught it: a channel that ranked things for a &lt;em&gt;meaningless&lt;/em&gt; reason still votes at full strength. If every description contains the word "about", every object ties in the prose channel, and the tie is broken by whatever order the catalog was built in. I reproduced that — shuffling the insertion order changes which tables come back on 1 to 2 questions out of 12. Recall does not move; the prompt does. Both fixes he proposed are right, and both are now filed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just concatenate everything
&lt;/h2&gt;

&lt;p&gt;This is the question I got wrong first, and it cost me a public retraction.&lt;/p&gt;

&lt;p&gt;Putting names and descriptions in one bag seems obviously better — more text, more to match on. Measured on the same fixtures, by someone else, from the published wheel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;identifiers only          29/52   55.8%
one flat bag              41/52
prose as its own channel  48/52
shipped configuration     49/52   94.2%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flat bag beats identifiers alone. It is also &lt;strong&gt;seven questions worse&lt;/strong&gt; than keeping prose in its own index. Inside one bag, the sentences compete with the identifiers for term frequency, and the identifiers lose because there are more words of prose than there are words of name.&lt;/p&gt;

&lt;p&gt;At 1,245 objects it stops being a seven-question gap and becomes a failure. Every description written by the same model in the same voice put the token &lt;code&gt;contact&lt;/code&gt; into roughly 1,072 of 1,245 documents. Its IDF went from 4.27 — computed over names, where it appears in 17 of 1,245 — to 0.15. The word users actually type had become a stopword. The table literally called &lt;code&gt;contacts&lt;/code&gt; fell from rank 3 to below rank 40 for the question "show the contacts of xmagnet".&lt;/p&gt;

&lt;p&gt;The descriptions were accurate. I read them. Accurate text can still be index poison, because IDF is a property of the corpus, not of the sentence.&lt;/p&gt;

&lt;p&gt;Fielded scoring fixes it for a structural reason rather than a tuning reason: in the names index, &lt;code&gt;contact&lt;/code&gt; is still 17 of 1,245 and still worth 4.27. The prose can be as repetitive as it likes over in its own index without touching that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs and what it buys
&lt;/h2&gt;

&lt;p&gt;That same question, end to end on the 42-object schema: 6 ranked tables plus 3 pulled in by foreign-key closure, 490 tokens instead of 2,205 for the whole schema.&lt;/p&gt;

&lt;p&gt;And the honest limit, because the comments on the last post will find it otherwise: 100% recall@6 when questions use the schema's vocabulary, 46–86% depending on schema when they do not. Fielded fusion narrows that gap. It does not close it.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://github.com/ashishsinha1602/schemagate" rel="noopener noreferrer"&gt;schemagate&lt;/a&gt; is Apache-2.0. Every number above is from &lt;code&gt;tests/run_paraphrase_eval.py&lt;/code&gt; and the checked-in fixtures.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>ai</category>
      <category>database</category>
      <category>python</category>
    </item>
    <item>
      <title>Seven times my test harness reported success while measuring nothing</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Thu, 17 Sep 2026 04:15:59 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/seven-times-my-test-harness-reported-success-while-measuring-nothing-5dgn</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/seven-times-my-test-harness-reported-success-while-measuring-nothing-5dgn</guid>
      <description>&lt;p&gt;Last week a stranger reproduced my benchmarks. Over four days he found six defects. Every single one of them had been reporting success.&lt;/p&gt;

&lt;p&gt;That is the part worth writing down. Not that I had bugs — everyone has bugs. That my instrumentation, six separate times, returned a number that looked fine and meant nothing. A crash is loud. A harness that prints &lt;code&gt;86.1%&lt;/code&gt; when it measured the wrong thing is not, and I published every one of those numbers.&lt;/p&gt;

&lt;p&gt;Here they are, in the order they were found.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;except Exception: continue&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The ablation loader walked a Spider 2.0 checkout and read each table's JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rglob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Windows, &lt;code&gt;open()&lt;/code&gt; refuses a path over 260 characters. 2,868 of the 7,892 paths in that checkout are over 260 characters. The &lt;code&gt;except&lt;/code&gt; caught &lt;code&gt;FileNotFoundError&lt;/code&gt; and counted every one of those tables as a table that does not exist.&lt;/p&gt;

&lt;p&gt;So the benchmark ran against a database missing roughly 39% of its tables, and reported &lt;code&gt;86.1%&lt;/code&gt; recall at k=20. A smaller schema is an easier schema. The number was not wrong because the retrieval was bad; it was wrong because the corpus was a third smaller than the corpus I said it was.&lt;/p&gt;

&lt;p&gt;The detail that makes this worse: &lt;strong&gt;the shortfall depends on where you cloned the repo.&lt;/strong&gt; A 182-character root gives 2,868 unreadable files. A 183-character root gives 3,056. The benchmark's result was a function of the reviewer's directory layout, and nothing in the output said so.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A comment that had been lying for a day and a half
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Bounded, and only ever additive -- it cannot displace a ranked pick.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forty lines below it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chosen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chosen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;chosen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pinned&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;covers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;taken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;discard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chosen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;qname&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;del&lt;/span&gt; &lt;span class="n"&gt;chosen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It displaces a ranked pick. It has since a commit I made a day and a half earlier. I had quoted the comment in a public reply as though it were the behaviour, because I read the comment instead of the code directly above and below it.&lt;/p&gt;

&lt;p&gt;Comments do not have tests. This one shipped in three releases.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. An enum missing a value the code writes
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Scored.reason&lt;/code&gt; documented five values: &lt;code&gt;hybrid | vector | lexical | fk | pinned&lt;/code&gt;. The selector writes a sixth, &lt;code&gt;covers&lt;/code&gt;. Anyone branching on that field — and the whole point of the field is that you branch on it — would silently fall through on one case in six.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A check with power zero by construction
&lt;/h2&gt;

&lt;p&gt;He claimed 52 of 52 questions had a step where the returned-set size decreases as K grows. My check returned 0 of 52, and I was one keystroke from posting that as a contradiction.&lt;/p&gt;

&lt;p&gt;My check compared &lt;code&gt;returned(K)&lt;/code&gt; against &lt;code&gt;returned(K−1)&lt;/code&gt;. Top-K picks are nested and both expansion passes are unions, so &lt;code&gt;returned&lt;/code&gt; cannot decrease in K. &lt;strong&gt;The test could never fire.&lt;/strong&gt; It was not a disagreement with his result; it was a test with no power, returning the only answer it was capable of returning.&lt;/p&gt;

&lt;p&gt;Comparing the overages instead of the totals: 52 of 52, exactly as he had it.&lt;/p&gt;

&lt;p&gt;This is the one that still bothers me. A test that returns 0 looks like evidence of absence. It is indistinguishable, from the outside, from a test that measured something.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The aggregate that was never printed
&lt;/h2&gt;

&lt;p&gt;The held-out evaluation built six fixtures. One of them declares three extra schemas and puts four objects in them — three tables all named &lt;code&gt;account&lt;/code&gt;, plus a view joining across two. That name collision is most of what the fixture exists to test.&lt;/p&gt;

&lt;p&gt;The harness opened a bare SQLite connection with no &lt;code&gt;ATTACH&lt;/code&gt;, so &lt;code&gt;executescript&lt;/code&gt; raised &lt;code&gt;unknown database "billing"&lt;/code&gt; on the first of those statements and the run died there. After the five per-schema rows had printed. Before the &lt;code&gt;TUNE&lt;/code&gt; / &lt;code&gt;HELD OUT&lt;/code&gt; / &lt;code&gt;OVERALL&lt;/code&gt; lines.&lt;/p&gt;

&lt;p&gt;The file exists to produce an aggregate. It had never printed one. Five plausible rows of output scrolled past every time and I read them as the run having worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The fix that turned a crash into a silence
&lt;/h2&gt;

&lt;p&gt;This is the one I would most like you to take away.&lt;/p&gt;

&lt;p&gt;The obvious repair is to &lt;code&gt;ATTACH DATABASE ':memory:'&lt;/code&gt; before running the DDL. It works. The script completes, the harness prints its aggregate, exit code 0.&lt;/p&gt;

&lt;p&gt;In-memory databases are dropped when the connection closes. The harness closes that connection and &lt;code&gt;bootstrap&lt;/code&gt; opens a new one. So:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;256 objects indexed, all in `main`
objects named `account`: none
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three same-named tables and the cross-schema view — the hardest case in the fixture, the reason the fixture is there — are still absent. The difference is that now nothing crashes. &lt;strong&gt;The repair converted a loud failure into a quiet one&lt;/strong&gt;, and both of us verified the repair by reading a number that the repair had not changed.&lt;/p&gt;

&lt;p&gt;File-backed databases attached on a connect listener, with the schema list passed to &lt;code&gt;bootstrap&lt;/code&gt;, gives 260 objects — main 256, billing 2, crm 1, sec 1. Recall did not move. The measurement did.&lt;/p&gt;

&lt;h2&gt;
  
  
  And one the reviewer didn't find
&lt;/h2&gt;

&lt;p&gt;While writing this I checked the benchmark script that guards my README. Its last line is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bench OK: README claims hold
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gate above that line checks recall thresholds and a 70% token-reduction floor. It does not check the token count the README prints (the README said 2,583; the script prints 2,812). It does not check the 260-object row at all — that schema is never built by that script. It asserted that the README holds, while not reading most of the README.&lt;/p&gt;

&lt;p&gt;Seventh.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;Every one of these reported success or absence for a reason unrelated to the thing being measured.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an exception handler that turned &lt;em&gt;missing&lt;/em&gt; into &lt;em&gt;does not exist&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;a comment that described code that had changed&lt;/li&gt;
&lt;li&gt;an enum that described code that had grown&lt;/li&gt;
&lt;li&gt;a comparison that was monotone and therefore constant&lt;/li&gt;
&lt;li&gt;a crash sequenced after the output that made it look like a run&lt;/li&gt;
&lt;li&gt;a fix that replaced a crash with an absence&lt;/li&gt;
&lt;li&gt;a gate whose message claimed more than its assertions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these produce an error. All of them produce a number. That is the shape: &lt;strong&gt;the failure mode of a measurement is not a wrong answer, it is a plausible one.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The test I have taken from him is mechanical, and I recommend it. &lt;em&gt;Sweep the apparatus.&lt;/em&gt; Change the K grid, the predicate, the clamp, the domain, the platform. If the number moves, you measured your apparatus. If it holds, you may publish it.&lt;/p&gt;

&lt;p&gt;It caught all seven above. It also caught a number I had sent him two days ago: I reported 22 questions whose ceiling rises on a dense sweep; he measured 24. He was right — I had included a K value that is not in the published grid. Sweeping four grids gives 22, 23, 24, 29. The count is a property of the grid, not of the system, so the curve is what gets published and the count does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I am writing this instead of quietly fixing it
&lt;/h2&gt;

&lt;p&gt;Because the numbers were public, and a correction that is not as visible as the claim is not a correction.&lt;/p&gt;

&lt;p&gt;And because the thing that produced all seven fixes was not my discipline. It was a stranger on the internet with no stake in the project, who reproduced every figure before disagreeing with any of it, and who kept going for four days. I have never had review like that from a paid reviewer.&lt;/p&gt;

&lt;p&gt;If you maintain something and somebody turns up to check your arithmetic: that is the most valuable thing that will happen to your project this year. Give them everything they ask for.&lt;/p&gt;




&lt;p&gt;The library is &lt;a href="https://github.com/ashishsinha1602/schemagate" rel="noopener noreferrer"&gt;schemagate&lt;/a&gt; — identity-scoped schema selection for text-to-SQL. Every figure above is reproducible from the repo; the benchmark file records what was withdrawn as well as what held.&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>python</category>
      <category>softwareengineering</category>
      <category>testing</category>
    </item>
    <item>
      <title>I audited all 25,125 servers in the MCP registry</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Wed, 16 Sep 2026 03:43:47 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/i-audited-all-25125-servers-in-the-mcp-registry-607</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/i-audited-all-25125-servers-in-the-mcp-registry-607</guid>
      <description>&lt;p&gt;Every MCP directory, every "browse servers" page, every agent that discovers tools at runtime reads from the same place: the Model Context Protocol registry. I pulled a snapshot of it on 4 September 2026 — &lt;strong&gt;25,125 servers&lt;/strong&gt; — and ran an integrity audit over the whole thing.&lt;/p&gt;

&lt;p&gt;The interesting part isn't what I found. It's the number I nearly reported.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually in there
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;25,125 servers
  24,863 active · 262 deprecated

904 servers (3.6%)  share a description identically with another server
1,252 (4.98%)       are near-duplicates of each other at Jaccard &amp;gt;= 0.7
12 records          repeat a name (see the correction below)
0                   are identical across every field the audit evaluates
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is the one that matters, and I'll come back to why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number I nearly led with
&lt;/h2&gt;

&lt;p&gt;My own tool's summary says this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"schema_issues"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;101383&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A hundred thousand schema issues in a registry of twenty-five thousand servers. That is a headline. It is also, as reported, meaningless.&lt;/p&gt;

&lt;p&gt;Every one of those 101,383 is a &lt;code&gt;missing_field&lt;/code&gt; on an &lt;strong&gt;optional&lt;/strong&gt; field — &lt;code&gt;repository&lt;/code&gt;, &lt;code&gt;websiteUrl&lt;/code&gt;, &lt;code&gt;packages&lt;/code&gt;. The audit infers the majority type of each field across the corpus and flags records that omit it, which is the right behaviour for a dataset where fields are mandatory and the wrong framing for a registry where they aren't.&lt;/p&gt;

&lt;p&gt;What's true underneath it is duller and more useful:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;title      present in 14,384 of 25,125   (10,741 have none)
remotes    present in 13,866 of 25,125   (11,259 have none)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So: &lt;strong&gt;a bit under half the registry has no title, and a bit under half declares no remote.&lt;/strong&gt; That's a completeness observation about a young registry, not a corruption finding. "101,383 schema issues" would have been the more shareable sentence and it would have been wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "0 identical across every field" is the real result
&lt;/h2&gt;

&lt;p&gt;The audit separates near-duplicate clusters into two kinds: records that match on &lt;em&gt;every&lt;/em&gt; evaluated field, and records that share a text stem but differ elsewhere.&lt;/p&gt;

&lt;p&gt;In the MCP registry, 487 clusters covering 1,252 servers come back as near-duplicates on description. &lt;strong&gt;Zero of them are identical across every field.&lt;/strong&gt; They're servers doing similar things, described similarly — &lt;code&gt;A Model Context Protocol server for X&lt;/code&gt; repeated across many X — with different names, versions and packages.&lt;/p&gt;

&lt;p&gt;Which is exactly what you'd expect from a registry, and exactly what a naive duplicate count would report as a 5% redundancy problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is a general failure, not an MCP one
&lt;/h2&gt;

&lt;p&gt;I hit the same gap measuring benchmark datasets, which is what the tool was written for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spider's training split&lt;/strong&gt;: a naive text-similarity pass reports &lt;strong&gt;77 duplicate clusters&lt;/strong&gt;. Only &lt;strong&gt;8&lt;/strong&gt; are real. The other 69 share a question stem while differing in the reference SQL, the database, or both — one phrasing deliberately reused against different schemas. Reporting the raw count overstates by roughly &lt;strong&gt;9x&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BIRD-CRITIC&lt;/strong&gt;: 4 clusters flagged, &lt;strong&gt;1&lt;/strong&gt; genuine. Overstated by &lt;strong&gt;4x&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Same shape every time. The obvious implementation of a duplicate check counts &lt;em&gt;questions that look alike&lt;/em&gt;. The thing you actually care about is &lt;em&gt;records a model could bank the same answer twice for&lt;/em&gt;, which requires the answer to match too.&lt;/p&gt;

&lt;p&gt;Cross-split contamination has the identical problem. Counting identical questions between Spider's train and dev splits says 6 dev items are contaminated. Additionally requiring the reference SQL to match says &lt;strong&gt;2&lt;/strong&gt;, out of 1,034 — and both are trivial &lt;code&gt;SELECT count(*)&lt;/code&gt; questions landing on coincidentally same-named tables in entirely different databases. Reporting the first number would have framed it as three times worse than it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't say
&lt;/h2&gt;

&lt;p&gt;The audit uses MinHash-LSH above 3,000 records, and LSH offers no recall guarantee, so the duplicate counts here are &lt;strong&gt;lower bounds&lt;/strong&gt;. Every report states which method produced it.&lt;/p&gt;

&lt;p&gt;Similarity is computed over the description field only. The snapshot is from 4 September 2026 and the registry has grown since. &lt;strong&gt;Correction, added after publishing.&lt;/strong&gt; I originally wrote that "12 servers share a name" was worth the registry looking at. It isn't, and I should have checked before saying so.&lt;/p&gt;

&lt;p&gt;The registry stores &lt;strong&gt;one row per version&lt;/strong&gt;. Querying the live API: a 100-row page contains 62 distinct names, 24 of which recur — &lt;code&gt;ac.inference.sh/mcp&lt;/code&gt; appears four times at 1.0.0, 1.0.1, 2.0.0 and 2.0.1, all active. A repeated name is the data model, not a collision.&lt;/p&gt;

&lt;p&gt;Which makes this the second raw number in the same audit that meant something other than what it looked like, in a post arguing that raw numbers mean something other than what they look like. The snapshot I measured appears to have been reduced to one row per name already, so the 12 are worth understanding before they are worth reporting — and I have not done that work.&lt;/p&gt;

&lt;p&gt;I'm not claiming the MCP registry has a quality problem. I'm claiming it now has a measured baseline, which it didn't before, and that the first number my own tool handed me would have misrepresented it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;Pure standard library except for fetching:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/ashishsinha1602/dataset-integrity-audit
&lt;span class="nb"&gt;cd &lt;/span&gt;dataset-integrity-audit
pip &lt;span class="nb"&gt;install &lt;/span&gt;datasets

python audit.py prep &lt;span class="nt"&gt;--data&lt;/span&gt; mcp-latest.jsonl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--text-field&lt;/span&gt; description &lt;span class="nt"&gt;--answer-field&lt;/span&gt; name &lt;span class="nt"&gt;--group-field&lt;/span&gt; _status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;prepared/REPORT.md&lt;/code&gt; gives you the table. Point &lt;code&gt;--text-field&lt;/code&gt; and &lt;code&gt;--answer-field&lt;/code&gt; at any two columns and it works on any dataset — there's a &lt;a href="https://huggingface.co/spaces/Ashsinha1/dataset-integrity-auditor" rel="noopener noreferrer"&gt;hosted version&lt;/a&gt; if you'd rather not install anything.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/ashishsinha1602/dataset-integrity-audit" rel="noopener noreferrer"&gt;github.com/ashishsinha1602/dataset-integrity-audit&lt;/a&gt;, MIT. Every number above is in &lt;code&gt;prepared/&lt;/code&gt; as committed JSON, so you can check any of them without re-running anything.&lt;/p&gt;




&lt;p&gt;If there's one thing to take from this: when a duplicate check hands you a big number, find out how many of those records are identical &lt;strong&gt;everywhere&lt;/strong&gt; before you quote it. On three different corpora that ratio has been between 4x and 9x, always in the direction that makes the problem look worse than it is.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>opensource</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Vanna is archived. The failure mode none of the replacements fix.</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Tue, 15 Sep 2026 04:11:40 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/vanna-is-archived-the-failure-mode-none-of-the-replacements-fix-4l3i</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/vanna-is-archived-the-failure-mode-none-of-the-replacements-fix-4l3i</guid>
      <description>&lt;p&gt;Someone in finance asks your text-to-SQL agent what the compensation band is for a role they're hiring for. The agent writes correct SQL. The query runs. Row-level security strips every row. The user is told:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;No records found.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That answer is wrong, and it is wrong in the worst available way. There &lt;em&gt;are&lt;/em&gt; records. The user simply isn't allowed to see them. Nothing in the chain can tell the difference between "this data does not exist" and "you may not have this data" — so the system picks the first and says it with confidence.&lt;/p&gt;

&lt;p&gt;Your logs show a successful query. Your RLS policy shows it working exactly as configured. Everybody's dashboard is green, and the person who asked now believes something false about your company.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this comes from
&lt;/h2&gt;

&lt;p&gt;It comes from the order of operations, and almost every text-to-SQL stack has the same order.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;question → pick tables → write SQL → execute → apply row-level security → answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Access control is the second-to-last step. By the time it runs, the model has already seen the table, already reasoned about its columns, already written a query that names it. RLS is doing its job — it protects the &lt;em&gt;data&lt;/em&gt;. It cannot protect the &lt;em&gt;answer&lt;/em&gt;, because the answer was shaped four steps earlier by a model that had no idea the table was off-limits.&lt;/p&gt;

&lt;p&gt;This is not a Vanna problem. It's the default shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which matters now, because Vanna is archived
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/vanna-ai/vanna" rel="noopener noreferrer"&gt;vanna-ai/vanna&lt;/a&gt; was archived on 29 March 2026 and is read-only. 23.8k stars, 2.5k forks — a lot of people integrated it, and a fair number are now deciding what to move to.&lt;/p&gt;

&lt;p&gt;Vanna 2.0 was a rewrite around user-aware agents: it resolves a &lt;code&gt;User&lt;/code&gt; with group memberships, gates tools on those groups, and applies row-level security when the SQL runs. That's more identity handling than most of its alternatives have. It still has the ordering above.&lt;/p&gt;

&lt;p&gt;So if you're picking a replacement, this is worth checking before you pick: &lt;strong&gt;does the model ever see a table this caller cannot read?&lt;/strong&gt; For nearly everything on the shortlist the answer is yes, and the protection is downstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other order
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;question + who's asking → pick tables THEY can read → write SQL → execute → answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A restricted table isn't ranked lower. It isn't filtered afterwards. It is absent from the prompt. The model cannot write SQL against a table it was never shown, so there's no query to strip rows from and no confident wrong answer to deliver.&lt;/p&gt;

&lt;p&gt;The knock-on effect is the one I didn't expect when I started: when the model &lt;em&gt;can't&lt;/em&gt; reach the data, it says so. "I don't have a table that covers compensation" is a true statement about what it was given, and a user can act on it — ask someone, request access, escalate. "No records found" is a dead end built out of a false premise.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you're migrating
&lt;/h2&gt;

&lt;p&gt;This is the mapping I use. It's deliberately boring:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Vanna&lt;/th&gt;
&lt;th&gt;equivalent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;vn.train(ddl=...)&lt;/code&gt; (1.x) / tools reading a configured DB (2.x)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cat = Catalog().bootstrap("postgresql://…")&lt;/code&gt; — reflects the schema once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vn.train(documentation=...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cat.hint("orders", "…")&lt;/code&gt; — a human note that outranks everything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;User(id=…, group_memberships=[…])&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Principal("okta:jdoe", roles={…})&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a tool's &lt;code&gt;access_groups&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cat.restrict("hr_compensation", ["payroll"])&lt;/code&gt; — on the &lt;em&gt;table&lt;/em&gt;, not the tool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vn.ask(question)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sel = cat.select(question, principal=p)&lt;/code&gt;, then &lt;code&gt;sel.prompt_fragment()&lt;/code&gt; in your prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vn.generate_sql(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;keep whatever model call you already have&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Your &lt;code&gt;User&lt;/code&gt; object drops in more or less unchanged — &lt;code&gt;id&lt;/code&gt; becomes the principal's subject, &lt;code&gt;group_memberships&lt;/code&gt; become its roles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;schemagate&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Catalog&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Principal&lt;/span&gt;

&lt;span class="n"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Catalog&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;bootstrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql://localhost/app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;restrict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hr_compensation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payroll&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;          &lt;span class="c1"&gt;# once, at startup
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;                      &lt;span class="c1"&gt;# per request
&lt;/span&gt;    &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Principal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;okta:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;group_memberships&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;sel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Schema:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;sel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prompt_fragment&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;Question: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whatever generated SQL before still does. It just never sees &lt;code&gt;hr_compensation&lt;/code&gt; unless the caller holds &lt;code&gt;payroll&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Column-level works the same way — &lt;code&gt;cat.restrict_column("employees", "salary", ["payroll"])&lt;/code&gt; — so a table can be visible with a column withheld, which is the common real case.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you give up, honestly
&lt;/h2&gt;

&lt;p&gt;Vanna trained on question/SQL pairs and learned from feedback. This doesn't learn. It reflects the schema and ranks it.&lt;/p&gt;

&lt;p&gt;So: if your accuracy came from a large question–SQL memory, &lt;strong&gt;keep that memory&lt;/strong&gt; and use the identity gate only for the selection step. If your accuracy came from schema documentation, &lt;code&gt;cat.hint()&lt;/code&gt; covers the same ground with less machinery. If it came from the feedback loop, you will miss it, and I'd rather say that here than have you find out in week three.&lt;/p&gt;

&lt;p&gt;Certification status, plainly: live-tested on PostgreSQL 16, Oracle 26ai and SQLite. SQL Server and MySQL reflect but I haven't had a live instance to run them against — not because I expect trouble, but because I haven't done it. There's a &lt;code&gt;certify_dialect.py&lt;/code&gt; script if you want to run it and tell me what happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it without installing anything against your database
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;schemagate
schemagate demo &lt;span class="s2"&gt;"which customers owe us money"&lt;/span&gt; &lt;span class="nt"&gt;--answer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That runs against a bundled 42-object schema. No database to set up. No API key required — without one it prints a prompt you can paste into any chat. There's a &lt;a href="https://ashishsinha1602.github.io/schemagate/" rel="noopener noreferrer"&gt;browser demo&lt;/a&gt; too, no signup.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/ashishsinha1602/schemagate" rel="noopener noreferrer"&gt;github.com/ashishsinha1602/schemagate&lt;/a&gt;, Apache-2.0. It reflects any SQLAlchemy database, and it ships an MCP server, so if your agent lives in Claude Desktop, Cursor or Zed it gets the same per-caller boundary.&lt;/p&gt;




&lt;p&gt;If you're migrating off Vanna and you take one thing from this, let it be the question rather than the library: &lt;strong&gt;when your agent says "no records found", can you tell whether that's true?&lt;/strong&gt; If you can't, that's worth fixing regardless of what you migrate to.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>ai</category>
      <category>database</category>
      <category>python</category>
    </item>
    <item>
      <title>Spider 2.0 deleted a claim I made this morning</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Sun, 13 Sep 2026 04:09:10 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/spider-20-deleted-a-claim-i-made-this-morning-56i9</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/spider-20-deleted-a-claim-i-made-this-morning-56i9</guid>
      <description>&lt;p&gt;title: Spider 2.0 deleted a claim I made this morning&lt;br&gt;
published: true&lt;/p&gt;
&lt;h2&gt;
  
  
  tags: sql, ai, database, python
&lt;/h2&gt;

&lt;p&gt;This morning I wrote that swapping the hashed vectoriser in my schema&lt;br&gt;
retriever for a sentence-transformer was a consistent win. I had numbers:&lt;br&gt;
on a pooled Spider 1.0 catalog, strict recall at k=10 went from 82.6% to&lt;br&gt;
88.1%. Consistent, I said. Larger than my own benchmarks suggested.&lt;/p&gt;

&lt;p&gt;This evening I ran the same comparison on Spider 2.0 and the effect vanished.&lt;/p&gt;

&lt;p&gt;What follows is the run, what I think happened, and the experiment that would&lt;br&gt;
actually settle it, which I have not done yet.&lt;/p&gt;
&lt;h2&gt;
  
  
  Spider 2.0 is the benchmark this problem needed
&lt;/h2&gt;

&lt;p&gt;Spider 1.0's databases have a median of three tables. That is why my&lt;br&gt;
per-database numbers there are 100% and worthless: returning the entire schema&lt;br&gt;
also scores 100%. I had to invent a pooled variant — every database merged into&lt;br&gt;
one 876-table catalog — to make the task resemble retrieval at all, and an&lt;br&gt;
invented variant is exactly the kind of thing a reader is right to discount.&lt;/p&gt;

&lt;p&gt;Spider 2.0-lite needs no such construction. Measured from the distribution:&lt;br&gt;
162 databases, 7,892 tables, a median of 15 tables per database and a maximum&lt;br&gt;
of 785, drawn from real BigQuery and Snowflake warehouses. The databases are&lt;br&gt;
already big.&lt;/p&gt;

&lt;p&gt;spider2-lite ships 547 examples, but only the questions whose gold SQL is&lt;br&gt;
public are usable — the rest is held out — which leaves 158 across 103&lt;br&gt;
databases. Table recall on those:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;top_k&lt;/th&gt;
&lt;th&gt;all gold tables present&lt;/th&gt;
&lt;th&gt;per-table recall&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;70.9%&lt;/td&gt;
&lt;td&gt;81.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;82.9%&lt;/td&gt;
&lt;td&gt;88.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;86.1%&lt;/td&gt;
&lt;td&gt;90.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nothing was tuned for this. I downloaded it, pointed the same code at it, and&lt;br&gt;
those are the numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With the caveat the sample size demands.&lt;/strong&gt; n=158 gives a 95% confidence&lt;br&gt;
interval of roughly [63.4, 77.4] on that 70.9%, and [76.3, 88.0] on the 82.9%.&lt;br&gt;
So the honest comparison with my Spider 1.0 pooled figures is not "within a&lt;br&gt;
point or two" — the intervals are too wide to resolve a point or two. It is&lt;br&gt;
that the two are &lt;em&gt;indistinguishable at this sample size&lt;/em&gt;, on databases an order&lt;br&gt;
of magnitude larger and questions written to be hard. That is still the&lt;br&gt;
interesting result. It is just a weaker sentence than the one I wanted to&lt;br&gt;
write.&lt;/p&gt;
&lt;h2&gt;
  
  
  The claim that died
&lt;/h2&gt;

&lt;p&gt;Spider 1.0 pooled, n=1,034, strict recall at k=10: hashed 82.6%, sentence&lt;br&gt;
model &lt;strong&gt;88.1%&lt;/strong&gt;. That is the number I quoted this morning.&lt;/p&gt;

&lt;p&gt;Spider 2.0-lite, n=158:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;top_k&lt;/th&gt;
&lt;th&gt;hashed&lt;/th&gt;
&lt;th&gt;sentence model&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;70.9%&lt;/td&gt;
&lt;td&gt;71.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;82.9%&lt;/td&gt;
&lt;td&gt;82.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;86.1%&lt;/td&gt;
&lt;td&gt;85.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Better at one k, worse at two, and here is the discipline I failed to apply to&lt;br&gt;
my own good news this morning: &lt;strong&gt;every one of those differences is a single&lt;br&gt;
question.&lt;/strong&gt; 112 versus 113. 131 versus 130. I cannot call that a regression any&lt;br&gt;
more than I could have called it a win. The correct statement is that there is&lt;br&gt;
&lt;em&gt;no measurable difference at n=158&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That still kills "consistent win," which is what I said and what was wrong.&lt;/p&gt;

&lt;p&gt;The Spider 1.0 effect, meanwhile, is real: at n=1,034 the intervals around&lt;br&gt;
82.6% and 88.1% do not overlap. So the finding is not "the embedder does&lt;br&gt;
nothing." It is &lt;em&gt;measurably useful on one benchmark and unmeasurable on the&lt;br&gt;
other&lt;/em&gt;, and the reason matters.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I think is going on
&lt;/h2&gt;

&lt;p&gt;Spider 2.0's tables carry real descriptions, harvested from the warehouses'&lt;br&gt;
own data dictionaries. Spider 1.0's tables carry none — just identifiers.&lt;/p&gt;

&lt;p&gt;When a table has prose describing it, lexical scoring over that prose already&lt;br&gt;
closes the gap between the words a user types and the words a schema uses. A&lt;br&gt;
question about "revenue" finds a table whose description says revenue, without&lt;br&gt;
any embedding involved. The sentence model was earning its keep on Spider 1.0&lt;br&gt;
by compensating for the absence of that text. Give the corpus the text and&lt;br&gt;
there is less left to compensate for.&lt;/p&gt;

&lt;p&gt;This is a hypothesis fitted to two data points and I am labelling it as such.&lt;/p&gt;
&lt;h2&gt;
  
  
  It is also the same finding as the one before it
&lt;/h2&gt;

&lt;p&gt;A few days ago I wrote up the opposite-looking result: adding LLM-generated&lt;br&gt;
table descriptions to a 1,245-object schema made retrieval &lt;em&gt;worse&lt;/em&gt;, because&lt;br&gt;
generated prose inflated the document frequency of the domain's own nouns until&lt;br&gt;
"contact" scored an IDF of 0.15 and the &lt;code&gt;contacts&lt;/code&gt; table fell out of the top 40.&lt;/p&gt;

&lt;p&gt;Those two results look unrelated. They are the same axis:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The value of any semantic layer — a learned encoder, or generated&lt;br&gt;
descriptions — depends on how much prose the schema already has. Where prose&lt;br&gt;
exists, lexical scoring over it does most of the work. Where it doesn't, you&lt;br&gt;
need something to bridge the vocabulary gap. And if you add prose to the same&lt;br&gt;
field as the identifiers, you damage the identifiers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Which gives a practical rule I can actually stand behind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bare schema, no comments&lt;/strong&gt; — the common case, and the worst one. A sentence
model helps. So do generated descriptions, in their own field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema with a real data dictionary&lt;/strong&gt; — you already have the prose. Expect
indexing cost from an encoder and little else. Spend the effort on keeping
the fields separate instead.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The experiment that would settle it
&lt;/h2&gt;

&lt;p&gt;Two benchmarks differing in everything is a correlation, not a mechanism. The&lt;br&gt;
controlled version is one line of setup: strip the descriptions out of Spider&lt;br&gt;
2.0 and re-run the same comparison on the same questions and the same&lt;br&gt;
databases.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spider 2.0-lite, k=10&lt;/th&gt;
&lt;th&gt;hashed&lt;/th&gt;
&lt;th&gt;sentence model&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;with data-dictionary prose&lt;/td&gt;
&lt;td&gt;82.9%&lt;/td&gt;
&lt;td&gt;82.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;descriptions removed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;?&lt;/td&gt;
&lt;td&gt;?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If the encoder's advantage reappears once the prose is gone, the hypothesis is&lt;br&gt;
confirmed inside one corpus with everything else held constant. If it doesn't,&lt;br&gt;
the explanation is something else — dialect, question style, table size — and I&lt;br&gt;
should stop telling this story.&lt;/p&gt;

&lt;p&gt;I'll run it and post the row either way.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug that only real data finds
&lt;/h2&gt;

&lt;p&gt;Spider 2.0 carries &lt;code&gt;description&lt;/code&gt; as a &lt;em&gt;list&lt;/em&gt; for some tables. That crashed&lt;br&gt;
indexing with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TypeError: sequence item 2: expected str instance, list found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Naming neither the object nor the field. A catalog of 800 tables was&lt;br&gt;
unindexable because one of them described itself in a list instead of a string.&lt;/p&gt;

&lt;p&gt;Fixed at the boundary rather than defensively at each reader — &lt;code&gt;ObjectDoc&lt;/code&gt;&lt;br&gt;
normalises &lt;code&gt;description&lt;/code&gt;, &lt;code&gt;hint&lt;/code&gt; and column comments once on construction,&lt;br&gt;
and &lt;code&gt;None&lt;/code&gt; stays &lt;code&gt;None&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That bug would have hit anyone pointing this at a real data dictionary. It did&lt;br&gt;
not show up across six schemas I wrote myself, my own test suite, or a live run&lt;br&gt;
against a 127-object Oracle database, because I built all of those and I would&lt;br&gt;
never have thought to put a list there. It took ninety seconds to find on&lt;br&gt;
someone else's data.&lt;/p&gt;

&lt;p&gt;Which is the actual argument for running public benchmarks, more than any&lt;br&gt;
number in the tables above. They are not there to prove you are good. They are&lt;br&gt;
there to be data you did not shape.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The library is &lt;a href="https://github.com/ashishsinha1602/schemagate" rel="noopener noreferrer"&gt;schemagate&lt;/a&gt;,&lt;br&gt;
Apache-2.0, currently 0.1.48. The benchmark scripts are in &lt;code&gt;benchmarks/&lt;/code&gt; and&lt;br&gt;
the data comes from the original sources.&lt;br&gt;
&lt;a href="https://github.com/ashishsinha1602/schemagate/blob/main/BENCHMARKS.md" rel="noopener noreferrer"&gt;BENCHMARKS.md&lt;/a&gt;&lt;br&gt;
carries Spider, BIRD and Spider 2.0, and quotes both embedder results rather&lt;br&gt;
than the flattering one.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sql</category>
      <category>ai</category>
      <category>database</category>
      <category>python</category>
    </item>
    <item>
      <title>I described 1,245 tables with an LLM and retrieval got worse</title>
      <dc:creator>Ashish sinha</dc:creator>
      <pubDate>Sat, 12 Sep 2026 23:35:28 +0000</pubDate>
      <link>https://dev.to/ashish_sinha_5241c7673d93/i-described-1245-tables-with-an-llm-and-retrieval-got-worse-58a</link>
      <guid>https://dev.to/ashish_sinha_5241c7673d93/i-described-1245-tables-with-an-llm-and-retrieval-got-worse-58a</guid>
      <description>&lt;p&gt;The cataloguing step is supposed to be the easy win. You have a schema whose&lt;br&gt;
tables are called &lt;code&gt;ecm_template_link&lt;/code&gt; and &lt;code&gt;v_pmpm&lt;/code&gt;, your users ask questions&lt;br&gt;
in English, and the gap between those two vocabularies is why retrieval&lt;br&gt;
misses. So you point a model at every table, get back a sentence describing&lt;br&gt;
each one, index the sentences alongside the names, and now the corpus speaks&lt;br&gt;
English too.&lt;/p&gt;

&lt;p&gt;I did that to a real 1,245-object schema. Recall went &lt;strong&gt;down&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Not by a little. The table literally called &lt;code&gt;contacts&lt;/code&gt; sat at rank 3 for the&lt;br&gt;
question "show the contacts of xmagnet" before cataloguing. After&lt;br&gt;
cataloguing it was below rank 40 — off the end of anything I would put in a&lt;br&gt;
prompt. The descriptions were fine. I read them. They were accurate,&lt;br&gt;
specific, and they made the system worse.&lt;/p&gt;

&lt;p&gt;This post is what was actually happening, why the obvious fix doesn't work,&lt;br&gt;
and the one that does. None of it is specific to text-to-SQL. If you are&lt;br&gt;
enriching documents before indexing them — summaries, generated titles,&lt;br&gt;
hypothetical questions, keyword expansion, anything — the same mechanism is&lt;br&gt;
available to bite you, and it will not announce itself.&lt;/p&gt;
&lt;h2&gt;
  
  
  The mechanism, in one line
&lt;/h2&gt;

&lt;p&gt;Every description you generate is written in the same vocabulary as every&lt;br&gt;
other description, so enrichment raises the document frequency of exactly&lt;br&gt;
the words your users type.&lt;/p&gt;

&lt;p&gt;BM25 scores a term by inverse document frequency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;idf(t) = log(1 + (N - n_t + 0.5) / (n_t + 0.5))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;N&lt;/code&gt; is the corpus size, &lt;code&gt;n_t&lt;/code&gt; the number of documents containing the term.&lt;br&gt;
A term in few documents is informative and scores high; a term in most&lt;br&gt;
documents is worthless and scores near zero. That is the entire point of&lt;br&gt;
IDF, and it is normally a good instinct.&lt;/p&gt;

&lt;p&gt;Now think about what a model writes when you ask it to describe a table in a&lt;br&gt;
CRM schema. It writes about contacts. It writes about contacts when&lt;br&gt;
describing &lt;code&gt;contacts&lt;/code&gt;, and also when describing &lt;code&gt;contact_lists&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;campaign_recipients&lt;/code&gt;, &lt;code&gt;email_events&lt;/code&gt;, &lt;code&gt;tenants&lt;/code&gt;, &lt;code&gt;users&lt;/code&gt;, and the audit&lt;br&gt;
table that logs changes to any of them — because in a CRM, almost everything&lt;br&gt;
is &lt;em&gt;about&lt;/em&gt; contacts in some defensible sense. The descriptions are not&lt;br&gt;
wrong. They are correlated.&lt;/p&gt;

&lt;p&gt;After cataloguing, the token &lt;code&gt;contact&lt;/code&gt; appeared in roughly &lt;strong&gt;1,072 of the&lt;br&gt;
1,245&lt;/strong&gt; documents — a figure I can reconstruct from the IDF it produced,&lt;br&gt;
which was &lt;strong&gt;0.15&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For comparison, here is what the same schema gives you for a genuinely rare&lt;br&gt;
term:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;term&lt;/th&gt;
&lt;th&gt;documents containing it&lt;/th&gt;
&lt;th&gt;idf&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;contact&lt;/code&gt;, after cataloguing&lt;/td&gt;
&lt;td&gt;~1,072 of 1,245&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.15&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;contact&lt;/code&gt;, in the name field only&lt;/td&gt;
&lt;td&gt;17 of 1,245&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.27&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tenant&lt;/code&gt;, in the name field only&lt;/td&gt;
&lt;td&gt;30 of 1,245&lt;/td&gt;
&lt;td&gt;3.71&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;the&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0 of 1,245&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The tokenizer does not strip stopwords, so &lt;code&gt;the&lt;/code&gt; and &lt;code&gt;and&lt;/code&gt; are in that&lt;br&gt;
index too, sitting near zero because they are in everything. At 0.15,&lt;br&gt;
&lt;code&gt;contact&lt;/code&gt; had joined them. The word the user typed was, for scoring purposes,&lt;br&gt;
a function word.&lt;/p&gt;
&lt;h2&gt;
  
  
  The second half, which is worse
&lt;/h2&gt;

&lt;p&gt;IDF collapse alone would flatten the ranking. What actively inverted it was&lt;br&gt;
length normalisation.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;b&lt;/code&gt; parameter in BM25 penalises long documents, on the sound theory that&lt;br&gt;
a long document containing your term is less &lt;em&gt;about&lt;/em&gt; your term than a short&lt;br&gt;
one that contains it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;score += idf(t) * f * (k1 + 1) / (f + k1 * (1 - b + b * len / avg_len))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ask which object in a CRM schema has the longest document, and the answer is&lt;br&gt;
the central one. &lt;code&gt;contacts&lt;/code&gt; in this schema has 55 columns. Add a generated&lt;br&gt;
description and a row of alias words and its document is several times the&lt;br&gt;
corpus average. Meanwhile &lt;code&gt;contact_import_log&lt;/code&gt; has six columns and a&lt;br&gt;
one-line description, so it is short, tidy, and — as far as the length&lt;br&gt;
prior is concerned — much more &lt;em&gt;about&lt;/em&gt; contacts.&lt;/p&gt;

&lt;p&gt;So the two effects compound in the same direction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;IDF collapse removes the signal that would have separated &lt;code&gt;contacts&lt;/code&gt; from
the forty other tables mentioning contacts.&lt;/li&gt;
&lt;li&gt;Length normalisation then actively sorts what's left by inverse
centrality, because the most important table in a schema is reliably the
one with the most columns.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cataloguing didn't add noise. It added &lt;em&gt;correlated&lt;/em&gt; noise, and correlated&lt;br&gt;
noise attacks the exact query it was meant to help. The questions that&lt;br&gt;
degraded most were the ones cataloguing exists to serve — plain English, no&lt;br&gt;
schema words. Questions that named a table outright were mostly fine, because&lt;br&gt;
they had a rare token to hang on. That is a nasty failure profile: the&lt;br&gt;
feature looks fine on your smoke tests and fails on your users.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix that doesn't work
&lt;/h2&gt;

&lt;p&gt;The obvious response is to trust descriptions less. One index, but weight the&lt;br&gt;
generated text below the real text.&lt;/p&gt;

&lt;p&gt;I did this first. It helps a bit and it is the wrong lever, for a reason&lt;br&gt;
that took me a while to see: &lt;strong&gt;weight and dilution act at different stages.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Down-weighting scales the contribution of a term &lt;em&gt;after&lt;/em&gt; IDF has already been&lt;br&gt;
computed over a corpus the descriptions polluted. &lt;code&gt;contact&lt;/code&gt; is still worth&lt;br&gt;
0.15 in the name's own score, because name and description live in one bag of&lt;br&gt;
words and IDF is a property of the bag. You have made a bad channel quieter&lt;br&gt;
without making the good channel accurate again.&lt;/p&gt;

&lt;p&gt;And the cost is real. Starving the prose weight cost me&lt;br&gt;
"per member per month cost" → &lt;code&gt;v_pmpm&lt;/code&gt;, which is the single best example in&lt;br&gt;
the whole schema of a question only a description can answer. There is no&lt;br&gt;
lexical path from that phrase to that name. The description was the only&lt;br&gt;
bridge and I had just defunded it.&lt;/p&gt;

&lt;p&gt;So: down-weighting trades away the wins to partially mitigate the losses. You&lt;br&gt;
end up tuning a scalar that makes both worse than they need to be.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix that works
&lt;/h2&gt;

&lt;p&gt;Score the fields separately and fuse the rankings, rather than concatenating&lt;br&gt;
the fields and scoring once.&lt;/p&gt;

&lt;p&gt;Three BM25 indexes over the same objects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_bm25&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_BM25&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embed_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;   &lt;span class="c1"&gt;# everything
&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_bm25_name&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_BM25&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;_name_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;   &lt;span class="c1"&gt;# identifiers
&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_bm25_prose&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_BM25&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;_prose_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;   &lt;span class="c1"&gt;# written text
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;where&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_name_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Just the identifiers: schema, name, and the name split on underscores.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_prose_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Everything written *about* the object: hint, description, comments.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hint&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;comment&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then fuse by reciprocal rank rather than by score:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;vec_rank&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;vector_weight&lt;/span&gt;  &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RRF_K&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;vec_rank&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lex_rank&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;lexical_weight&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RRF_K&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;lex_rank&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;name_rank&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;NAME_WEIGHT&lt;/span&gt;    &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RRF_K&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;name_rank&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;prose_rank&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;PROSE_WEIGHT&lt;/span&gt;   &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RRF_K&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;prose_rank&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both halves of the bug die at once, and it is worth being precise about why,&lt;br&gt;
because "just use fielded search" is advice people give without the&lt;br&gt;
mechanism:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IDF is recomputed per field.&lt;/strong&gt; In the name index, the only text is&lt;br&gt;
identifiers. Nothing a model writes can ever enter it. &lt;code&gt;contact&lt;/code&gt; appears in&lt;br&gt;
17 names out of 1,245, so its IDF is 4.27 instead of 0.15 — 28× the&lt;br&gt;
discriminating power, restored by construction rather than by tuning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Length is per field too.&lt;/strong&gt; The name index's document length is the length&lt;br&gt;
of the name. &lt;code&gt;contacts&lt;/code&gt; is two tokens whatever else you attach to the object.&lt;br&gt;
The 55 columns cannot inflate it, so the length prior stops punishing&lt;br&gt;
centrality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fusion is over ranks, not scores.&lt;/strong&gt; This is the part that contains a bad&lt;br&gt;
catalogue, and it's why I could raise the prose weight back to parity. A&lt;br&gt;
channel can only ever contribute its own ranking. If a weak model writes&lt;br&gt;
"Stores data about users and their settings" about all 1,245 objects, the&lt;br&gt;
prose channel becomes uniformly useless — every object ranks the same, the&lt;br&gt;
channel contributes nothing that discriminates, and the name and body&lt;br&gt;
channels decide the result unchanged. The floor becomes &lt;em&gt;"no better than&lt;br&gt;
before cataloguing"&lt;/em&gt; instead of &lt;em&gt;"worse than before cataloguing"&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That last property is the one I actually care about. It means pointing a&lt;br&gt;
small local model at your schema is safe. Not good, necessarily — a 1.5B&lt;br&gt;
model writes considerably worse descriptions than a frontier model, and I'd&lt;br&gt;
rather you use the good one. But safe: bad prose can no longer bury the&lt;br&gt;
object it describes, so the downside of trying is bounded.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this generalises to
&lt;/h2&gt;

&lt;p&gt;The pattern is not about databases. It is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Generated text about a corpus is written in the corpus's own vocabulary,&lt;br&gt;
so enrichment inflates document frequency for the domain's central terms —&lt;br&gt;
the ones users search with — and inflates document length most for the&lt;br&gt;
items that matter most.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Anywhere you generate text and index it next to original text, in the same&lt;br&gt;
field, you have signed up for both effects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Summaries prepended to chunks.&lt;/strong&gt; Every summary in a corpus about
Kubernetes says "Kubernetes".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hypothetical-question generation (HyDE-style indexing).&lt;/strong&gt; You are
synthesising the user's own phrasing, at scale, across every document. That
is IDF dilution as a product feature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keyword and synonym expansion.&lt;/strong&gt; Same shape, more concentrated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LLM-written titles or alt-text&lt;/strong&gt; merged into the body field.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are bad ideas. I still catalogue schemas; recall on&lt;br&gt;
business-phrased questions is far better with descriptions than without. The&lt;br&gt;
claim is narrower: &lt;strong&gt;enrichment belongs in its own field, always.&lt;/strong&gt; The cost&lt;br&gt;
of separating fields is one more index and a fusion step. The cost of not&lt;br&gt;
separating them is a regression that shows up only on your most important&lt;br&gt;
queries and looks like "retrieval is just hard".&lt;/p&gt;

&lt;p&gt;If you want to check whether this is happening to you, it is one query and&lt;br&gt;
no instrumentation: take the ten nouns your users actually type, and print&lt;br&gt;
their document frequency before and after your enrichment step. If any of&lt;br&gt;
them are now in more than half your documents, that term is doing nothing,&lt;br&gt;
and it was probably doing something before.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't fix
&lt;/h2&gt;

&lt;p&gt;Honesty about the edges, since the above reads tidier than the week did:&lt;/p&gt;

&lt;p&gt;Fielded scoring does not make a bad catalogue good. It makes it harmless. If&lt;br&gt;
your descriptions are generic, you get the pre-cataloguing ranking back, not&lt;br&gt;
a better one — which is the right outcome, but don't read it as a licence to&lt;br&gt;
skip evaluating the model that writes them.&lt;/p&gt;

&lt;p&gt;It also introduces a knob per field, and I do not have a principled method&lt;br&gt;
for setting them. Mine are all at parity because that tested best across six&lt;br&gt;
schemas, not because parity is theoretically correct.&lt;/p&gt;

&lt;p&gt;And separating fields cannot fix a term that is genuinely common in the&lt;br&gt;
&lt;em&gt;names&lt;/em&gt; too. A schema with 300 tables actually called &lt;code&gt;contact_something&lt;/code&gt; has&lt;br&gt;
a real ambiguity problem, and no amount of field isolation invents the&lt;br&gt;
information to resolve it.&lt;/p&gt;




&lt;p&gt;The measurements here come from schemagate, an open-source library&lt;br&gt;
(Apache-2.0) that does the retrieval step for text-to-SQL. The relevant code&lt;br&gt;
is in &lt;a href="https://github.com/ashishsinha1602/schemagate/blob/main/src/schemagate/catalog.py" rel="noopener noreferrer"&gt;&lt;code&gt;catalog.py&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
— the comments around the three &lt;code&gt;_BM25&lt;/code&gt; constructions are where I wrote this&lt;br&gt;
down while it was still fresh. There's a browser demo at&lt;br&gt;
&lt;a href="https://ashishsinha1602.github.io/schemagate/" rel="noopener noreferrer"&gt;ashishsinha1602.github.io/schemagate&lt;/a&gt;&lt;br&gt;
that runs the real selector client-side on six sample schemas, if you'd&lt;br&gt;
rather poke at the ranking than read about it.&lt;/p&gt;

&lt;p&gt;If you run the document-frequency check on your own corpus, I'd like to know&lt;br&gt;
what it says — particularly if it says nothing is wrong, because I'd like to&lt;br&gt;
know what makes a corpus immune.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>rag</category>
      <category>search</category>
    </item>
  </channel>
</rss>
