<?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: Sekharendu Dey</title>
    <description>The latest articles on DEV Community by Sekharendu Dey (@sekharendu_dey).</description>
    <link>https://dev.to/sekharendu_dey</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%2F4133394%2F19dc4d82-7836-4cc6-a0a6-7156713efe49.jpg</url>
      <title>DEV Community: Sekharendu Dey</title>
      <link>https://dev.to/sekharendu_dey</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sekharendu_dey"/>
    <language>en</language>
    <item>
      <title>What 113 eval questions taught me building a fully local RAG</title>
      <dc:creator>Sekharendu Dey</dc:creator>
      <pubDate>Sun, 04 Oct 2026 20:19:39 +0000</pubDate>
      <link>https://dev.to/sekharendu_dey/what-113-eval-questions-taught-me-building-a-fully-local-rag-1923</link>
      <guid>https://dev.to/sekharendu_dey/what-113-eval-questions-taught-me-building-a-fully-local-rag-1923</guid>
      <description>&lt;p&gt;I built &lt;a href="https://github.com/Sekharendu/LocalCortex" rel="noopener noreferrer"&gt;LocalCortex&lt;/a&gt;, a chat app for your own documents that runs entirely on a laptop: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ollama for the models, &lt;/li&gt;
&lt;li&gt;Qdrant for search, &lt;/li&gt;
&lt;li&gt;Postgres for chat history, &lt;/li&gt;
&lt;li&gt;no cloud APIs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You upload PDFs, Word files, or Markdown, ask questions, and get answers with sources. If the answer isn't in your documents, it refuses instead of making something up.&lt;/p&gt;

&lt;p&gt;But the app itself isn't really the interesting part.&lt;/p&gt;

&lt;p&gt;The interesting part was finding out that almost every idea that sounded like "yeah, this should definitely make it better" either did absolutely nothing, made things worse, or required way too much digging through numbers just to figure out whether it helped at all. So I wrote the eval before most of the features, and I kept a log of every experiment I tried.&lt;/p&gt;

&lt;p&gt;This post is the short version of that log.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The final result:&lt;/strong&gt; on a production-style run of 62 questions, reading every single answer by hand, 61 were correct (98%) and all 14 off-topic prompts were properly refused.&lt;/p&gt;

&lt;p&gt;Speed-wise, the median answer time was 48 seconds per query. Not exactly fast, but that's pretty much what you sign up for when you're running Llama 3 on a CPU.&lt;/p&gt;

&lt;p&gt;Here's what got it there, and what didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Two eval sets: a hand-written 20-section employee handbook with 58 questions (including near-duplicate "distractor" sections designed to confuse retrieval), and a larger 4-document, initially 208-chunk policy corpus with 55 questions. &lt;br&gt;
That's 113 questions in total.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Metrics: Recall@1/3/5 and MRR for retrieval, a hallucination stress test for refusals, and later a hand-graded answer check.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Models: &lt;code&gt;nomic-embed-text&lt;/code&gt; for embeddings, &lt;code&gt;llama3&lt;/code&gt; 8B for generating the final answer, both through Ollama.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Hybrid search didn’t improve overall rank-1 retrieval
&lt;/h2&gt;

&lt;p&gt;Everyone says to add BM25 next to your embeddings. I did. &lt;br&gt;
Every Qdrant point got a dense and a sparse vector, fused with RRF.&lt;/p&gt;

&lt;p&gt;The handbook results:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hybrid: 54/58.&lt;/li&gt;
&lt;li&gt;Dense: 56/58 correct at rank 1.&lt;/li&gt;
&lt;li&gt;After hardening keyword matching, abbreviation handling, ranking, and relevance filtering: 55/58.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My theory was that the handbook was too small for BM25 to help, so I built the bigger corpus. Hybrid got &lt;em&gt;worse&lt;/em&gt; &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hybrid: 43/55 &lt;/li&gt;
&lt;li&gt;Dense: 47/55.&lt;/li&gt;
&lt;li&gt;Adding the missing EAP expansion brought hybrid from 43/55 to 47/55, which closed the entire rank-1 gap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So why can hybrid lose when it literally contains the dense ranking?&lt;/p&gt;

&lt;p&gt;Because RRF fuses rank positions, not scores.&lt;/p&gt;

&lt;p&gt;If the dense top result barely beats the second result, RRF sees rank 1 and rank 2. If the top result absolutely destroys the second result, RRF still just sees rank 1 and rank 2.&lt;/p&gt;

