<?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: Dan Weaver</title>
    <description>The latest articles on DEV Community by Dan Weaver (@disisnoturbusiness).</description>
    <link>https://dev.to/disisnoturbusiness</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%2F4154746%2F6155ff79-9fd6-4d1c-af5e-aacb4790567f.png</url>
      <title>DEV Community: Dan Weaver</title>
      <link>https://dev.to/disisnoturbusiness</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/disisnoturbusiness"/>
    <language>en</language>
    <item>
      <title>I asked 63 models the same 76 questions, with and without web search</title>
      <dc:creator>Dan Weaver</dc:creator>
      <pubDate>Thu, 08 Oct 2026 14:11:16 +0000</pubDate>
      <link>https://dev.to/disisnoturbusiness/i-asked-63-models-the-same-76-questions-with-and-without-web-search-45b1</link>
      <guid>https://dev.to/disisnoturbusiness/i-asked-63-models-the-same-76-questions-with-and-without-web-search-45b1</guid>
      <description>&lt;p&gt;The 2026 standard deduction for a single filer is $16,100. I asked 63 models what it was.&lt;/p&gt;

&lt;p&gt;Asked from memory, 8 of 61 got it right. 24 invented a number, three of them landing on $8,300. 19 gave a figure from an earlier year. 9 refused to answer. Of the 15 seats that could search the web, 14 got it and the fifteenth errored out.&lt;/p&gt;

&lt;p&gt;That is one question. This is what happened across 76 of them, and what I got wrong on the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you end up with
&lt;/h2&gt;

&lt;p&gt;A .NET 10 service, Hangfire, and one SQL Server database. It works through a plan slowly and in order, one question per provider group per pass, waiting out daily rate limits. It ran for days without supervision. Every raw provider response is stored as it came back, and grading runs afterwards over what was stored.&lt;/p&gt;

&lt;p&gt;76 questions. 63 models. 5,776 graded answers. $31.74 of paid API calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. What makes a question usable
&lt;/h2&gt;

&lt;p&gt;A question earns its place only if it has a short unambiguous answer and a primary source that states it: the agency, the manufacturer, the law itself. "Explain X" is not a question you can grade at this scale. "What is the USCIS premium processing fee for an H-1B filed on Form I-129" is.&lt;/p&gt;

&lt;p&gt;54 of the 76 were picked because the answer changed in the last twelve months. The other 22 are an older set on payroll, tax, .NET and SQL Server, and were not held to that test. Some of them have not moved in years: the federal minimum wage has been $7.25 since 2009, two are arithmetic, and one is how SQL Server handles &lt;code&gt;NOT IN&lt;/code&gt; against a NULL. Keeping those turned out to be useful, because a model that fails arithmetic is failing differently than a model that is a year behind.&lt;/p&gt;

&lt;p&gt;The previous answers are recorded too, in a separate table of known-stale values. That single decision is what makes the output readable:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bucket&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Correct&lt;/td&gt;
&lt;td&gt;Matches the verified answer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outdated&lt;/td&gt;
&lt;td&gt;Matches a real figure from an earlier year&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hallucinated&lt;/td&gt;
&lt;td&gt;A number that was never right&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refusal&lt;/td&gt;
&lt;td&gt;Declined to answer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Truncated&lt;/td&gt;
&lt;td&gt;Cut off before finishing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error&lt;/td&gt;
&lt;td&gt;The call failed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unclassified&lt;/td&gt;
&lt;td&gt;The two judges disagreed, so it was not scored&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Outdated and Hallucinated are different failures and collapsing them loses the finding. A model repeating last year's contribution limit is stale. A model inventing $8,300 is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A seat is a model plus how you asked it
&lt;/h2&gt;

&lt;p&gt;A model that can search gets asked twice, once from memory and once with the search tool on. Those are two seats. 63 models therefore produce 76 seats, because only some of them can search.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SeatId = {provider}:{model}:{mode}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write it down this way at the start. Everything downstream, every grade, every cost row, keys off the seat rather than the model, and the first time you want to compare modes you will be glad the schema already knows the difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Who grades the answers
&lt;/h2&gt;

