<?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: Archie Marshall</title>
    <description>The latest articles on DEV Community by Archie Marshall (@atwmarshall).</description>
    <link>https://dev.to/atwmarshall</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3890638%2Ff6244f2c-e4b2-418b-a5a2-42543b8487a7.jpeg</url>
      <title>DEV Community: Archie Marshall</title>
      <link>https://dev.to/atwmarshall</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/atwmarshall"/>
    <language>en</language>
    <item>
      <title>Hybrid search inside SurrealDB: one query, vector + keyword + RRF</title>
      <dc:creator>Archie Marshall</dc:creator>
      <pubDate>Wed, 29 Apr 2026 19:35:12 +0000</pubDate>
      <link>https://dev.to/atwmarshall/hybrid-search-inside-surrealdb-one-query-vector-keyword-rrf-5ade</link>
      <guid>https://dev.to/atwmarshall/hybrid-search-inside-surrealdb-one-query-vector-keyword-rrf-5ade</guid>
      <description>&lt;p&gt;&lt;em&gt;By Archie Marshall - Applied AI Lead at Squad AI. Built during the LangChain × SurrealDB London Hackathon.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;RAG systems fail when vector search returns &lt;em&gt;semantically similar&lt;/em&gt; results instead of the &lt;em&gt;exactly named&lt;/em&gt; function you asked for.&lt;/li&gt;
&lt;li&gt;The fix is hybrid search: vector + keyword in parallel, fused by Reciprocal Rank Fusion.&lt;/li&gt;
&lt;li&gt;SurrealDB's &lt;code&gt;search::rrf()&lt;/code&gt; runs the whole thing inside the database — one query, no middleware, no score normalisation.&lt;/li&gt;
&lt;li&gt;With graph enrichment on top, a 3B local model handles codebase questions you'd normally need a frontier API for.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The problem nobody tells you about RAG
&lt;/h2&gt;

&lt;p&gt;Your RAG system returns confident, well-formatted answers. They're also wrong - because the retriever found semantically similar results instead of the actual function you asked for.&lt;/p&gt;

&lt;p&gt;This is the problem I hit building &lt;a href="https://github.com/atwmarshall/dead-reckoning/" rel="noopener noreferrer"&gt;DeadReckoning&lt;/a&gt;, a knowledge graph agent that turns any Python codebase into something you can query in plain English. You ask it: &lt;em&gt;"what depends on the slugify function?"&lt;/em&gt; and it needs to find the function named &lt;code&gt;slugify&lt;/code&gt; — not every function that does something similar to slugifying.&lt;/p&gt;

&lt;p&gt;DeadReckoning was a winner in the LangChain × SurrealDB hackathon in London. One SurrealDB instance runs the entire backend: knowledge graph, vector store, agent checkpoint, and version history. But the layer that made everything else work was the search architecture.&lt;/p&gt;

&lt;p&gt;The solution: run vector search and keyword search in parallel, then fuse them with Reciprocal Rank Fusion — in a single SurrealQL query. No application-side stitching. No separate search service. One database, one query, ranked results that handle both meaning and spelling.&lt;/p&gt;

&lt;p&gt;Here's how I built it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F48d5vzevw9b230xm37m9.gif" 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.amazonaws.com%2Fuploads%2Farticles%2F48d5vzevw9b230xm37m9.gif" alt="DeadReckoning ingesting a second version of a codebase. The knowledge graph diffs itself — green (unchanged), yellow (modified), red (deleted), blue (new)." width="720" height="376"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;DeadReckoning ingesting a second version of a codebase. The knowledge graph diffs itself — green (unchanged), yellow (modified), red (deleted), blue (new). One SurrealDB instance stores the graph, runs the search, and checkpoints the agent state.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/atwmarshall/dead-reckoning/" rel="noopener noreferrer"&gt;github.com/atwmarshall/dead-reckoning&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Why vector search alone fails
&lt;/h2&gt;

&lt;p&gt;HNSW vector search maps text into a high-dimensional embedding space where similar meanings cluster together. It's powerful for open-ended questions like &lt;em&gt;"what handles input validation?"&lt;/em&gt; because it understands meaning, not just words.&lt;/p&gt;

&lt;p&gt;But it has a blind spot: spelling.&lt;/p&gt;

&lt;p&gt;Search for &lt;em&gt;"the slugify function"&lt;/em&gt; and vector similarity returns &lt;code&gt;sanitise_input&lt;/code&gt;, &lt;code&gt;clean_string&lt;/code&gt;, &lt;code&gt;normalise_text&lt;/code&gt;. Semantically related — all text-cleaning functions — but you wanted the function named &lt;code&gt;slugify&lt;/code&gt;. Embeddings don't care about the name. They care about the neighbourhood.&lt;/p&gt;