&lt;p&gt;That means a noisy sparse ranking can come along and flip something that dense search was already very confident about.&lt;br&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%2F20qazj9tkd64p6ocrrhv.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%2F20qazj9tkd64p6ocrrhv.png" alt="image showing why hybrid doesn't beat dense                                                                                                                   " width="800" height="382"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: Hybrid search means extra storage, extra bookkeeping, extra ranking logic, and, naturally, extra ways for retrieval to go wrong.&lt;/p&gt;

&lt;p&gt;On these documents, all that extra machinery gave me no overall rank-1 improvement. Dense and hybrid both finished at 105/113.&lt;/p&gt;

&lt;p&gt;So LocalCortex uses dense search by default. Hybrid is still there if you want it, but it's optional.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The biggest win was a one-line prefix
&lt;/h2&gt;

&lt;p&gt;This one hurt a little.&lt;/p&gt;

&lt;p&gt;nomic-embed-text was trained with task prefixes:&lt;br&gt;
&lt;strong&gt;search_query&lt;/strong&gt;: before questions.&lt;br&gt;
&lt;strong&gt;search_document&lt;/strong&gt;: before indexed text.&lt;/p&gt;

&lt;p&gt;I was sending it raw text.&lt;br&gt;
I added the prefixes and got this:&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;Dense&lt;/th&gt;
&lt;th&gt;Dense + prefixes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Handbook (58)&lt;/td&gt;
&lt;td&gt;56&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large corpus (55)&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;50&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total (113)&lt;/td&gt;
&lt;td&gt;103&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;105&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With prefixes, dense and hybrid tied at 105/113.&lt;br&gt;
But the more interesting part was what happened to the similarity scores.&lt;br&gt;
The prefixes pushed the scores of correct chunks higher. With my old 0.7 relevance threshold, valid chunks that were being incorrectly rejected dropped: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;from 16 down to 7 on the handbook, and &lt;/li&gt;
&lt;li&gt;from 8 down to 3 on the large corpus.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So yes, after trying considerably more exciting things, one of the biggest improvements was basically adding two strings.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Why saying "I don't know" needs a measurement, not a vibe
&lt;/h2&gt;

&lt;p&gt;A RAG app should refuse when nothing relevant exists.&lt;/p&gt;

&lt;p&gt;Mine had a relevance threshold of 0.7.&lt;br&gt;
Why 0.7?&lt;br&gt;
Because 0.7 looked reasonable.&lt;br&gt;
Excellent science.&lt;/p&gt;

&lt;p&gt;So I finally measured it properly using 39 off-topic questions: 10 general-knowledge questions like "What is the capital of France?" and 29 questions asking about information that simply wasn't in the documents.&lt;/p&gt;

&lt;p&gt;The top-1 similarity ranges looked like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Answerable questions: 0.640–0.908&lt;/li&gt;
&lt;li&gt;General knowledge: 0.419–0.567&lt;/li&gt;
&lt;li&gt;questions about missing information: 0.514–0.739&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At 0.7, my threshold was apparently feeling a bit too picky, locking out 9 of the 113 valid questions. Dropping it to 0.63 let everyone in while successfully keeping random general-knowledge questions outside the door. &lt;br&gt;
Of course, near-miss questions still tried to crash the party by scoring just like the real ones, which made one thing pretty obvious: a threshold can't catch every kind of bad query.&lt;br&gt;
In a separate check, the model correctly refused all 15 such questions that received context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; a threshold is a measurement. Plot the distributions before you pick it, and know which failures it can't catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. How the model handled those 15 questions
&lt;/h2&gt;

&lt;p&gt;The model was not told which questions asked for missing information. It compared each question with the retrieved passages.&lt;/p&gt;

&lt;p&gt;The prompt told the model to answer using only the supplied passages and say the information was insufficient when they didn’t support an answer.&lt;/p&gt;

&lt;p&gt;Say the document tells us that Naruto became the Seventh Hokage, but says absolutely nothing about his salary.&lt;/p&gt;

&lt;p&gt;Ask:&lt;br&gt;
"Which Hokage did Naruto become?"&lt;br&gt;
Easy. The answer is there.&lt;/p&gt;

&lt;p&gt;Ask:&lt;br&gt;
"How much did Naruto earn as Hokage?"&lt;br&gt;
Now it should refuse. The document might be about Naruto becoming Hokage, but it never tells us how much he earned.&lt;/p&gt;