&lt;p&gt;Half of them never reach a model. 2,866 of the 5,776 grades are deterministic: the extracted value matches the verified answer, matches a recorded earlier value, or the call itself failed.&lt;/p&gt;

&lt;p&gt;The other 2,910 go to a panel of two judges. They are asked to compare two strings, with the verified answer and the known-stale list supplied in the prompt, so it is a comparison task and not a recall task. Both judges returned a verdict on all 2,910, and they agreed on 2,867 of them, 98.5%. Where they split, the answer is bucketed Unclassified and not scored. There are 146 of those.&lt;/p&gt;

&lt;p&gt;Judge error is real. Two things bound it. The raw response is the record, so changing a match rule re-grades all 5,776 results with no provider calls. Be precise about the limit though: re-running the extractor over raw bytes is a manual step, and changing the judge prompt or a known answer does re-ask the judges, because the rubric version hashes both. And every judge call is stored with its prompt and its reply, so a grade that looks wrong can be traced to the sentence that produced it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The judges are also contestants, and that is a flaw.&lt;/strong&gt; The two judge seats are &lt;code&gt;claude-sonnet-5&lt;/code&gt; and &lt;code&gt;openai/gpt-oss-120b&lt;/code&gt;, and both are models in the field, with 152 and 76 answers of their own. &lt;code&gt;claude-sonnet-5&lt;/code&gt; graded 52 of its own answers, &lt;code&gt;gpt-oss-120b&lt;/code&gt; 45 of its own, and &lt;code&gt;claude-sonnet-5&lt;/code&gt; is a row in the headline table further down. The deterministic half is untouched by this and panel agreement is high, but a judge should not be sitting the exam. On a re-run the judges come from outside the field.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Spending real money without trusting your own code
&lt;/h2&gt;

&lt;p&gt;Paid models bill per call, and native web search cannot be bounded per request. The provider passes the search cost through and charges at request finish, so a per-call estimate is a planning figure, not a brake. The money rules therefore sit outside the calling code and assume the calling code is wrong.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A reservation row is written &lt;strong&gt;before&lt;/strong&gt; the request goes out. Any reservation means that seat was asked that question, permanently, whatever it settled at. Nothing re-asks it automatically.&lt;/li&gt;
&lt;li&gt;One send per seat. The only exception is a rejection the provider documents as happening before any model ran, which provably bills nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconcile.&lt;/strong&gt; The key's own lifetime usage at the provider may never exceed what the ledger accounts for. If it does, something billed that was not recorded, and the lane halts until a human clears it.&lt;/li&gt;
&lt;li&gt;The halt is written to the database &lt;em&gt;and&lt;/em&gt; to a file, so it survives a database that was unreachable at the moment it had to be raised.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The ledger books the larger of the reported cost and its own pricing, so it over-records on purpose. It recorded $38.22 over 1,591 calls. The provider's own meter says $31.74. Over-recording is the correct direction to be wrong in.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The number I almost published
&lt;/h2&gt;

&lt;p&gt;Here is the headline I had written, with a card made for it and a post drafted:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;From memory the models were right 23% of the time. With web search, 90%. Same models, same questions. The only difference was whether they could look it up.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every number in that is correct. The sentence is still false.&lt;/p&gt;

&lt;p&gt;61 models were asked from memory. 15 seats could search. &lt;strong&gt;Only 13 models were asked both ways.&lt;/strong&gt; So 23.1% and 89.6% describe two different populations, and the 15 that can search are mostly the frontier models, which are also the better models from memory. Part of that 66-point gap is the models, not the search.&lt;/p&gt;