&lt;p&gt;For a codebase search system, this is a critical failure. Developers think in function names. When they ask for &lt;code&gt;slugify&lt;/code&gt;, they mean &lt;code&gt;slugify&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why keyword search alone fails
&lt;/h2&gt;

&lt;p&gt;BM25 keyword search has the opposite problem. It's excellent at exact matches but blind to meaning.&lt;/p&gt;

&lt;p&gt;Search for &lt;em&gt;"what handles HTTP connection pooling?"&lt;/em&gt; and BM25 looks for those exact words. The answer lives in &lt;code&gt;_transport.py&lt;/code&gt; with a docstring that talks about &lt;em&gt;"session reuse"&lt;/em&gt; and &lt;em&gt;"persistent connections"&lt;/em&gt;. Same concept, different vocabulary. Keyword search misses it entirely.&lt;/p&gt;

&lt;p&gt;Neither strategy works alone. You need both. The question is how to combine them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F34kblzzk0m8yhcgfkg4a.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.amazonaws.com%2Fuploads%2Farticles%2F34kblzzk0m8yhcgfkg4a.png" alt="Comparison of vector-only, keyword-only and hybrid search results across three example queries" width="800" height="253"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Reciprocal Rank Fusion: merge by rank, not score
&lt;/h2&gt;

&lt;p&gt;The naive approach to combining two search strategies is to normalise their scores and add them. This doesn't work. Vector similarity scores and BM25 scores are on fundamentally incompatible scales. A cosine similarity of 0.82 and a BM25 score of 4.7 aren't comparable — and normalising them introduces arbitrary assumptions about their relative importance.&lt;/p&gt;

&lt;p&gt;Reciprocal Rank Fusion sidesteps this entirely. It ignores scores and uses rank positions instead.&lt;/p&gt;

&lt;p&gt;The formula is simple: for each result, sum &lt;code&gt;1 / (k + rank)&lt;/code&gt; across all retrieval lists. &lt;code&gt;k&lt;/code&gt; is a smoothing constant (typically 60 — see &lt;a href="https://cormack.uwaterloo.ca/cormacksigir09-rrf.pdf" rel="noopener noreferrer"&gt;Cormack et al.&lt;/a&gt;) that prevents the top-ranked result from dominating.&lt;/p&gt;

&lt;p&gt;A function ranked 2nd in vector search and 3rd in keyword search scores higher than one ranked 1st in only one list. Results that appear high in multiple strategies get a compounding boost. Results that only one strategy found rank lower.&lt;/p&gt;

&lt;p&gt;This is the same pattern used in production search systems at Elasticsearch and Azure AI Search. The difference: we're running it inside the database.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe6051uxlmeqnn6inuvkf.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.amazonaws.com%2Fuploads%2Farticles%2Fe6051uxlmeqnn6inuvkf.png" alt="The hybrid search pipeline diagram" width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The hybrid search pipeline. Vector and keyword search run in parallel inside SurrealDB, fused by rank with &lt;code&gt;search::rrf()&lt;/code&gt;. Graph enrichment then adds class context and the call neighbourhood before results reach the LLM — all within the same database.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The implementation: &lt;code&gt;search::rrf()&lt;/code&gt; in SurrealQL
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;search::rrf()&lt;/code&gt; is available in SurrealDB and does rank fusion inside the database. Most databases require you to run vector and keyword search separately and fuse them in application code. SurrealDB does it in a single query with &lt;code&gt;search::rrf()&lt;/code&gt;, making hybrid search a database-native operation. This means no middleware, no score normalisation logic, and no extra network hops.&lt;/p&gt;

&lt;p&gt;Here's the full query:&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;-- HNSW Vector search: find functions semantically similar to the query&lt;/span&gt;
&lt;span class="n"&gt;LET&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;vs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;similarity&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;cosine&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="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;
          &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;
          &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;|&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- BM25 Keyword search: find functions matching by name or docstring&lt;/span&gt;
&lt;span class="n"&gt;LET&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;ft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;SELECT&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;search&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;search&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;score&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;AS&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;
          &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;
          &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;keyword&lt;/span&gt;
             &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;docstring&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;keyword&lt;/span&gt;
          &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
          &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Fuse both result sets by rank position (k=60 smooths top-position influence)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;search&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;rrf&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;vs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;ft&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three statements. Let's walk through each one.&lt;/p&gt;

&lt;p&gt;The first runs an HNSW vector similarity search against the function embeddings. The &lt;code&gt;&amp;lt;|5,100|&amp;gt;&lt;/code&gt; operator queries the HNSW index — 5 results, with &lt;code&gt;ef_search=100&lt;/code&gt; controlling how many candidates the index considers during traversal.&lt;/p&gt;