&lt;p&gt;That's basically what happened with those 15 questions.&lt;/p&gt;

&lt;p&gt;The retrieved passages were related to the topic, but they didn’t contain the specific answers. Following the prompt, the model correctly said the information was insufficient. This was the result of that test, not a guarantee that it will always decide correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. My hallucination test was wrong for a whole session
&lt;/h2&gt;

&lt;p&gt;This was a fun one.&lt;/p&gt;

&lt;p&gt;The hallucination test asks 10 questions the documents don't answer and checks whether the model refuses them.&lt;/p&gt;

&lt;p&gt;Every run came back:&lt;br&gt;
0/10 PASS.&lt;/p&gt;

&lt;p&gt;Naturally, I assumed the model was the problem.&lt;/p&gt;

&lt;p&gt;It wasn't.&lt;/p&gt;

&lt;p&gt;The model was refusing correctly. My regexes were looking for phrases like "insufficient context" and "couldn't." Meanwhile, the model was saying things like "the provided context is insufficient" and "I could not find."&lt;/p&gt;

&lt;p&gt;Same refusal. Different words. My grader had opinions.&lt;/p&gt;

&lt;p&gt;It also occasionally saw words repeated from the question, like "parking reimbursement," and decided that was evidence the model had fabricated something.&lt;/p&gt;

&lt;p&gt;While debugging that, I found another nice surprise: 18 integration tests were always being skipped because Vitest evaluated their skip condition before the service check ran.&lt;/p&gt;

&lt;p&gt;Once those tests actually started running, they immediately found four cases using invalid Qdrant point IDs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: test your tests. A green or red number from a broken grader is worse than having no number at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Follow-up questions leak relevance
&lt;/h2&gt;

&lt;p&gt;Conversation memory made follow-up questions work.&lt;/p&gt;

&lt;p&gt;"And after five years?"&lt;/p&gt;

&lt;p&gt;On its own, that means basically nothing.&lt;/p&gt;

&lt;p&gt;The simple fix was to embed the previous question together with the current one.&lt;/p&gt;

&lt;p&gt;And it worked.&lt;/p&gt;

&lt;p&gt;Follow-ups found the correct section 12/13 times instead of 4/13.&lt;/p&gt;

&lt;p&gt;Great.&lt;/p&gt;

&lt;p&gt;Then it broke refusals.&lt;/p&gt;

&lt;p&gt;Suppose the previous question was about vacation policy and the next question is:&lt;/p&gt;

&lt;p&gt;"What is the capital of France?"&lt;/p&gt;

&lt;p&gt;On its own, that question scored 0.479.&lt;/p&gt;

&lt;p&gt;Combined with the previous vacation question, it scored 0.682.&lt;/p&gt;

&lt;p&gt;My relevance threshold was 0.63.&lt;/p&gt;

&lt;p&gt;So congratulations to Paris, it now apparently belongs in the employee handbook.&lt;/p&gt;

&lt;p&gt;All 6 off-topic follow-ups leaked through.&lt;/p&gt;

&lt;p&gt;I added a second gate: the current question by itself also had to clear 0.57.&lt;/p&gt;

&lt;p&gt;Why 0.57?&lt;/p&gt;

&lt;p&gt;The highest score among the tested general-knowledge questions was 0.567, so 0.57 was simply the smallest rounded threshold above the range I'd actually observed.&lt;/p&gt;

&lt;p&gt;Result:&lt;/p&gt;

&lt;p&gt;10/13 follow-ups correct.&lt;/p&gt;

&lt;p&gt;0/6 leaked.&lt;/p&gt;

&lt;p&gt;But I couldn't just send every question through the rewriter either.&lt;/p&gt;

&lt;p&gt;In an early test, after a conversation about someone's employment, I asked:&lt;/p&gt;

&lt;p&gt;"What is the capital of France?"&lt;/p&gt;

&lt;p&gt;Llama3 rewrote it as:&lt;/p&gt;

&lt;p&gt;"What was his employment period?"&lt;/p&gt;

&lt;p&gt;It basically looked at my completely unrelated new question and said, "Nope, we're still talking about the previous thing."&lt;/p&gt;

&lt;p&gt;So now I only rewrite questions that actually appear to refer back to earlier context, and I reject rewrites that throw away too much of the user's original wording.&lt;/p&gt;