&lt;p&gt;The comparison that controls for the model is the 13 that were asked both ways, over all 76 questions, 988 answers each side:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Correct&lt;/th&gt;
&lt;th&gt;Answers&lt;/th&gt;
&lt;th&gt;Accuracy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;From memory&lt;/td&gt;
&lt;td&gt;439&lt;/td&gt;
&lt;td&gt;988&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;44.4%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;With web search&lt;/td&gt;
&lt;td&gt;945&lt;/td&gt;
&lt;td&gt;988&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;95.6%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of the 13 improved.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;From memory&lt;/th&gt;
&lt;th&gt;With search&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;claude-haiku-4-5&lt;/td&gt;
&lt;td&gt;10.5%&lt;/td&gt;
&lt;td&gt;94.7%&lt;/td&gt;
&lt;td&gt;+84.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.5-flash-lite&lt;/td&gt;
&lt;td&gt;21.1%&lt;/td&gt;
&lt;td&gt;93.4%&lt;/td&gt;
&lt;td&gt;+72.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.1-pro-preview&lt;/td&gt;
&lt;td&gt;26.3%&lt;/td&gt;
&lt;td&gt;97.4%&lt;/td&gt;
&lt;td&gt;+71.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;grok-4.3&lt;/td&gt;
&lt;td&gt;30.3%&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;td&gt;+69.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.8-flash&lt;/td&gt;
&lt;td&gt;32.9%&lt;/td&gt;
&lt;td&gt;98.7%&lt;/td&gt;
&lt;td&gt;+65.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;grok-4.6&lt;/td&gt;
&lt;td&gt;43.4%&lt;/td&gt;
&lt;td&gt;97.4%&lt;/td&gt;
&lt;td&gt;+54.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;claude-sonnet-5&lt;/td&gt;
&lt;td&gt;40.8%&lt;/td&gt;
&lt;td&gt;93.4%&lt;/td&gt;
&lt;td&gt;+52.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;claude-opus-5&lt;/td&gt;
&lt;td&gt;51.3%&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;td&gt;+48.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-5.6-terra&lt;/td&gt;
&lt;td&gt;57.9%&lt;/td&gt;
&lt;td&gt;97.4%&lt;/td&gt;
&lt;td&gt;+39.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-5.6-luna&lt;/td&gt;
&lt;td&gt;59.2%&lt;/td&gt;
&lt;td&gt;97.4%&lt;/td&gt;
&lt;td&gt;+38.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-5.6-sol&lt;/td&gt;
&lt;td&gt;60.5%&lt;/td&gt;
&lt;td&gt;94.7%&lt;/td&gt;
&lt;td&gt;+34.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-6-astra&lt;/td&gt;
&lt;td&gt;78.9%&lt;/td&gt;
&lt;td&gt;98.7%&lt;/td&gt;
&lt;td&gt;+19.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-chat-latest&lt;/td&gt;
&lt;td&gt;64.5%&lt;/td&gt;
&lt;td&gt;80.3%&lt;/td&gt;
&lt;td&gt;+15.8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One more check, because three of those 13 run through a CLI that takes a system prompt, and theirs changed between modes as well as the tool. Drop those three and the remaining ten go from 47.5% to 95.5%. The result does not rest on the wording.&lt;/p&gt;

&lt;p&gt;I had the 23 to 90 version on a social card and most of the way into a post before I checked the denominator.&lt;/p&gt;

&lt;p&gt;Every individual number was already checked. Verifying each figure does not verify the claim built out of them, and the question that catches it is dull: what is the denominator on each side, and are the two sides the same population?&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Things worth poking at
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Judge error, and judges in the field.&lt;/strong&gt; Covered in section 3. The second one is a design mistake, not a rounding error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One seat dragged the population average down.&lt;/strong&gt; A free-tier model's search tool failed on 66 of its 76 calls. That may be my configuration as much as their model, but the calls errored either way. It is why the published figures come in two bases: counting every call attempted, and counting only answers that came back. Both are on the site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two seats are search-only&lt;/strong&gt; and therefore not in the paired comparison at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subscription seats are not free.&lt;/strong&gt; Seven seats across four models run on a flat-rate plan rather than per-call billing, so they are not in the $31.74. A table showing them at $0.00 next to metered seats is lying by omission, so they are labelled.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;Model the seat before you write anything else. Store raw responses from the first call, not the first time you wish you had them. Record the previous answers along with the current one, because wrong and a year behind are different numbers and you only get to tell them apart if you decided to up front. Pick judges from outside the field.&lt;/p&gt;