&lt;p&gt;The second runs a BM25 full-text search across function names and docstrings. The &lt;code&gt;@0@&lt;/code&gt; and &lt;code&gt;@1@&lt;/code&gt; operators reference separate BM25 indexes — one on &lt;code&gt;name&lt;/code&gt;, one on &lt;code&gt;docstring&lt;/code&gt; — both using the same analyzer. The numeric suffixes map to &lt;code&gt;search::score(0)&lt;/code&gt; and &lt;code&gt;search::score(1)&lt;/code&gt;, which means you can weight name matches differently from docstring matches.&lt;/p&gt;

&lt;p&gt;The third fuses them. &lt;code&gt;search::rrf()&lt;/code&gt; takes an array of result sets, a limit, and a &lt;code&gt;k&lt;/code&gt; parameter. For each result, it calculates &lt;code&gt;1 / (k + rank)&lt;/code&gt; from each list and sums them. The rank-based approach means you never need to normalise scores across incompatible scales.&lt;/p&gt;

&lt;p&gt;This runs inside SurrealDB. No Python middleware. No post-processing. The database returns fused, ranked results ready for your LLM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Graph enrichment: context, not just results
&lt;/h2&gt;

&lt;p&gt;Search gives you a ranked list of functions. But a function in isolation isn't very useful to an LLM. The model needs context: what class does this function belong to? What other functions sit alongside it? What calls it, and what does it call?&lt;/p&gt;

&lt;p&gt;After RRF returns the top results, I run three parallel queries on each one. SurrealDB stores the codebase as a knowledge graph — files, functions, classes, and call relationships as nodes and typed edges. For each search result, I query:&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;-- Parent class (nearest class definition above this function)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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;bases&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;`class`&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;lineno&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;lineno&lt;/span&gt;
  &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;lineno&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Sibling functions (same class, same file)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;`function`&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;class_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;class_name&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Call neighbourhood (graph traversal over calls edges)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="nv"&gt;`function`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;callers&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;calls&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;`function`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;callees&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;fn_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LLM now receives not just &lt;em&gt;"here's the function"&lt;/em&gt; but &lt;em&gt;"here's the function, its parent class, its sibling methods, and everything that calls it or that it calls."&lt;/em&gt; That dependency context is what turns accurate answers into useful ones. It's also what lets the agent trace downstream impact when it spots something worth investigating.&lt;/p&gt;

&lt;p&gt;This is where storing your search index and your knowledge graph in the same database pays off. The graph traversal after search is a SurrealQL query, not a separate service call. No network hop, no serialisation overhead, same database.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe3dfboh8mt6tordfxc1c.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.amazonaws.com%2Fuploads%2Farticles%2Fe3dfboh8mt6tordfxc1c.png" alt="The context graph for a hybrid search query" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The context graph for a hybrid search query. The search result (&lt;code&gt;slugify&lt;/code&gt;) is returned alongside its parent file, sibling functions, and call neighbourhood — all retrieved from the same SurrealDB instance in three parallel queries.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tracing every retrieval stage
&lt;/h2&gt;

&lt;p&gt;When hybrid search returns bad results, you need to know which stage failed. Was the vector search returning irrelevant results? Did keyword search miss because of vocabulary mismatch? Did RRF rank them wrong? Did graph enrichment pull in unrelated context?&lt;/p&gt;

&lt;p&gt;Every search request produces a trace with three separate spans: embedding, RRF retrieval (vector + keyword + fusion in a single SurrealDB query), and graph enrichment. Each span shows its inputs, outputs, and latency.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvldrut05amsp1bnhyrl1.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.amazonaws.com%2Fuploads%2Farticles%2Fvldrut05amsp1bnhyrl1.png" alt="A single hybrid search request traced in LangSmith" width="800" height="634"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A single hybrid search request traced in LangSmith. Embedding is cheap (0.86s), the fused BM25 + HNSW query via &lt;code&gt;search::rrf()&lt;/code&gt; runs in a single SurrealDB roundtrip (1.35s), and graph enrichment — traversing &lt;code&gt;calls&lt;/code&gt; edges for callers and callees — dominates at 3.65s. That breakdown is exactly the kind of signal you need when a query returns the wrong answer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This level of observability is essential for iterating on search quality. When a query returns wrong results, you open the trace and see immediately whether the problem is in retrieval, ranking, or graph context. Without it, debugging hybrid search is guesswork.&lt;/p&gt;

&lt;h2&gt;
  
  
  What improved
&lt;/h2&gt;