&lt;p&gt;With those guard rails:&lt;/p&gt;

&lt;p&gt;11/13 follow-ups correct.&lt;/p&gt;

&lt;p&gt;0/6 leaked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: every feature that makes answering easier also seems to find a creative new way to make refusing harder.Measure both.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Chunking: one bug fix beat three clever ideas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A splitter bug:&lt;/strong&gt; 
My recursive splitter included "## " as a separator.
That sounds harmless until you realize it could split inside the heading marker itself.
61 of 208 chunks were broken.
Some were literally just #.
Others were headings with no body.
I fixed the bug.
208 chunks became 117.
Same scores.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Already a win.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bigger chunks (800 chars):&lt;/strong&gt; 
The handbook's refusals went from 0 to 6, because two short unrelated sections got packed into one chunk whose embedding sat between both topics. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Removed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Contextual Retrieval&lt;/strong&gt; (an LLM-written blurb per chunk): 
I generated an LLM-written blurb for every chunk.
It produced the best rank-1 recall on the resume fixture at that point.
It also took 25–31 seconds per chunk on CPU.
And one vague generated blurb managed to push the correct chunk out of the top 5 for two questions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Removed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Semantic chunking:&lt;/strong&gt; 
It only cuts at sentence ends, so the handbook turned into 48 scraps and bullet lists got glued together. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not the default.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What stayed:&lt;/strong&gt; 
A "Title › Section" header above each chunk, but automatically for documents with fewer than two Markdown headings like resumes and most other PDFs. 
On a resume it took rank-1 recall from 5/14 to 8/14. 
On structured documents it cost a refusal each, so it switches on automatically from the text, not the file type.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: a surprising number of retrieval problems are just chunking problems wearing a fake moustache.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. The model needed whole documents, not five fragments
&lt;/h2&gt;

&lt;p&gt;On my resume, I asked:&lt;br&gt;
"How many projects does Sekharendu Dey have?"&lt;/p&gt;

&lt;p&gt;The answer came back:&lt;br&gt;
"At least three."&lt;/p&gt;

&lt;p&gt;I have two.&lt;/p&gt;

&lt;p&gt;The model had counted jobs as projects because I'd handed it five fragments ordered by retrieval relevance instead of giving it the résumé in reading order.&lt;/p&gt;

&lt;p&gt;Some project bullets also arrived without their project headings.&lt;/p&gt;

&lt;p&gt;So I separated retrieval from context assembly.&lt;/p&gt;

&lt;p&gt;Retrieval now answers one question first:&lt;br&gt;
"Is anything here relevant enough to continue, and which documents matched?"&lt;/p&gt;

&lt;p&gt;Then context assembly decides what the model should actually read.&lt;/p&gt;

&lt;p&gt;If a matched document is 1,000 tokens or less, I include the whole thing when the context budget allows it.&lt;/p&gt;

&lt;p&gt;For larger documents, the model gets the strongest matching chunks, the opening chunk of the document, and nearby chunks in reading order, up to a 1,200-token per-document limit and the overall 4,096-token context window.&lt;/p&gt;

&lt;p&gt;On a fictional resume fixture, correct answers went from 17/25 to 22/25.&lt;/p&gt;

&lt;p&gt;On my actual resume, it now says "two projects" and names both.&lt;/p&gt;

&lt;p&gt;There was a trade-off, of course.&lt;br&gt;
Long-document answers became roughly 2× slower.&lt;/p&gt;

&lt;p&gt;And one question on a 48-page PDF actually got worse because the answer disappeared inside a prompt that was 2.5× longer.&lt;br&gt;
A later context limit fixed that answer.&lt;br&gt;
The extra latency, unfortunately, did not receive the memo.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Read the answers yourself
&lt;/h2&gt;

&lt;p&gt;My keyword grader gave the final run a score of 57/62.&lt;/p&gt;

&lt;p&gt;Then I read the answers.&lt;/p&gt;

&lt;p&gt;Four of the five "wrong" answers were actually correct.&lt;/p&gt;

&lt;p&gt;So the real reviewed score was 61/62.&lt;/p&gt;

&lt;p&gt;One answer was correct but apparently too short for the grader's liking.&lt;/p&gt;

&lt;p&gt;Another correctly gave the Denver office's opening time, while the grader expected both the opening and closing time.&lt;/p&gt;

&lt;p&gt;There was only one actual miss:&lt;/p&gt;