&lt;p&gt;And write the denominator next to every percentage, in the schema, where you cannot avoid looking at it.&lt;/p&gt;

&lt;p&gt;Code, raw data and all 5,776 graded answers: &lt;a href="https://github.com/disisnoturbusiness/LLMQuorum" rel="noopener noreferrer"&gt;github.com/disisnoturbusiness/LLMQuorum&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every number here is clickable at &lt;a href="https://llmquorum.aidataforager.com" rel="noopener noreferrer"&gt;llmquorum.aidataforager.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>dotnet</category>
      <category>testing</category>
    </item>
    <item>
      <title>Hybrid code search on Azure SQL and SQL Server 2025, end to end</title>
      <dc:creator>Dan Weaver</dc:creator>
      <pubDate>Thu, 01 Oct 2026 12:46:09 +0000</pubDate>
      <link>https://dev.to/disisnoturbusiness/hybrid-code-search-on-azure-sql-and-sql-server-2025-end-to-end-1552</link>
      <guid>https://dev.to/disisnoturbusiness/hybrid-code-search-on-azure-sql-and-sql-server-2025-end-to-end-1552</guid>
      <description>&lt;p&gt;Azure SQL and SQL Server 2025 both have a native &lt;code&gt;VECTOR&lt;/code&gt; type, a DiskANN index, and &lt;code&gt;VECTOR_SEARCH&lt;/code&gt;. The documentation covers the syntax for each one. It does not cover what happens when you put them together and point them at a real codebase, which is where most of the work turned out to be.&lt;/p&gt;

&lt;p&gt;This is the whole path: source files in, ranked answers out. C# and .NET, one database, no separate vector store. Everything here runs on both platforms, and the one place they genuinely diverge is section 5, which is the section worth reading. The live deployment this came out of is Azure SQL serverless. The same build runs against self-hosted SQL Server 2025 on a workstation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you end up with
&lt;/h2&gt;

&lt;p&gt;A query runs three retrieval legs at once. Approximate nearest neighbour over a DiskANN index. Full-text over the chunk table. Full-text over the file table. The three ranked lists get fused with reciprocal rank fusion inside a stored procedure, a cross-encoder reranks the survivors, and a relevance gate throws out results that are nearest-neighbour artifacts rather than answers.&lt;/p&gt;

&lt;p&gt;Everything lives in one database. That is the point of doing it this way. No Qdrant to run, no sync job, no second thing to back up.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The schema
&lt;/h2&gt;

&lt;p&gt;Two tables. Files, and chunks of files.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;dbo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CodeChunks&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
   &lt;span class="n"&gt;Id&lt;/span&gt;           &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
   &lt;span class="n"&gt;CodeFileId&lt;/span&gt;   &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;dbo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CodeFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
   &lt;span class="n"&gt;ChunkKey&lt;/span&gt;     &lt;span class="n"&gt;NVARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
   &lt;span class="n"&gt;ChunkContent&lt;/span&gt; &lt;span class="n"&gt;NVARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
   &lt;span class="n"&gt;Embedding&lt;/span&gt;    &lt;span class="n"&gt;VECTOR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dimension is baked into the column type. Change embedding models and you change the schema. Plan for that now, because the failure mode later is silent: a corpus written at 1024 and queried at 1536 does not error, it returns nonsense.&lt;/p&gt;