&lt;p&gt;Before hybrid search, DeadReckoning used vector-only retrieval. The failure pattern was consistent: exact-name queries returned semantically similar but wrong functions, and the LLM confidently answered based on the wrong context.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjzp25kd13njmrudiha84.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.amazonaws.com%2Fuploads%2Farticles%2Fjzp25kd13njmrudiha84.png" alt="Summary of how each strategy performs by query type — exact name, semantic/conceptual, and mixed" width="800" height="225"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The graph enrichment layer compounded the improvement. Even when the top result was correct, without class context and sibling functions the LLM often missed the full picture. With enrichment, answers went from &lt;em&gt;"this function exists"&lt;/em&gt; to &lt;em&gt;"this function exists in this class alongside these other functions, and it's called by these three modules."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this works on a laptop
&lt;/h2&gt;

&lt;p&gt;DeadReckoning doesn't need a frontier model. It runs on small local models via Ollama - CPU only, no GPU required. The default is gemma4:e2b, Google's edge-tier model designed for laptops and phones. During the hackathon I ran it on llama3.2:3b - Gemma 4 hadn't been released yet, and llama3.2:3b was a fast small model to hand with usable tool calling capabilities. It's a 3B model that fits in 2GB of RAM and handles most queries, though structured tool calling gets flaky at that size. Either way: no API costs, nothing leaves your machine.&lt;/p&gt;

&lt;p&gt;For context, some frontier models are estimated to have over a trillion parameters — hundreds of times larger. They cost dollars per session via API and require your data to travel to someone else's infrastructure.&lt;/p&gt;

&lt;p&gt;The reason a 3B model works here isn't because the task is simple. It's because the retrieval layer does the heavy lifting before the model sees anything. Hybrid search with RRF finds the right functions. Graph enrichment adds the class context and sibling functions. By the time the query reaches the LLM, the context is small, precise, and directly relevant — no noise, no irrelevant files, no hoping the model figures out what matters.&lt;/p&gt;

&lt;p&gt;This is context engineering, not model engineering. The model doesn't need to be smart enough to search an entire codebase. It needs to be smart enough to reason over five well-chosen functions and their relationships. A 3B model can do that.&lt;/p&gt;

&lt;p&gt;There are tradeoffs. A larger model will reason better, handle more ambiguous queries, and produce more polished output. But for targeted code navigation — finding the right functions, understanding their relationships, answering specific questions — the gap is smaller than you'd expect when the context is good.&lt;/p&gt;

&lt;p&gt;DeadReckoning is model-agnostic — you configure the Ollama model in your &lt;code&gt;.env&lt;/code&gt; file and swap it for anything you have locally. The search architecture makes the model size a deployment choice, not a foregone conclusion. Run it on a MacBook Air. Run it air-gapped with zero API dependency. The infrastructure you have, not the infrastructure you wish you had.&lt;/p&gt;

&lt;p&gt;This matters for regulated industries where data can't leave the building, for teams that need to control costs, and for anyone who wants to stop paying per-token for context the model shouldn't need in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd change
&lt;/h2&gt;

&lt;p&gt;This was built in a hackathon, so there are things I'd do differently with more time:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-analyser weighting in BM25.&lt;/strong&gt; I weight name matches and docstring matches equally. In practice, a name match is a much stronger signal. Adjusting the analyser weights would sharpen keyword search before it even reaches RRF.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Feedback loop.&lt;/strong&gt; Logging which search results the LLM actually used in its answer would let us build a relevance feedback loop. Over time, the system could learn which RRF rankings led to correct answers and adjust accordingly.&lt;/p&gt;

&lt;h2&gt;
  
  
  One query, one database
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;search::rrf()&lt;/code&gt; makes hybrid search a database-native operation rather than an application-layer concern. For anyone building retrieval systems on SurrealDB, this is the pattern to start with: vector for meaning, keyword for precision, rank fusion to combine them, and graph traversal to add context.&lt;/p&gt;

&lt;p&gt;The entire search pipeline — from query to enriched, ranked results — runs inside a single SurrealDB instance. No separate vector database. No external search service. No middleware to maintain. One database, one query language, one set of credentials.&lt;/p&gt;

&lt;p&gt;DeadReckoning is open source — full codebase and hybrid search implementation at &lt;a href="https://github.com/atwmarshall/dead-reckoning/" rel="noopener noreferrer"&gt;github.com/atwmarshall/dead-reckoning&lt;/a&gt;. Built with &lt;a href="https://www.linkedin.com/in/julia-sala-bayo/" rel="noopener noreferrer"&gt;Júlia Sala-Bayo&lt;/a&gt; at the LangChain × SurrealDB London Hackathon.&lt;/p&gt;

&lt;p&gt;If you're building retrieval on SurrealDB, or doing hybrid search anywhere else and want to compare notes, I'd genuinely like to hear from you. Find me on &lt;a href="https://www.linkedin.com/in/atwmarshall/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; — happy to swap war stories, especially around eval and observability for retrieval pipelines.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>rag</category>
      <category>langchain</category>
    </item>
  </channel>
</rss>