&lt;p&gt;"who made the CUGA thing."&lt;/p&gt;

&lt;p&gt;The wording was vague enough that its retrieval score fell below the 0.63 threshold, so the app refused it.&lt;/p&gt;

&lt;p&gt;When I rephrased it to clearly ask for the authors' organizations, it answered correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: automated grading tells you where to look. It doesn't get to tell you the final score.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone building their first RAG
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Build an evaluation set early. Do it before adding enough features that you can no longer tell what actually made the system better.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Measure refusals just as seriously as answers. Off-topic questions are half the product.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Keep a log of what you tried and what you removed. A bunch of things I built sounded useful and were removed after I measured them. Without the log, I'd probably forget why.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Try the boring fix first. A prefix. A splitter bug. One missing acronym. Those ended up doing more for me than several much fancier ideas.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The code, the eval sets and every script behind these numbers are on GitHub:&lt;br&gt;
&lt;strong&gt;&lt;a href="https://github.com/Sekharendu/LocalCortex" rel="noopener noreferrer"&gt;github.com/Sekharendu/LocalCortex&lt;/a&gt;&lt;/strong&gt; (MIT). &lt;/p&gt;

&lt;p&gt;It starts with one command: &lt;code&gt;docker compose --profile app up -d&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you're building something similar, or you know a way to catch slangy questions without letting "the capital of France" through, I'd love to hear it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>typescript</category>
      <category>llm</category>
    </item>
    <item>
      <title>Chunking: Getting the First Cut Right</title>
      <dc:creator>Sekharendu Dey</dc:creator>
      <pubDate>Thu, 24 Sep 2026 14:30:48 +0000</pubDate>
      <link>https://dev.to/sekharendu_dey/chunking-getting-the-first-cut-right-pbc</link>
      <guid>https://dev.to/sekharendu_dey/chunking-getting-the-first-cut-right-pbc</guid>
      <description>&lt;p&gt;Let's start with an inconvenient truth: your carefully built  RAG system, the one you're quietly proud of, might be sabotaging itself at step one. Not at retrieval. Not at generation. At the very first thing that happens to your document, &lt;em&gt;the cut&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Chunking sounds boring. It's the "take a big document, slice it into smaller pieces" step, and it's usually the part we rush through to get to the "fun" stuff like embeddings and vector databases. But here's the thing nobody tells loudly enough: &lt;em&gt;get this step wrong, and everything downstream is trying to fix a problem that got created on page one.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Chunking Has to Exist (It's Not a Choice, It's Physics)
&lt;/h2&gt;

&lt;p&gt;A 40-page PDF is a lot to ask an embedding model to capture in a single vector. Embedding models have limits, and even without those limits, cramming a whole document into one vector means everything specific gets averaged into mush. So documents get broken down into smaller, digestible segments called chunks, which then get embedded and stored so a retrieval system can search and pull back only the relevant piece instead of the whole haystack.&lt;/p&gt;

&lt;p&gt;In short: chunking exists because "smaller and specific" beats "big and vague" when you're trying to find a needle. The argument isn't about &lt;em&gt;whether&lt;/em&gt; to chunk, rather it's about &lt;em&gt;how&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Classic Lineup
&lt;/h2&gt;

&lt;p&gt;If you've read literally any RAG tutorial, you've met these three:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixed-size chunking :&lt;/strong&gt; the "just cut every N characters" approach. Fast, simple, and about as thoughtful as slicing a birthday cake with your eyes closed. If a sentence happens to cross the 512-token limit, well... it doesn't get much of a say in where the cut lands.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;CharacterTextSplitter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@langchain/textsplitters&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;splitter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CharacterTextSplitter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;separator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// no separator to respect, just slice&lt;/span&gt;
  &lt;span class="na"&gt;chunkSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;chunkOverlap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;documents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;splitter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDocuments&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;documentText&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;Recursive chunking :&lt;/strong&gt; a slightly more polite cousin. It tries to split along natural boundaries first like paragraphs, then sentences, then lines and only falling back to a hard cut if it absolutely has to. Think of it as tearing a loaf of bread along the lines where it naturally wants to break, instead of taking a knife to it wherever you feel like.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;RecursiveCharacterTextSplitter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@langchain/textsplitters&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;splitter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RecursiveCharacterTextSplitter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;chunkSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;chunkOverlap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;separators&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// tried in this order&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;documents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;splitter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDocuments&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;documentText&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;Semantic chunking :&lt;/strong&gt; the fancy one. Instead of counting characters, it uses a model to notice where the &lt;em&gt;topic&lt;/em&gt; shifts, and cuts there instead, so each chunk stays thematically whole rather than just structurally intact.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;OpenAI&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openai&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;cosineDistance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;magA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;v&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;magB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;v&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="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;dot&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;magA&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;magB&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;percentile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;return&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;semanticChunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sentences&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;percentileThreshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text-embedding-3-small&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sentences&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;embeddings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;distances&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;embeddings&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;emb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;cosineDistance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;emb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;percentile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;distances&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;percentileThreshold&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;breakpoints&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;distances&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;threshold&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;i&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&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="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bp&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;breakpoints&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sentences&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bp&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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bp&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="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sentences&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These three are the bread and butter of chunking tutorials, and they're not wrong, they're just not the whole story anymore. Because here's the problem none of them actually solve.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: A Chunk That Doesn't Know What It's Talking About