&lt;p&gt;Write vectors by casting JSON server-side.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;dbo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CodeChunks&lt;/span&gt; &lt;span class="p"&gt;(...,&lt;/span&gt; &lt;span class="n"&gt;Embedding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(...,&lt;/span&gt; &lt;span class="k"&gt;CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;VECTOR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@embedding&lt;/code&gt; is a &lt;code&gt;float[]&lt;/code&gt; serialized with &lt;code&gt;System.Text.Json&lt;/code&gt;. There is no client-side vector parameter type to reach for.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Chunking
&lt;/h2&gt;

&lt;p&gt;Fixed-size windows are the obvious approach and they are wrong for code. A window that splits a method in half produces two chunks, neither of which is the method.&lt;/p&gt;

&lt;p&gt;Use the syntax tree. Roslyn hands you &lt;code&gt;MethodDeclarationSyntax&lt;/code&gt; and friends, so chunk on those boundaries.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One shell chunk per type: usings, namespace, XML docs, the declaration line, fields, constants, auto-properties. Method bodies excluded.&lt;/li&gt;
&lt;li&gt;One chunk per member with a real body.&lt;/li&gt;
&lt;li&gt;A token budget with a floor and a ceiling. 200 to 400 estimated tokens worked here.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two cases break the clean rule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Methods over the ceiling.&lt;/strong&gt; Split them, but split on top-level &lt;code&gt;StatementSyntax&lt;/code&gt; boundaries, never mid-statement. Greedily pack statements until the next one would blow the budget. A chunk that runs slightly fat beats a chunk that lost a line. Prefix each part with a comment carrying the signature and name them &lt;code&gt;Foo~part2of3&lt;/code&gt;. The embedding text does not have to be valid C#.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Members under the floor.&lt;/strong&gt; A three-line property embeds to noise. Coalesce runs of tiny members into one chunk.&lt;/p&gt;

&lt;p&gt;Then give each chunk roughly fifty tokens of the next chunk's opening, under a marker comment. Cheap continuity, and it means a question whose answer straddles two members still hits one of them.&lt;/p&gt;

&lt;p&gt;Estimate tokens with word count times a constant rather than running the real tokenizer. This runs once per chunk inside a build that is already slow. It does not need to be exact, it needs to be fast and never under-count.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Embedding
&lt;/h2&gt;

&lt;p&gt;Two things matter and neither one is the model choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The dimension is a single global and it has to match everywhere.&lt;/strong&gt; If your indexer prefers a local ONNX model and your server prefers a hosted endpoint, and those two have different widths, you will index at one width and query at another. Nothing throws. Put a loud warning at startup when both are configured.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A failed parse must throw, not return zeros.&lt;/strong&gt; An all-zero vector survives L2 normalization untouched, because a magnitude of zero short-circuits, and then produces garbage cosine distances against every row in the table. Validate the length at the source.&lt;/p&gt;

&lt;p&gt;On batching, measure it. Batching pads every sequence to the longest one in the batch. On a corpus with uneven chunk sizes that lost to one-at-a-time here, roughly three minutes against four and a half. Your corpus may differ. The point is that the obvious optimization is not free.&lt;/p&gt;

&lt;p&gt;On concurrency, pick the limit from whichever side is the bottleneck. Local model on your machine, parallelize across cores. Remote endpoint, cap at what the server can take. A strong client pointed at a small server makes it thrash and throughput drops.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The DiskANN index, and when to build it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;VECTOR&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IX_CodeChunks_Embedding&lt;/span&gt;
   &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;dbo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CodeChunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Embedding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metric&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'cosine'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'diskann'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build this after the data is loaded, not alongside the schema. DiskANN needs a meaningful number of non-null vectors before it will build at all.&lt;/p&gt;

&lt;p&gt;Before you spend an hour embedding, prove the server can actually do this. Do not read &lt;code&gt;ProductVersion&lt;/code&gt;, because Azure SQL is evergreen and reports 12.0 forever. Cast a literal instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'[1,2,3]'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;VECTOR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then create a throwaway table with a real DiskANN index on it and drop it. Ten seconds of preflight against an hour of wasted embedding.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Querying, and the one place the two platforms diverge
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;VECTOR_SEARCH&lt;/code&gt; has two spellings, and which one you need depends on the DiskANN index build version, not the product. In practice that splits along platform lines: Azure SQL and Fabric build version 3 indexes, self-hosted SQL Server 2025 builds earlier ones.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- version 3 and up (Azure SQL, Fabric)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;TOP&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;APPROXIMATE&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;

&lt;span class="c1"&gt;-- earlier builds (SQL Server 2025 self-hosted)&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="n"&gt;VECTOR_SEARCH&lt;/span&gt;&lt;span class="p"&gt;(...,&lt;/span&gt; &lt;span class="n"&gt;TOP_N&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you only ever deploy to one of them you can hardcode the right one and stop reading here. If you develop locally and deploy to Azure, which is the normal arrangement, you need both in the same codebase.&lt;/p&gt;

&lt;p&gt;Send the wrong one and you get Msg 42274. Both spellings are documented. Three things about them are not obvious from the docs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One.&lt;/strong&gt; &lt;code&gt;WITH APPROXIMATE&lt;/code&gt; is load-bearing. A bare &lt;code&gt;TOP (200)&lt;/code&gt; parses fine and runs fine, and performs an exact kNN scan of the whole table without touching the index. The engine raises a warning, but if your data layer is not surfacing warnings you will never see it. You get correct results, slowly, forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two.&lt;/strong&gt; You cannot reliably branch on the version. It comes from &lt;code&gt;JSON_VALUE(sys.vector_indexes.build_parameters, '$.Version')&lt;/code&gt;, and self-hosted SQL 2025 writes &lt;code&gt;StartId&lt;/code&gt;, &lt;code&gt;L&lt;/code&gt;, &lt;code&gt;M&lt;/code&gt;, &lt;code&gt;R&lt;/code&gt; and no &lt;code&gt;Version&lt;/code&gt; key at all. A missing key means the older spelling. Default it the other way and every self-hosted install silently degrades to full-text only, which is exactly what happened here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three.&lt;/strong&gt; Because the two spellings are mutually unparseable, you cannot put them in one procedure body behind an &lt;code&gt;IF&lt;/code&gt;. The branch the engine cannot parse fails at compile time. Build both as strings and dispatch with &lt;code&gt;sp_executesql&lt;/code&gt;. Treat the version as a hint for which to try first, and catch the failure to try the other.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;WITH APPROXIMATE&lt;/code&gt; also forces &lt;code&gt;ORDER BY&lt;/code&gt; on the distance column alone, so tie-breaking has to happen afterward. Land the hits in a temp table and apply &lt;code&gt;ROW_NUMBER()&lt;/code&gt; there. That has a second benefit: it keeps the dynamic SQL out of an &lt;code&gt;INSERT ... EXEC&lt;/code&gt;, so callers can still &lt;code&gt;INSERT ... EXEC&lt;/code&gt; your procedure, which cannot nest.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Fusing three legs
&lt;/h2&gt;

&lt;p&gt;Reciprocal rank fusion, k = 60, entirely in T-SQL.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;  &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;VectorWeight&lt;/span&gt;   &lt;span class="o"&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="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;VectorRank&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;ChunkFtsWeight&lt;/span&gt; &lt;span class="o"&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="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;ChunkFtsRank&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;FileFtsWeight&lt;/span&gt;  &lt;span class="o"&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="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;FileFtsRank&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rank each leg independently, union the candidates, sum the contributions. RRF only needs ranks, so you never have to make a cosine distance and a full-text RANK comparable, which is the thing that makes score-normalization approaches fragile.&lt;/p&gt;

&lt;p&gt;Keep the three components as separate output columns. You will want them in section 7.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Reranking, and why the gate matters more
&lt;/h2&gt;

&lt;p&gt;A cross-encoder over the top thirty candidates was the single largest quality win here. Measured against swapping the embedding model, it was not close.&lt;/p&gt;

&lt;p&gt;But reranking alone does not fix the real failure. Vector search always returns its nearest neighbours, however far away they are. Search for a term your corpus has never seen and you get back five confident-looking files containing none of the words.&lt;/p&gt;

&lt;p&gt;So gate the results, in three parts.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A relative floor, as a fraction of the top score. This one survives a model swap, because it only depends on the shape of one result set.&lt;/li&gt;
&lt;li&gt;An absolute floor on the top score itself. This one has to be recalibrated per reranker. Log the observed top score so you can tune it.&lt;/li&gt;
&lt;li&gt;A higher absolute floor applied only to hits with no lexical corroboration. This is what the separate RRF columns are for. A hit that no full-text leg found should clear a higher bar than one two legs agree on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When everything fails the gate, return nothing. Empty is the correct answer. Do not fall back to the unfiltered list.&lt;/p&gt;

&lt;p&gt;One caveat that bit me. If the reranker is unavailable, skip the gate entirely. Otherwise an outage turns into every search returning zero results, which looks like a data problem and is not. Relatedly, when you fall back to the pre-rerank order, emit descending pseudo-scores just under 1.0 rather than zeros. Zeros make an outage indistinguishable from "nothing is relevant."&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Two things that only bite on Azure SQL serverless
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The database reports 12.0 forever.&lt;/strong&gt; Covered in section 4, and it is the reason you capability-test instead of version-check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full-text is not ready when the connection is.&lt;/strong&gt; After a serverless resume, the engine accepts connections before the full-text filter daemon has come up. Your connection-level retry policy never sees this, because the connection succeeded. The command fails instead, and the engine reports it under more than one error number, so match on the message text for "filter daemon" or "FDHost" and retry at the command level. Log the actual &lt;code&gt;SqlException.Number&lt;/code&gt; so you can add codes later.&lt;/p&gt;

&lt;p&gt;Without that retry the affected queries are the &lt;em&gt;first ones after a wake&lt;/em&gt;. On a public demo that is every cold visitor, and what they get is the vector leg silently missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Rebuilding without downtime
&lt;/h2&gt;

&lt;p&gt;Build into &lt;code&gt;CodeFiles_Staging&lt;/code&gt; and &lt;code&gt;CodeChunks_Staging&lt;/code&gt;, then swap. Generate both table definitions from one parameterized string so live and staging cannot drift.&lt;/p&gt;

&lt;p&gt;The swap is more steps than you would guess, because the full-text indexes, the vector index and the stored procedure are all bound to the live table's object id. Drop the dependents, rename live out, rename staging in, &lt;code&gt;sp_rename&lt;/code&gt; every constraint and index back to the live names, rebuild the dependents.&lt;/p&gt;

&lt;p&gt;Guard the promotion. If staging holds less than 95% of the files you listed, do not swap. Leave the good index alone and say so in the log. That guard is the entire recovery story: a build that dies partway never becomes live, so recovery is "run it again."&lt;/p&gt;

&lt;p&gt;Make your unique keys stable across runs. Truncating a long key to fit the column and appending a hash of the full value works, as long as the hash is deterministic. FNV-1a is fine. Do not use &lt;code&gt;GetHashCode&lt;/code&gt;, which is randomized per process.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. What I would do differently
&lt;/h2&gt;

&lt;p&gt;Instrument the build from day one. Every number worth having, stage timings, error counts, how close the staged count came to the promotion threshold, was already computed and written to a console nobody reads. The query side had telemetry in a table. The build side had nothing, so there was no way to answer "is this getting slower."&lt;/p&gt;

&lt;p&gt;Surface warnings. Two of the worst failures here, the silent full scan and the silent full-text-only degradation, were both things the system knew about and did not say.&lt;/p&gt;

&lt;p&gt;And measure before you tune. Swapping the embedding model changed recall and did nothing for ranking. The reranker changed everything. I would have guessed the other way around.&lt;/p&gt;




&lt;p&gt;Working code, MIT: &lt;a href="https://github.com/disisnoturbusiness/AzureDevOpsForager" rel="noopener noreferrer"&gt;https://github.com/disisnoturbusiness/AzureDevOpsForager&lt;/a&gt; Four-minute walkthrough: &lt;a href="https://www.loom.com/share/644b73ea68e34ded81a490c82a4cb99f" rel="noopener noreferrer"&gt;https://www.loom.com/share/644b73ea68e34ded81a490c82a4cb99f&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://azuredevops.aidataforager.com/writeup-hybrid-vector-search.html" rel="noopener noreferrer"&gt;azuredevops.aidataforager.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sqlserver</category>
      <category>azure</category>
      <category>dotnet</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