&lt;/h2&gt;

&lt;p&gt;Say you've got a wiki-style document about &lt;em&gt;the Pain Assault Arc in Naruto Shippuden&lt;/em&gt;. Somewhere deep in that document, chunked out on its own, lives this sentence: "He entered Sage Mode and used it to counter the attack, saving the village."&lt;/p&gt;

&lt;p&gt;Cool. Except... who's "he"? Which attack? Which village? The names "Naruto" and "Pain" and "Konoha" all got mentioned two paragraphs earlier, in a completely different chunk. This isolated little sentence has no idea it's talking about Naruto's fight against Pain. Its embedding doesn't know either, and here's the part that actually matters, neither does a keyword index, because the words "Naruto" and "Pain" never appear in this chunk's text at all. Hybrid search doesn't help here, because there's nothing to match against for either retrieval method&lt;/p&gt;

&lt;p&gt;This isn't a rare edge case. It's the default behavior of naive chunking, and it's arguably the single biggest reason RAG systems either return nothing useful or hallucinate with total confidence. Fixed-size, recursive, and semantic chunking all have this same blind spot, &lt;em&gt;they decide where to cut, but none of them fix what the chunk loses in the process&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Two real fixes for this showed up in 2024, and both are super cool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix #1: Anthropic's Contextual Retrieval: Just Tell the Chunk What It Is
&lt;/h2&gt;

&lt;p&gt;Anthropic published this idea in September 2024, and the core insight is almost too simple: before you embed a chunk, have a fast LLM read the &lt;em&gt;whole&lt;/em&gt; document and write a brief context, usually 50 to 100 tokens, explaining what this specific chunk is about and where it sits in the bigger picture. Something like “This section discusses X in the context of document Y about Z.”  Then you add the context to the chunk before embedding it.&lt;/p&gt;

&lt;p&gt;So instead of embedding:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"He entered Sage Mode and used it to counter the attack, saving the village."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You embed:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"This chunk is from the Pain Assault Arc from Naruto Shippuden,set in Konoha, describing Naruto's fight against Pain. He entered Sage Mode and used it to counter the attack, saving the village."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Same underlying fact, but a very different embedding. The first one could look like a million other generic "hero powers up and saves the day" sentences floating around in vector space, it could belong to almost any shonen protagonist, in almost any arc. The second one is much more clearly "Naruto, Pain Assault Arc, Sage Mode, Konoha" because now the context is actually part of the text.&lt;/p&gt;

&lt;p&gt;The numbers here are genuinely worth your attention. Anthropic's own testing found that adding these contextual embeddings alone cut the retrieval failure rate meaningfully; stacking contextual embeddings &lt;em&gt;with&lt;/em&gt; a contextual version of keyword search (BM25) can reduce the failure rate by 49%; and adding a reranking step on top of both took it down even further by 67%, bringing what started as roughly a 1-in-17 miss rate down to closer to 1-in-50. &lt;br&gt;
That's a pretty big jump for something that, at first glance, sounds like "just add a little context to the chunk."&lt;/p&gt;

&lt;p&gt;And it doesn't seem to be only a benchmark thing, either. Some engineering teams that tried the technique on their own datasets have reported solid double-digit improvements too. So, thankfully, this isn't one of those papers where the chart looks beautiful and your production system politely refuses to cooperate.&lt;/p&gt;

&lt;p&gt;Here's what that looks like&lt;/p&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%2Fnhv3nuou78pg39u621xa.webp" 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%2Fnhv3nuou78pg39u621xa.webp" alt="Chart showing reranked contextual embedding and BM25 reduce retrieval failure by 67%" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
Reranked Contextual Embedding and Contextual BM25 reduces the top-20-chunk retrieval failure rate by 67%.&lt;br&gt;
&lt;em&gt;&lt;strong&gt;Source:&lt;/strong&gt; &lt;a href="https://www.anthropic.com/engineering/contextual-retrieval" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There is a catch, though: Contextual Retrieval isn't free. You're making an LLM call for each chunk, and at large scale, especially with documents that change frequently, that cost can start to matter. At the same time, prompt caching changes the math quite a bit. If you're processing a document's chunks together, the source document can stay cached while you generate context for each chunk, making those follow-up calls much cheaper.&lt;/p&gt;

&lt;p&gt;So the cost is often closer to a one-time indexing expense than something you pay every time someone asks a question. Worth testing on your own documents before assuming the published numbers transfer directly. &lt;/p&gt;

&lt;h2&gt;
  
  
  Fix #2: Late Chunking: Just Do the Whole Thing Backwards
&lt;/h2&gt;

&lt;p&gt;Here's a genuinely clever reversal, published by Jina AI around the same time in 2024. Instead of chunking first and embedding each piece separately (the way literally everyone does it), late chunking flips the order: embed the &lt;em&gt;entire&lt;/em&gt; document first using a long-context embedding model, and only split it into chunks &lt;em&gt;after&lt;/em&gt;, right before the final pooling step that produces the vector.&lt;/p&gt;

&lt;p&gt;Here's how it works:&lt;br&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%2Fftkjld4llj1i0ylrawmb.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%2Fftkjld4llj1i0ylrawmb.png" alt="naive VS late chunking" width="783" height="430"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;&lt;strong&gt;Source:&lt;/strong&gt; &lt;a href="https://arxiv.org/pdf/2409.04701" rel="noopener noreferrer"&gt;arxiv.org&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Why does this matter? Because when you embed the whole document at once, the model's internal representation of any given sentence already "knows" about the sentences around it, the pronouns, the company names, the earlier context...before chunking ever happens. Splitting afterward means each resulting chunk embedding still carries traces of that full-document understanding, without needing a separate LLM call to explain it. Same problem as Contextual Retrieval, solved from the opposite direction, no extra generation step, just a smarter place to make the cut.&lt;/p&gt;

&lt;p&gt;The tradeoff: this needs an embedding model that can actually handle long context in the first place (thousands of tokens, not the traditional 512-token ceiling), so it's less of a drop-in fix and more of a "does your embedding model support this" decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  So... Which One Do You Actually Use?
&lt;/h2&gt;

&lt;p&gt;Honest answer: it depends on what you're optimizing for.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If your documents don't change often and you can afford a one-time indexing cost, &lt;strong&gt;Contextual Retrieval&lt;/strong&gt; is the more battle-tested, widely-adopted option with the clearest published numbers behind it.&lt;/li&gt;
&lt;li&gt;If you're working with a long-context embedding model already and want to skip the extra LLM-call overhead entirely, &lt;strong&gt;Late Chunking&lt;/strong&gt; gets you a similar win more cheaply, assuming your embedding model is up to the task.&lt;/li&gt;
&lt;li&gt;If your content is genuinely narrative, think fiction, long-form articles where topic boundaries actually move around , there's also work like LumberChunker, which uses an LLM to dynamically find where content actually shifts rather than assuming paragraphs mark it; its authors reported a 7.37% retrieval improvement over the strongest baseline they tested against..&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the classic fixed/recursive/semantic split from earlier? Still relevant, they're about &lt;em&gt;where&lt;/em&gt; you cut. Contextual Retrieval and Late Chunking are about &lt;em&gt;what the chunk remembers after the cut&lt;/em&gt;. You genuinely need both decisions, and mixing them (say, recursive chunking &lt;em&gt;plus&lt;/em&gt; contextual embeddings) is completely normal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;Chunking isn't the boring setup step before the "real" RAG work starts. It's the step that decides whether everything downstream like retrieval, reranking, generation is working with chunks that actually know what they're talking about, or chunks that are quietly missing context about their own document. Fix it here, and you'll notice the rest of the pipeline getting easier to trust.&lt;/p&gt;

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