<?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: Manoj Sethi</title>
    <description>The latest articles on DEV Community by Manoj Sethi (@manojsethi).</description>
    <link>https://dev.to/manojsethi</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%2F4151311%2Fc46806e0-3374-4341-a71d-574ed35c90d2.png</url>
      <title>DEV Community: Manoj Sethi</title>
      <link>https://dev.to/manojsethi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/manojsethi"/>
    <language>en</language>
    <item>
      <title>Your speech-to-text sends the recording. It only needed the words.</title>
      <dc:creator>Manoj Sethi</dc:creator>
      <pubDate>Thu, 22 Oct 2026 13:23:05 +0000</pubDate>
      <link>https://dev.to/manojsethi/your-speech-to-text-sends-the-recording-it-only-needed-the-words-2gkg</link>
      <guid>https://dev.to/manojsethi/your-speech-to-text-sends-the-recording-it-only-needed-the-words-2gkg</guid>
      <description>&lt;p&gt;A speech feature has one job: turn a voice into a paragraph of text. Most of them do it by uploading the voice to a company neither you nor the speaker has heard of. There is a second way, where the model runs on your own server and only the paragraph travels. This is how that is built, what it costs, and the part of it that is still uncomfortable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check it yourself.&lt;/strong&gt; &lt;code&gt;ffmpeg -i clip.webm -ar 16000 -ac 1 -c:a pcm_s16le clip.wav &amp;amp;&amp;amp; ls -l clip.*&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Start with a school and a cupboard full of cassettes. Students have answered questions out loud, a teacher has recorded them, and now somebody has to type out what each student said so it can be marked.&lt;/p&gt;

&lt;p&gt;There are two ways to get that typing done.&lt;/p&gt;

&lt;p&gt;You can put the cassettes in an envelope and post them to a typing bureau in another city. They are good at typing. They are fast and they are cheap. A week later the typed pages come back, and they are correct. But the bureau has the cassettes. They have a shelf, and the shelf has your students’ voices on it, and what happens to that shelf is now a question about the bureau’s policies rather than yours.&lt;/p&gt;

&lt;p&gt;Or you can put a typist in the school office. The cassettes never leave the building. The typed pages come out of the same room they went into. The typist is slower than the bureau and you had to find a desk for them, and that is the whole of the trade.&lt;/p&gt;

&lt;p&gt;Both ways give you the same page of text, and the page of text is the only thing the marking ever reads. This piece is about building the second one in software, in a platform where the students are real and the recordings are of children and young adults taking a practice exam.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to get the same page of text
&lt;/h2&gt;

&lt;p&gt;In a browser, a spoken answer becomes an audio file the moment the student stops talking. From that point it has to reach a model that can read it. There are exactly two places that model can be.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgxgzakvz9aohryre5mg8.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%2Fgxgzakvz9aohryre5mg8.png" alt="Two routes, first state: sending the recording puts the voice on someone else’s computer" width="800" height="313"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F51snkhhm3cxoj9ejwt31.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%2F51snkhhm3cxoj9ejwt31.png" alt="Two routes, second state: sending only the words, a paragraph of text with no voice in it" width="800" height="290"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Both routes end with the same paragraph. Only one of them puts the voice itself on a machine you do not own.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Nobody chooses the first route because they want to share the audio. They choose it because it is four lines of code and the other one is an afternoon of work. So it is worth being precise about what the four lines actually do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why everyone calls the API
&lt;/h2&gt;

&lt;p&gt;A hosted speech API is genuinely excellent. It is more accurate than a small model on your own hardware, it handles accents better, it costs a fraction of a cent a minute, and it scales to a thousand students without you thinking about it once. If you are building a voice note feature for a to-do app, call the API. That is the right answer and this piece is not arguing with it.&lt;/p&gt;

&lt;p&gt;It stops being the right answer when three things are true at the same time. The audio is of a person who did not choose the vendor. The audio is of them being tested, which means it captures them at their least polished. And the organisation running the test has to be able to answer, in writing, what happens to it.&lt;/p&gt;

&lt;p&gt;An exam platform hits all three. A student sits down to practise, speaks for two minutes about their home town, and has no idea that a copy of their voice is now on a server in a country they have never visited, under an agreement they never read. The institute cannot tell them what happens to it either, because the institute does not know.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The question that decides it&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If a parent wrote in and asked where their child’s voice recordings go, could you answer with a sentence rather than a link to somebody else’s privacy policy? If not, the audio is in the wrong place.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What the model actually needs
&lt;/h2&gt;

&lt;p&gt;Here is the part that makes the local route obvious once you see it. The model does not want the file the browser produced. It wants something much plainer, and much bigger.&lt;/p&gt;

&lt;p&gt;A browser records compressed audio, because compressed audio is small and small is good on a network. The model cannot read compression. It wants raw samples: the height of the sound wave measured sixteen thousand times a second, two bytes per measurement, one channel. Multiply that out for a minute of speech and you get 16,000 × 2 × 60, which is 1,920,000 bytes. Nearly two megabytes for one minute of a person talking.&lt;/p&gt;

&lt;p&gt;And what comes back out of the model is a paragraph.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7xktrd19fx02v4kjlfps.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%2F7xktrd19fx02v4kjlfps.png" alt="One minute of speech measured three ways: 120 KB as the browser records it, 1.92 MB as the model wants it, about 900 bytes of transcript" width="799" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One minute of speech, measured three ways. The last bar is the only one the scoring step ever reads.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Look at that gap for a moment. The thing you would be uploading is roughly two thousand times larger than the thing you actually wanted, and it is the only one of the three that can be played back to identify a person. The text cannot be played. Nobody can listen to a paragraph and recognise a voice in it.&lt;/p&gt;

&lt;p&gt;Which means the audio is not the output. It is scaffolding. It exists for the length of one function call and it has no reason to travel.&lt;/p&gt;

&lt;h2&gt;
  
  
  The seven steps, in order
&lt;/h2&gt;

&lt;p&gt;This is the path a single spoken answer takes in the platform. On the &lt;a href="https://boffincoders.com/architecture/speech-to-text-keeps-the-recording?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=speech-to-text" rel="noopener noreferrer"&gt;original article&lt;/a&gt; you can click through the steps one at a time. The image below shows all seven.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffyh7yiq2xj0omjzyugqu.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%2Ffyh7yiq2xj0omjzyugqu.png" alt="The seven steps a spoken answer takes, from the empty-audio guard to the scoring request" width="800" height="326"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every step up to the transcript happens inside infrastructure the institute controls. Only the last one sends anything to an AI vendor, and what it sends is text.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first step is the cheapest and the one most people skip. A browser will sometimes hand over a recording that contains no sound at all, only the wrapper around it. Feed that to ffmpeg and it fails with a parsing error, and you have spent a process spawn and a log line on nothing.&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="c1"&gt;// speaking-stt.service.ts&lt;/span&gt;
&lt;span class="c1"&gt;// A container stub with no audio in it. ffmpeg cannot parse these&lt;/span&gt;
&lt;span class="c1"&gt;// ("EBML header parsing failed"), so there is no point spawning it.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MIN_AUDIO_BYTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audioBuffer&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;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MIN_AUDIO_BYTES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;slog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STT&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;Skipping empty audio buffer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;bufferBytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;audioBuffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;""&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;A thousand and twenty-four bytes is not a clever threshold. It is simply smaller than any real recording and larger than any empty one, and it was picked after watching what actually arrived.&lt;/p&gt;

&lt;p&gt;Then the conversion. This is the whole of it:&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="c1"&gt;// speaking-stt.service.ts - webmToWav()&lt;/span&gt;
&lt;span class="c1"&gt;// WebM/Opus from the browser → the only shape the model will read.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;execFileAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ffmpegPath&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="s2"&gt;-i&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;inputPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-ar&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;16000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// 16,000 samples a second&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-ac&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;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// one channel, not two&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-c:a&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;pcm_s16le&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// plain 16-bit samples, no compression&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-y&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;outputPath&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;Three flags carry all the meaning. &lt;code&gt;-ar 16000&lt;/code&gt; sets the sample rate, &lt;code&gt;-ac 1&lt;/code&gt; collapses stereo to one channel because a microphone in a laptop was never really recording two, and &lt;code&gt;-c:a pcm_s16le&lt;/code&gt; says store the raw numbers with no compression at all. The output is the two-megabyte file from the chart above, sitting on local disk, going nowhere.&lt;/p&gt;

&lt;p&gt;Then the model reads it, in the same process that wrote the file:&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="c1"&gt;// speaking-stt.service.ts&lt;/span&gt;
&lt;span class="c1"&gt;// Loaded once, kept in memory for the life of the process.&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;pipeline&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="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@huggingface/transformers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;whisperPipeline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;automatic-speech-recognition&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WHISPER_MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Use fp32 for CPU compatibility&lt;/span&gt;
  &lt;span class="na"&gt;dtype&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fp32&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// And the call itself. No network in this line.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;whisper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wavPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;english&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;return_timestamps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;chunk_length_s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&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;Two details worth naming. &lt;code&gt;fp32&lt;/code&gt; means full-precision numbers, which is slower than the half-precision a graphics card would use, and it is chosen because this runs on an ordinary CPU with no graphics card at all. &lt;code&gt;chunk_length_s: 30&lt;/code&gt; means the model reads the audio in thirty-second pieces, which is what lets a two-minute answer work at all rather than running out of memory.&lt;/p&gt;

&lt;p&gt;And then the temporary files go, in a &lt;code&gt;finally&lt;/code&gt;, so they go even when the step above them threw:&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="c1"&gt;// speaking-stt.service.ts&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;slogError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STT&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;Transcription failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STT_ROUTING&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Return empty rather than crashing session&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;webmPath&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unlinkSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;webmPath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ignore */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wavPath&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wavPath&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unlinkSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wavPath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ignore */&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;Notice the catch. A failed transcription returns an empty string rather than an error. A student in the middle of a spoken exam should not see their session die because a model had a bad minute. We will come back to what happens to that empty string, because it is the reason for one of the least comfortable parts of this design.&lt;/p&gt;

&lt;h2&gt;
  
  
  There is a switch, and you hold it
&lt;/h2&gt;

&lt;p&gt;Honesty first: this system can also send the audio away. There is one environment variable, and it has two branches.&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="c1"&gt;// the routing&lt;/span&gt;
&lt;span class="c1"&gt;// The whole routing decision. Two branches, one env var.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STT_ROUTING&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;local&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;transcribeLocal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wavPath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// Whisper, in this process&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;transcribeViaRemote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wavPath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Groq or OpenRouter&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;STT_ROUTING&lt;/code&gt; to &lt;code&gt;local&lt;/code&gt; and the model runs in the API process. Set it to anything else and the WAV goes to Groq or OpenRouter, whichever is currently selected. That branch is real, it works, and it is faster than the local one.&lt;/p&gt;

&lt;p&gt;A design that pretended otherwise would be a worse design. The switch exists because an institute running a thousand mocks a week on a small server has a genuine reason to want the remote path, and because a platform with no escape hatch is a platform that fails on the day the CPU cannot keep up.&lt;/p&gt;

&lt;p&gt;What matters is where the switch lives. It is a server-side setting in the institute’s own deployment. It is not a default, it is not buried in a vendor’s dashboard, and it does not change by itself when somebody upgrades a library. If the recordings are going out, somebody made that decision and can say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this costs you
&lt;/h2&gt;

&lt;p&gt;Now the honest bill. Running the model yourself is slower, and it is slower in a specific way that will surprise you if nobody says it out loud.&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="c1"&gt;// the concurrency gate&lt;/span&gt;
&lt;span class="c1"&gt;// One transcription at a time. Every call waits for the last one.&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;transcriptionQueue&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="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&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;runExclusive&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&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;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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="nx"&gt;T&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="nx"&gt;previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;transcriptionQueue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="o"&gt;=&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;transcriptionQueue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&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="k"&gt;void&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;resolve&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;release&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;task&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;();&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;That is a queue of one. Every transcription waits for the one before it. It is there because a Whisper model on a CPU will happily use every core it can reach, and four of them running at once do not go four times faster, they go slower and take the rest of the API down with them.&lt;/p&gt;

&lt;p&gt;So when twenty students finish a spoken section at the same time, the twentieth transcript is produced after nineteen others. On this platform that is acceptable, and the reason is worth understanding rather than assuming. Transcription does not happen while a student is waiting. It happens after a turn ends, in the background, while the student is already being asked the next question. The number that has to stay small is the time before the student sees a band and written feedback at the end, and that has minutes of room in it, not seconds.&lt;/p&gt;

&lt;p&gt;If your product needs live captions on a call, this design is wrong for you and no amount of tuning will fix it. The queue of one is not a limitation to work around. It is the shape of the decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  The recording is still kept
&lt;/h2&gt;

&lt;p&gt;Here is the part that is easy to oversell and should not be. Running the model locally does not mean the audio disappears. The original recording is uploaded to the institute’s own object storage with a private access rule, and it stays there.&lt;/p&gt;

&lt;p&gt;It is kept on purpose, and this is why:&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="c1"&gt;// speaking-session.service.ts - retranscribeMissingTurns()&lt;/span&gt;
&lt;span class="c1"&gt;// Why the original recording is kept. Scoring runs this first.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;transcript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;transcribeAudio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// transcribeAudio swallows provider errors and returns "" — still failing,&lt;/span&gt;
  &lt;span class="c1"&gt;// leave the turn empty; the next scoring attempt / manual rescore retries.&lt;/span&gt;
  &lt;span class="k"&gt;continue&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;Remember the empty string from earlier. Before a session is scored, the system looks for turns that have a recording but no transcript, fetches the recording back out of storage, and tries again. A student who lost a transcript to a bad minute gets their answer marked anyway, and never finds out anything went wrong. That recovery is only possible because the audio was kept.&lt;/p&gt;

&lt;p&gt;So the claim this architecture earns is narrow and worth stating exactly. The recording is stored in storage the institute controls, under an access rule the institute sets, and it is never handed to a third-party model vendor. It is not “the audio is deleted”. Anyone telling you a speech feature keeps no audio at all should be asked how it retries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this breaks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Accuracy.&lt;/strong&gt; A small model on a CPU is less accurate than the best hosted one, particularly on strong accents, and on an exam platform the accent is the whole point. This is the real cost and it is not fully solved. A larger model is one setting away and considerably slower.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The scoring step still leaves.&lt;/strong&gt; The transcript is sent to a language model for a band and written feedback. That is a genuine external call and the piece would be dishonest without saying so. What crosses is text, which cannot be played back and does not carry a voice, but it is not nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first request is slow.&lt;/strong&gt; The model loads once and is held in memory. The request that triggers the load waits for it. After a deploy, somebody pays that cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It consumes the server you already have.&lt;/strong&gt; Transcription competes with everything else the API is doing. On a small box under real load, that is felt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ffmpeg is a dependency with opinions.&lt;/strong&gt; It ships as a binary, it has to be executable in your container, and the day it is not, every spoken answer silently produces an empty transcript.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check it on your own machine
&lt;/h2&gt;

&lt;p&gt;You do not have to take the size argument on trust. Record anything in a browser, save the file, and run the same conversion this platform runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# terminal&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; clip.webm &lt;span class="nt"&gt;-ar&lt;/span&gt; 16000 &lt;span class="nt"&gt;-ac&lt;/span&gt; 1 &lt;span class="nt"&gt;-c&lt;/span&gt;:a pcm_s16le clip.wav
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; clip.&lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The WAV will be roughly fifteen times the size of the WebM, and it will be about 1.9 megabytes for every minute. That is the file a hosted API receives. Then look at the transcript it gives back and count the characters.&lt;/p&gt;

&lt;p&gt;And if you are choosing a speech vendor rather than building one, there is a shorter version of the same test. Ask them one question: after the transcript is returned, is the audio still on your systems, and for how long? A vendor who can answer that in a sentence is fine. A vendor who sends you a policy page is telling you the honest answer without meaning to.&lt;/p&gt;

&lt;p&gt;The uncomfortable truth in all of this is that the local route is not better engineering. It is slower, less accurate and more work, and a good engineer looking only at the code would pick the API. It wins on the one axis that does not appear in the code at all, which is that the person whose voice it is never got a say. That is not a technical argument, and it is still the right one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions about this
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can you run speech to text without sending audio to a cloud API?
&lt;/h3&gt;

&lt;p&gt;Yes. A Whisper model runs inside your own API process. The recording is converted to 16 kHz mono WAV on your server, read by the model in the same process, and only the resulting text leaves the machine. In the platform this piece is drawn from, the model is openai/whisper-small running on CPU in fp32.&lt;/p&gt;

&lt;h3&gt;
  
  
  What audio format does Whisper need?
&lt;/h3&gt;

&lt;p&gt;Mono PCM at 16,000 samples per second. Browser recordings arrive as compressed WebM or Opus, so ffmpeg converts them first with -ar 16000 -ac 1 -c:a pcm_s16le. That conversion makes the file much larger, which is one reason the conversion belongs on the same machine as the model.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is local speech to text slower than a cloud API?
&lt;/h3&gt;

&lt;p&gt;On CPU, yes. The platform described here runs one transcription at a time behind a promise queue, so twenty students finishing together are transcribed in sequence rather than in parallel. It is acceptable because transcription happens after a spoken turn ends, not while the student is waiting to speak.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does running the model locally mean the audio is deleted?
&lt;/h3&gt;

&lt;p&gt;No, and it should not. The original recording is stored in the institute’s own object storage with a private ACL, because a failed transcription is retried from it later. Local processing changes who the audio is shared with, not whether it is kept.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://boffincoders.com/architecture/speech-to-text-keeps-the-recording?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=speech-to-text" rel="noopener noreferrer"&gt;Boffin Coders&lt;/a&gt;. If you need speech, OCR or scoring that runs on hardware you control, see &lt;a href="https://boffincoders.com/services/ai-automation?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=speech-to-text" rel="noopener noreferrer"&gt;how AI automation is built at Boffin Coders&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>privacy</category>
      <category>security</category>
    </item>
    <item>
      <title>Why your RAG chatbot ignores the rules you gave it</title>
      <dc:creator>Manoj Sethi</dc:creator>
      <pubDate>Tue, 20 Oct 2026 13:23:05 +0000</pubDate>
      <link>https://dev.to/manojsethi/why-your-rag-chatbot-ignores-the-rules-you-gave-it-29ob</link>
      <guid>https://dev.to/manojsethi/why-your-rag-chatbot-ignores-the-rules-you-gave-it-29ob</guid>
      <description>&lt;p&gt;Your support bot has one rule: never promise a refund. A customer asks for a refund. The bot promises one. The rule was in the knowledge base the whole time - it just was not in the part the model was given.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check it yourself.&lt;/strong&gt; &lt;code&gt;context budget ÷ knowledge base size - usually under 30%&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measured in one system&lt;/strong&gt;&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;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Knowledge base&lt;/td&gt;
&lt;td&gt;42,144 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Was sending&lt;/td&gt;
&lt;td&gt;first 38,000 characters - 23%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per request&lt;/td&gt;
&lt;td&gt;12,764 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Now&lt;/td&gt;
&lt;td&gt;136 chunks, ~3,700 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Always sent&lt;/td&gt;
&lt;td&gt;10 pinned chunks, 1,262 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scoring&lt;/td&gt;
&lt;td&gt;BM25, k1 1.5, b 0.75&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here is a situation worth picturing before anything technical. You run a support bot. Somewhere in its knowledge base is one line: &lt;em&gt;never promise a refund - refunds are decided by the billing team.&lt;/em&gt; A customer asks for a refund. The bot promises one. You now have a customer holding a chat transcript, and a rule that was in the file the whole time.&lt;/p&gt;

&lt;p&gt;Nothing was jailbroken. Nobody wrote a clever prompt. The rule simply was not in the text the model received for that question, and this piece is about why that happens.&lt;/p&gt;

&lt;p&gt;Most guides about retrieval stop at two topics. The first is &lt;strong&gt;chunking&lt;/strong&gt;, which means cutting your documents into smaller pieces so that a single piece can be sent to the model. The second is &lt;strong&gt;embeddings&lt;/strong&gt;, which means converting those pieces into numbers, so that a computer can measure how similar two pieces of text are. Both topics are useful and both are worth learning.&lt;/p&gt;

&lt;p&gt;But a system that is already running in production does not usually fail at either of them. It fails somewhere else, and it fails silently. Let us go through how that happens, step by step.&lt;/p&gt;

&lt;p&gt;Your knowledge base keeps growing. Somebody adds a new FAQ, a new policy page, a new product description. This is normal and it is a good sign. Meanwhile, a language model can only read a fixed amount of text in one request. That limit is called the &lt;strong&gt;context budget&lt;/strong&gt;, and it is fixed by your provider and your plan, not by you.&lt;/p&gt;

&lt;p&gt;At some point your knowledge base becomes bigger than that budget. From that day onward, some part of your content has to be left out of every single request. There is no way around it. The text simply does not fit.&lt;/p&gt;

&lt;p&gt;So now something has to decide which parts get sent and which parts get dropped. This is the most important decision in your entire system, because the model can only answer using the text it actually receives. And in most systems, this decision is not made by any real selection logic. It is made by one line of code that takes the first few thousand characters of a file and throws away the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure what your bot actually sees
&lt;/h2&gt;

&lt;p&gt;Before changing anything, find out how big the problem is in your own system. You need two numbers. The first is the total size of the knowledge base you have written. The second is the size of the portion you send with each question.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# the five-minute check&lt;/span&gt;
&lt;span class="c"&gt;# What the model actually receives, versus what you wrote.&lt;/span&gt;
&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; KNOWLEDGE.md          &lt;span class="c"&gt;# 164,098&lt;/span&gt;
&lt;span class="c"&gt;# and in the route:&lt;/span&gt;
KNOWLEDGE_CHAR_LIMIT        &lt;span class="c"&gt;# 38,000&lt;/span&gt;

&lt;span class="c"&gt;# 38,000 / 164,098 = 23%.&lt;/span&gt;
&lt;span class="c"&gt;# The other 77% has never been in a single request.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the system these numbers are taken from, the result was 23 per cent. Read that again, because it is the important part. Seventy-seven per cent of everything that had been written for that assistant had never been sent to the model. Not once, for any question, by any user.&lt;/p&gt;

&lt;p&gt;The cause was not carelessness. The provider allowed 12,000 tokens per minute. Each request was using 12,764 tokens, which is above that limit. So somebody reduced the amount of knowledge until the request fitted. Under a hard limit, that is a reasonable thing to do. The problem is that it was never revisited, and it was never called what it actually is: throwing away three quarters of your content.&lt;/p&gt;

&lt;p&gt;Run this division on your own system before you continue reading. It takes five minutes and most teams are surprised by the answer.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why nobody notices this&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A shortened knowledge base does not produce error messages. It produces confident answers, built from whatever text happened to be included. Those answers look exactly like confident answers built from your full content. Your logs show a successful request either way. There is no alert, no exception and no red line in a dashboard. This is why the problem can sit in a live system for months.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Taking the first part of a file is not choosing
&lt;/h2&gt;

&lt;p&gt;This single line decides what your model knows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// the common default&lt;/span&gt;
&lt;span class="c1"&gt;// The version almost everyone ships first.&lt;/span&gt;
&lt;span class="c1"&gt;// It is not a bug. It is a budget, spent in file order.&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;knowledge&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="nx"&gt;KNOWLEDGE_CHAR_LIMIT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It takes the first N characters of the file and stops there. Nothing else is considered. Which sections end up inside those characters depends only on the order in which the file was assembled by your build script.&lt;/p&gt;

&lt;p&gt;Think about what that means in practice. A visitor asks a question about video streaming. The model receives whichever pages the build script happened to write first - perhaps your company overview, your pricing page and two service pages that have nothing to do with video. The section that actually answers the question is sitting at character 140,000 of the file, and nothing in this code will ever reach it.&lt;/p&gt;

&lt;p&gt;Notice what decides the outcome here. It is the position of the text inside the file. Something written near the top gets sent. The same text, written near the bottom, never gets sent. The question the visitor asked plays no part in it at all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6i8ozob540b7swnlg1el.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%2F6i8ozob540b7swnlg1el.png" alt="A fixed prefix: twelve knowledge base sections in file order, the model has room for four, and the first four are sent whatever the question. Three of four sections are irrelevant to " width="799" height="554"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5ybst9slozbghyekgh57.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%2F5ybst9slozbghyekgh57.png" alt="Scored against the question " width="799" height="554"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The same twelve sections, cut two ways. Switch between a fixed prefix and a score against the question.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoring, and why not embeddings
&lt;/h2&gt;

&lt;p&gt;The fix is to give each section a score against the question that was asked, and then send only the sections with the highest scores. For a knowledge base of a few hundred sections, an algorithm called BM25 does this job well.&lt;/p&gt;

&lt;p&gt;BM25 works on a simple idea. If a section contains the words from the question many times, it is probably relevant. But that idea alone is not enough, so BM25 applies two corrections to it.&lt;/p&gt;

&lt;p&gt;The first correction handles repetition. If a word appears twenty times instead of five, the section is not four times more relevant. So after a certain point, extra repetitions stop adding to the score.&lt;/p&gt;

&lt;p&gt;The second correction handles length. A very long document will naturally contain more of your words, just because it contains more words in total. So BM25 divides by document length, which stops long pages from winning simply for being long.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// the whole scorer&lt;/span&gt;
&lt;span class="c1"&gt;// Okapi BM25. Term frequency, saturated; document length, normalised.&lt;/span&gt;
&lt;span class="c1"&gt;// k1 caps how much repetition can help. b decides how much length hurts.&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;term&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;queryTerms&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;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;doc&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;t&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;t&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;term&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&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;continue&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;docFrequency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;term&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idf&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;docCount&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.5&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;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

  &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;idf&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;K1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;K1&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="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;B&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;B&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;avgLen&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;That is the complete algorithm. There is nothing hidden behind it. It has been the standard baseline in information retrieval since the 1990s, it needs no model, no vector database and no API call, and over 136 sections it finishes in under a millisecond.&lt;/p&gt;

&lt;p&gt;You may be wondering why we did not use embeddings here, since that is what most articles recommend. Embeddings compare meaning rather than words, and they are genuinely better when you have a very large number of documents - a hundred thousand or more. At that size, matching words simply stops working.&lt;/p&gt;

&lt;p&gt;But at a few hundred sections, the list is short enough to score exactly. Adding embeddings at this size means adding a monthly bill, one more dependency in your project, and a separate index that has to be rebuilt every time your content changes. You would be paying all of that to sort a list that BM25 already sorts correctly. Add embeddings on the day you can show BM25 failing on real questions from real users, and not before that day.&lt;/p&gt;

&lt;p&gt;BM25 does have one clear weakness, and it is worth knowing. It compares words, not meaning. If a customer types "how cheap is it", that question shares no word with a section titled "Pricing". The score will be zero. The practical fix is not complicated: write down ten or twenty synonyms for the terms your customers actually use - cheap, cost, price, budget, rate - and expand the question before scoring it. This costs nothing and closes most of the gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your rules are documents too
&lt;/h2&gt;

&lt;p&gt;Now back to the refund.&lt;/p&gt;

&lt;p&gt;Your knowledge base contains rules. Never promise a refund. Never quote a price you cannot confirm. Never name another client. Say you do not know, rather than guessing. These are the most important sentences in the file, and in most systems they sit in that file as ordinary paragraphs, next to everything else.&lt;/p&gt;

&lt;p&gt;Here is the problem. As soon as you add relevance scoring, those rules become documents like any other document in the file. They have no special status. They compete for space against product descriptions and FAQ answers, and they are judged by exactly the same measure: how many words they share with the question.&lt;/p&gt;

&lt;p&gt;Watch what the scorer does with "can I get a refund?". Your refund policy page scores high - it is about refunds and it uses the word repeatedly. Your billing FAQ scores high for the same reason. Your cancellation terms score well too.&lt;/p&gt;

&lt;p&gt;And the rule? &lt;em&gt;Never promise a refund - refunds are decided by the billing team.&lt;/em&gt; Eleven words. It mentions refunds once. Against three pages that discuss refunds in detail, it ranks near the bottom. The budget fills up before it is reached, so it is not sent.&lt;/p&gt;

&lt;p&gt;The model receives three documents explaining how refunds work, and nothing telling it who decides. It then does the reasonable thing with what it was given.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzr64x94l3l52d3dnaj9e.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%2Fzr64x94l3l52d3dnaj9e.png" alt="BM25 scores against " width="799" height="368"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Four sections scored against “can I get a refund?”. The rule that decides the answer is the one left out.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It is worth pausing here, because the first instinct is to blame the model or to rewrite the prompt. Neither of those was at fault. The rule was stored as ordinary reference material, and it lost a relevance contest to the very documents it was written to control.&lt;/p&gt;

&lt;p&gt;There is something uncomfortable in this. The problem gets worse as your retrieval gets better. A tighter budget and a sharper scoring algorithm both push in the same direction - more space for text that matches the question, and less space for text that does not. So a carefully tuned system is more likely to drop its own rules than a careless one. Improving the retrieval, on its own, makes this failure more likely rather than less.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pinned context and scored context
&lt;/h2&gt;

&lt;p&gt;The answer is not a better scoring algorithm. You can spend a month tuning the scores and the refund rule will still lose, because the rule barely mentions refunds. It has almost no words in common with the question. No scoring method will rank it highly, and that is not a bug in the scoring.&lt;/p&gt;

&lt;p&gt;So the answer is to stop scoring some things at all.&lt;/p&gt;

&lt;p&gt;Divide your knowledge base into two groups. Put in the first group everything that must be present in every request, whatever the customer asks. Your prices. Your verified numbers. Every rule about what the assistant must not say - including the refund rule.&lt;/p&gt;

&lt;p&gt;Put everything else in the second group. Product descriptions, FAQs, guides, policy pages, help articles.&lt;/p&gt;

&lt;p&gt;Now send the first group every time, without scoring it. Score only the second group and send whatever fits in the space that is left.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// two categories&lt;/span&gt;
&lt;span class="c1"&gt;// Two categories, not one ranked list.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pinned&lt;/span&gt; &lt;span class="o"&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;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pinned&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// facts, prices, prohibitions&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;   &lt;span class="o"&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;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pinned&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// everything else&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;selected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;pinned&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                    &lt;span class="c1"&gt;// never scored, never dropped&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;used&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pinned&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;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&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;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;for &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;chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;scoreAgainst&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&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;break&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;used&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&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;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
  &lt;span class="nx"&gt;selected&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;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;used&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the system measured here, the first group is ten sections and about 1,262 tokens. That is roughly one third of every request. That one third is now reserved, permanently, for the text that must never go missing.&lt;/p&gt;

&lt;p&gt;Now the refund question works. The refund policy, the billing FAQ and the cancellation terms all score well and get sent, exactly as before. And the rule goes too, because it was never in the competition. The model can now see both the policy and the limit on what it may promise.&lt;/p&gt;

&lt;p&gt;Be careful not to claim too much for this. Pinning does not make a model obey you. A model can read your rule and still ignore it. That is a different problem, and it needs different solutions - testing, evaluation, and a human check on anything expensive.&lt;/p&gt;

&lt;p&gt;What pinning fixes is narrower than that. It fixes the case where the rule was never sent at all, so the model had no chance to follow it. That is a smaller promise than the word "guardrails" usually implies. But you can check this one yourself in a minute, by printing the text that was sent and searching it for your rule. Most guardrail claims cannot be checked that easily.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A test you can run today&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Take the question your strictest rule was written for. For a support bot, that is usually the refund question, or the one about cancelling a contract. Ask it. Then print the text that was retrieved for that question, and search that text for your rule. If the rule is not there, then for this question your rule does not exist. It does not matter how carefully you wrote it, how clear the wording is, or where it sits in your knowledge base.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where this breaks
&lt;/h2&gt;

&lt;p&gt;Scoring only helps when there is something to find. Some questions match nothing at all.&lt;/p&gt;

&lt;p&gt;A customer opens with "hi, can you help me?". There is nothing to score against. A customer sends a follow-up made only of pronouns - "what about that one?" - and again there is nothing. Or somebody asks about a topic your company does not cover, so no section matches because no section exists.&lt;/p&gt;

&lt;p&gt;In all three cases every section scores zero. The model then receives only your pinned text and nothing else. That is worse than the simple version you started with, because that one at least sent something every time.&lt;/p&gt;

&lt;p&gt;Two protections handle this, and neither takes long to build.&lt;/p&gt;

&lt;p&gt;The first is to build the search query from the last two customer messages instead of only the latest one. Then a short follow-up like "what about that one?" still carries the subject from the message before it.&lt;/p&gt;

&lt;p&gt;The second is to set a floor. When scoring returns very little, keep adding sections in file order until you reach a minimum amount of text. In the system measured here, a question that matches nothing still sends about 2,950 tokens, against 3,700 for a question that matches well. The floor makes sure the model is never left with almost nothing.&lt;/p&gt;

&lt;p&gt;Pinning also has a cost, and you should keep an eye on it. Every pinned section is space the question cannot use. If your pinned group keeps growing, it will slowly squeeze out the scored content, and you will be back to sending a fixed block of text no matter what was asked.&lt;/p&gt;

&lt;p&gt;Keeping the pinned group near one third of the budget is a reasonable target. If yours is above one half, look at what is in there. Some of it is probably reference material that somebody pinned because it felt important, not because it is a rule.&lt;/p&gt;

&lt;p&gt;Finally, be clear about what none of this does. It does not make an assistant truthful, and it does not remove the need to test one properly. It removes one failure that was previously silent. We build &lt;a href="https://boffincoders.com/services/chatbots-conversational-ai?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=rag-ignores-rules" rel="noopener noreferrer"&gt;retrieval-grounded assistants&lt;/a&gt; on the assumption that grounding is something you have to keep checking, not something you switch on once and forget about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your own system
&lt;/h2&gt;

&lt;p&gt;Three steps. None of them takes long.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step one. Do the sum.&lt;/strong&gt; Find your context budget - the amount of text you send with each question. Then find the total size of your knowledge base. Divide the first by the second. That percentage is how much of your own content the model ever sees. If it is low, everything in this article applies to you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step two. Look at what one question pulls.&lt;/strong&gt; Pick any question and print the list of sections that were selected for it. Do not read the answer. Read the list. The answer will look fine either way, which is why it tells you nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# what a question retrieves&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;explain &lt;span class="s2"&gt;"can I get a refund?"&lt;/span&gt;

  23.56  Refund policy
  22.13  Billing FAQ
  22.12  Cancellation terms
  16.08  Plans and limits
   2.31  Rule: never promise a refund      ← not sent
  → 3,625 tokens sent

&lt;span class="nv"&gt;$ &lt;/span&gt;explain &lt;span class="s2"&gt;"what about that?"&lt;/span&gt;

  → 2,950 tokens sent   &lt;span class="o"&gt;(&lt;/span&gt;nothing scored&lt;span class="p"&gt;;&lt;/span&gt; the floor filled it&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step three. Test two specific questions.&lt;/strong&gt; These two are the ones that matter.&lt;/p&gt;

&lt;p&gt;First, think of your strictest rule. For a support bot it is usually the refund rule, or the one about cancelling a contract. Now ask the question a customer would ask when that rule applies - "can I get a refund?". Print the list. Is your rule in it?&lt;/p&gt;

&lt;p&gt;Second, type the vaguest thing a real customer might open with. Something like "hi, I need some help". Print the list again. Did anything come back at all?&lt;/p&gt;

&lt;p&gt;The first test tells you whether your rules are reaching the model. The second tells you what your bot does when it has nothing to work with.&lt;/p&gt;

&lt;p&gt;Most teams get a surprise from at least one of these two, and that is exactly why it is worth doing. Neither failure can be seen from outside. A model working with half the text it needed still replies in full sentences, and it still sounds completely sure of itself.&lt;/p&gt;

&lt;p&gt;One idea to take away. Your knowledge base holds two different kinds of text. Some of it is material the model should look at when a question calls for it. The rest is rules that must be there every time, whether they look relevant or not. Score the first kind. Never score the second.&lt;/p&gt;

&lt;p&gt;Related on this site: &lt;a href="https://boffincoders.com/architecture/let-ai-write-the-email-never-send-it?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=rag-ignores-rules" rel="noopener noreferrer"&gt;why the model should draft the email but never send it&lt;/a&gt;, &lt;a href="https://boffincoders.com/services/web-application-development?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=rag-ignores-rules" rel="noopener noreferrer"&gt;web application development&lt;/a&gt;, &lt;a href="https://boffincoders.com/services/generative-ai-llm?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=rag-ignores-rules" rel="noopener noreferrer"&gt;generative AI and LLM development&lt;/a&gt; and &lt;a href="https://boffincoders.com/hire/ai-engineers?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=rag-ignores-rules" rel="noopener noreferrer"&gt;hiring AI engineers&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions about this
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why does my RAG chatbot ignore its instructions?
&lt;/h3&gt;

&lt;p&gt;Often the instruction never reached the model. If your rules are stored in the same knowledge base that retrieval selects from, they compete for space with everything else. On a question where the rule matters, the rule itself may contain none of the question’s terms, so it scores low and is left out.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much of my knowledge base does the model actually see?
&lt;/h3&gt;

&lt;p&gt;Divide your context budget by the size of your knowledge base. Many systems send under 30 per cent, and the part that is sent is often just the first N characters of the file rather than the sections that answer the question.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need embeddings, or is BM25 enough?
&lt;/h3&gt;

&lt;p&gt;BM25 is enough for a few hundred sections and needs no model, vector database or API call. Embeddings earn their cost at very large document counts. Add them when you can show BM25 failing on real questions from real users.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I stop a chatbot dropping its own rules?
&lt;/h3&gt;

&lt;p&gt;Split the knowledge base into pinned and scored. Facts, prices and prohibitions go into every request without being scored. Everything else competes on relevance. Keep the pinned block to roughly a third of the budget so retrieval still has room.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://boffincoders.com/architecture/why-rag-chatbots-ignore-your-rules?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=rag-ignores-rules" rel="noopener noreferrer"&gt;Boffin Coders&lt;/a&gt;. If your assistant has rules that must reach the model on every question, see how &lt;a href="https://boffincoders.com/services/generative-ai-llm?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=rag-ignores-rules" rel="noopener noreferrer"&gt;generative AI and LLM development&lt;/a&gt; is built so that grounding can be printed and checked.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>rag</category>
    </item>
    <item>
      <title>Your rebuild will drop URLs. Google will tell you in six weeks.</title>
      <dc:creator>Manoj Sethi</dc:creator>
      <pubDate>Thu, 15 Oct 2026 13:23:08 +0000</pubDate>
      <link>https://dev.to/manojsethi/your-rebuild-will-drop-urls-google-will-tell-you-in-six-weeks-92b</link>
      <guid>https://dev.to/manojsethi/your-rebuild-will-drop-urls-google-will-tell-you-in-six-weeks-92b</guid>
      <description>&lt;p&gt;A rebuild changes how pages are made. The old addresses stop existing without anyone deciding they should, and Google notices before you do. Here is the check that proves no URL moved, the four things it cannot see, and the failure that cost more than any of them - from a 10,723-page site that went through it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check it yourself.&lt;/strong&gt; &lt;code&gt;curl the sitemap before and after, then diff the URL sets&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The migration&lt;/strong&gt;&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;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Site&lt;/td&gt;
&lt;td&gt;timezones.in - 10,723 pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Templates&lt;/td&gt;
&lt;td&gt;six&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migration&lt;/td&gt;
&lt;td&gt;to Next.js 16, one week&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;URLs lost&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Snapshots&lt;/td&gt;
&lt;td&gt;6 September 2026, either side&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://timezones.in/" rel="noopener noreferrer"&gt;The site itself&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nobody rebuilds a website intending to lose its pages. It happens anyway, and it happens in a specific way: an address gets tidied because the new one reads better, a template gets a cleaner path, a folder is dropped because the new system does not need it. Every one of those decisions is sensible on its own. Together they are why traffic falls off a cliff six weeks after a launch that nobody thought was risky.&lt;/p&gt;

&lt;p&gt;One word before the example. A page’s &lt;strong&gt;URL&lt;/strong&gt; is its address - the part after the domain name, like &lt;code&gt;/word/serendipity&lt;/code&gt;. Google’s memory of a page is tied to that exact address. Change it, and as far as Google is concerned the old page has died and a new, unknown page has appeared. The years of ranking the old address earned do not move across by themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  A dictionary site rebuilds
&lt;/h2&gt;

&lt;p&gt;Take an online dictionary. It has forty thousand pages, one per word, and none of them was written by hand. A template builds each page from a database row: the word, its meaning, its pronunciation. The address of each page is built the same way - a small function that takes the word and turns it into &lt;code&gt;/word/serendipity&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The site is rebuilt on a new framework. The developer, tidying as she goes, notices that the old addresses have an untidy trailing part that the new system does not need, and simplifies the function. &lt;code&gt;/word/serendipity-1&lt;/code&gt; becomes &lt;code&gt;/word/serendipity&lt;/code&gt;. Cleaner. Better. And forty thousand addresses have just changed at once, because they all come from one line of code.&lt;/p&gt;

&lt;p&gt;Nothing warns her. The build passes. The new site looks right, because it is right on its own terms. The old addresses simply stop existing, and the only party who notices is a search engine that will not say anything for weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nobody notices for six weeks
&lt;/h2&gt;

&lt;p&gt;A dropped address does not throw an error. No test goes red. The people who built the site are looking at the new site, where every link works. The people who would hit the old addresses - visitors arriving from Google, from old bookmarks, from links on other sites - are strangers, and strangers do not file bug reports. They see “page not found” and leave.&lt;/p&gt;

&lt;p&gt;Google does notice, slowly. It re-visits the old addresses, finds nothing, and after a few weeks removes them. The traffic graph bends downwards. By then the team has shipped a fortnight of other changes, and the question “which of these did it?” has no cheap answer. The fix has to happen before the launch, or it costs ten times as much afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why “keep the URLs” is not a plan
&lt;/h2&gt;

&lt;p&gt;Everyone says it at the start of a rebuild. On a twenty-page site it even works, because twenty is a number a person can hold in their head and check by eye before launch.&lt;/p&gt;

&lt;p&gt;On a site with thousands of pages built from templates, it is exactly where things go wrong. The templates are the danger: change one line in the function that builds an address and every page in that family moves at once. The blast radius of a one-character edit is the whole site. So “keep the URLs” has to become something a machine checks, not something a person promises.&lt;/p&gt;

&lt;p&gt;There are two halves to that, and they are different jobs. The first is a test inside the build that fails when an address, a title or a heading changes - that protects you while you work. The second is proving, from outside, that the site which actually went live kept them. This piece is about the second, because it is the half you can run against any site, including one you did not build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check: two lists, compared
&lt;/h2&gt;

&lt;p&gt;It is deliberately simple. Every site that cares about search already publishes a complete list of its own addresses. It is called a &lt;strong&gt;sitemap&lt;/strong&gt;, it lives at &lt;code&gt;/sitemap.xml&lt;/code&gt;, and search engines read it to learn what the site contains. Because it is a full list of addresses, it doubles as a record of what the site looked like on a given day. Save it before the launch, and again after:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# two snapshots&lt;/span&gt;
&lt;span class="c"&gt;# Before the deploy, and again after it. Nothing clever  - &lt;/span&gt;
&lt;span class="c"&gt;# the sitemap is the list of URLs you already publish.&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://example.com/sitemap.xml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; before.xml

&lt;span class="c"&gt;# ...deploy...&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://example.com/sitemap.xml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; after.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then compare the two lists. Not the totals - the lists. A total that matches can still hide one page dropped and another added, which is the case that hurts most, because the number looks reassuring.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# diff.py - twelve lines
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;xml&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ignore&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;loc&amp;gt;([^&amp;lt;]+)&amp;lt;/loc&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xml&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&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="nf"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;before:&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;after: &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;gone:  &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;# The count is reassurance. The list is the answer.
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;  DROPPED&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three numbers and a list. The list is the part that matters: it names every address that existed before the launch and does not exist after it, which is the question everyone asks two months too late.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Family&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;/convert/&lt;/td&gt;
&lt;td&gt;9,045&lt;/td&gt;
&lt;td&gt;9,045&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/time/&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/dst/&lt;/td&gt;
&lt;td&gt;248&lt;/td&gt;
&lt;td&gt;248&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/country/&lt;/td&gt;
&lt;td&gt;247&lt;/td&gt;
&lt;td&gt;247&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/abbreviation/&lt;/td&gt;
&lt;td&gt;136&lt;/td&gt;
&lt;td&gt;136&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/utc/&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;static&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Total&lt;/strong&gt; (one page added, none removed)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10,722&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10,723&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Before and after, per template family, from a real migration. One page appeared - a new static route - and nothing was removed.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the check cannot see
&lt;/h2&gt;

&lt;p&gt;A passing diff should not make anyone relax, and this is the part usually left out. An address is one of at least five things a rebuild can change, and the sitemap only knows about one of them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Everything a rebuild touches&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;th&gt;Sitemap diff&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The URL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/convert/EST_to_FJT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a diff catches this&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The page title&lt;/td&gt;
&lt;td&gt;what the result shows in search&lt;/td&gt;
&lt;td&gt;a diff does not&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The H1&lt;/td&gt;
&lt;td&gt;the heading Google matches against the query&lt;/td&gt;
&lt;td&gt;a diff does not&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The canonical&lt;/td&gt;
&lt;td&gt;which version of the page counts&lt;/td&gt;
&lt;td&gt;a diff does not&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Internal links&lt;/td&gt;
&lt;td&gt;how authority moves between pages&lt;/td&gt;
&lt;td&gt;a diff does not&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The redirect map&lt;/td&gt;
&lt;td&gt;what happens to anything that did move&lt;/td&gt;
&lt;td&gt;a diff does not&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;A sitemap lists addresses. Everything else that decides whether a page keeps its rankings is invisible to it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A page can keep its address and lose its title, its main heading, its “canonical” tag - the line in the page that tells Google which address is the official one when several show the same content - and every internal link that pointed at it. The diff returns zero and the rankings still go. A build test can cover titles and headings too. The canonical tag and the links between pages are usually guarded by nothing but attention, which is not a guarantee and should not be described as one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this breaks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The sitemap must come from the same place as the pages.&lt;/strong&gt; If it is written by hand, or built by a separate job, it can describe a site that no longer exists and the diff will happily confirm a fiction. It only works as evidence when the file is generated from the same data that builds the pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An address in the sitemap can still be broken.&lt;/strong&gt; Present in the file, missing on the server: the diff passes. So take a sample of the survivors and check what they actually return:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# sample forty, expect 200&lt;/span&gt;
&lt;span class="c"&gt;# A URL can survive the diff and still be broken: present in the&lt;/span&gt;
&lt;span class="c"&gt;# sitemap, 404 at the server. Sample the survivors.&lt;/span&gt;
&lt;span class="nb"&gt;shuf&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 40 &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;loc&amp;gt;[^&amp;lt;]*'&lt;/span&gt; after.xml | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/&amp;lt;loc&amp;gt;//'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; |
  xargs &lt;span class="nt"&gt;-P&lt;/span&gt; 8 &lt;span class="nt"&gt;-I&lt;/span&gt;&lt;span class="o"&gt;{}&lt;/span&gt; sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'printf "%s %s\n" "$(curl -s -o /dev/null -w %{http_code} "{}")" "{}"'&lt;/span&gt; |
  &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'^200'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"all sampled URLs returned 200"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pages outside the sitemap are outside the check.&lt;/strong&gt; Old campaign pages, PDFs, anything only ever linked from an email - none of it is in the file, so none of it is protected. For those, the equivalent snapshot is a Search Console export of every page that had impressions, taken before the work starts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And none of it matters if the site is down.&lt;/strong&gt; The site these numbers come from was unreachable for about a week shortly before the migration - not a proper “we are down, come back later” answer, which tells a search engine to wait, but a dead connection, which tells it nothing. Every address was preserved perfectly throughout, and preserved addresses on an unreachable server are worth exactly nothing. The fix afterwards was ordinary: the web server now shows a maintenance page with a 503 status and a “retry after” header, so an outage looks like an outage rather than an absence. That was the more expensive lesson of the two.&lt;/p&gt;

&lt;h2&gt;
  
  
  The site these numbers come from
&lt;/h2&gt;

&lt;p&gt;The dictionary is made up. The migration is not. On the day of the move, timezones.in had 10,723 pages generated from six templates - 9,045 of them time-conversion pages built from one address function, which is exactly the one-line blast radius described above. It moved to Next.js 16 in one week in September 2026. The first sitemap snapshot was taken on the morning of 6 September and the second after the deploy the same day: 10,722 addresses before, 10,723 after, none dropped. The build test stopped the addresses moving; the diff proved they had not; and the second of those is checkable by anyone, which is the next section. Whether the rankings held is a separate claim. The site was only verified in Search Console that month, so there is no “before” to compare against - an honest gap, and one worth writing down rather than leaving out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it on your own rebuild
&lt;/h2&gt;

&lt;p&gt;The whole method is two downloads and twelve lines of Python, and it works on any site with a sitemap - including one you are being asked to take over from another agency. Take the first snapshot before anyone touches anything. It is worthless afterwards, and afterwards is when you will want it.&lt;/p&gt;

&lt;p&gt;You can run the same check against the site above. Its sitemap is public and split by template family: &lt;a href="https://timezones.in/sitemap.xml" rel="noopener noreferrer"&gt;timezones.in/sitemap.xml&lt;/a&gt;. The fuller story of that site - what it is, what it cost, what else went wrong - is on &lt;a href="https://boffincoders.com/project/stay-in-sync-timezones-web-application?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=rebuild-without-losing-urls" rel="noopener noreferrer"&gt;its project page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The habit to take away is smaller than the tooling. Before any change that touches how pages are made, save the list of addresses the site publishes today. After the change, compare. If the list is the same, you have proved one thing and only one thing - that the addresses survived - and that is still the one thing most teams find out the hard way, from a graph, six weeks after it was cheap to check.&lt;/p&gt;

&lt;p&gt;Related on this site: &lt;a href="https://boffincoders.com/architecture/redirect-rules-that-eat-live-pages?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=rebuild-without-losing-urls" rel="noopener noreferrer"&gt;when a redirect rule swallows a page that still works&lt;/a&gt;, &lt;a href="https://boffincoders.com/services/web-application-development?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=rebuild-without-losing-urls" rel="noopener noreferrer"&gt;web application development&lt;/a&gt;, &lt;a href="https://boffincoders.com/services/legacy-modernization?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=rebuild-without-losing-urls" rel="noopener noreferrer"&gt;legacy modernisation&lt;/a&gt; and &lt;a href="https://boffincoders.com/hire/nextjs-developers?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=rebuild-without-losing-urls" rel="noopener noreferrer"&gt;hiring Next.js developers&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions about this
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why did my site lose traffic after a redesign?
&lt;/h3&gt;

&lt;p&gt;Usually because page addresses changed. A new framework or template tidies a URL, the old address stops existing, and Google drops the page from its index. Nothing in the build fails, so the first sign is a traffic graph a few weeks later.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I check that a website migration kept all its URLs?
&lt;/h3&gt;

&lt;p&gt;Download the sitemap before the change and again after, then compare the two lists of addresses - not the totals. Every address in the first list that is missing from the second is a page you have lost. Twelve lines of Python do it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is comparing sitemaps enough to protect rankings?
&lt;/h3&gt;

&lt;p&gt;No. A sitemap only lists addresses. A page can keep its address and lose its title, its heading, its canonical tag or the links pointing at it, and rankings go with them. The sitemap check catches one of at least five things a rebuild can move.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is a sitemap?
&lt;/h3&gt;

&lt;p&gt;A file, usually at /sitemap.xml, in which a website lists every page it wants search engines to know about. Because it is a complete list of addresses, it doubles as a record of what the site looked like on a given day.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://boffincoders.com/architecture/rebuild-without-losing-urls?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=rebuild-without-losing-urls" rel="noopener noreferrer"&gt;Boffin Coders&lt;/a&gt;. If you are about to move a large site to a new framework, you can &lt;a href="https://boffincoders.com/hire/nextjs-developers?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=rebuild-without-losing-urls" rel="noopener noreferrer"&gt;hire a Next.js developer&lt;/a&gt; who has run this check on a live migration.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>nextjs</category>
      <category>seo</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Tell Whether Google Actually Indexed What You Published</title>
      <dc:creator>Manoj Sethi</dc:creator>
      <pubDate>Thu, 08 Oct 2026 13:23:06 +0000</pubDate>
      <link>https://dev.to/manojsethi/how-to-tell-whether-google-actually-indexed-what-you-published-4di8</link>
      <guid>https://dev.to/manojsethi/how-to-tell-whether-google-actually-indexed-what-you-published-4di8</guid>
      <description>&lt;p&gt;Publishing a new blog, landing page, or product page is only the first step. Before expecting organic traffic, one must find out if Google has actually found, crawled and indexed the published page.&lt;/p&gt;

&lt;p&gt;One should note that crawling and indexing are two different terms. Google may find a URL but may not add it to its search index. Google also does not guarantee the indexing of every crawled page.&lt;/p&gt;

&lt;p&gt;So, how can one check their new page's status? There are some easy methods of the published page's status verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  Crawling vs. Indexing: What Is the Difference?
&lt;/h2&gt;

&lt;p&gt;Google Search generally works through three stages: crawling, indexing, and serving results.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;What happens?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Crawling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Googlebot discovers and visits your URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Indexing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Google processes the page and decides whether to add it to its index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Serving&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Google may show the indexed page in search results for relevant searches&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A page can therefore be live on your website without being indexed. Meeting Google's technical requirements also does not automatically guarantee indexing.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Use Google Search Console First
&lt;/h2&gt;

&lt;p&gt;The best approach to &lt;a href="https://search.google.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;check Google index status&lt;/strong&gt;&lt;/a&gt; of a page is to use Google Search Console.&lt;/p&gt;

&lt;p&gt;First open the Search Console and use the URL inspection tool. In this case, copy and paste the URL of the page.&lt;/p&gt;

&lt;p&gt;Some insights that could be received via this tool are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Whether the given URL is indexed&lt;/li&gt;
&lt;li&gt;When the last crawling was done&lt;/li&gt;
&lt;li&gt;The canonical version of the same page&lt;/li&gt;
&lt;li&gt;Some crawling and indexing issues&lt;/li&gt;
&lt;li&gt;Whether it’s possible to run the test of the page as the live one&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For understanding the index status of an individual page, it is suggested by Google to use the URL Inspection tool. For website owners, Google Search Console can also serve as a reliable &lt;strong&gt;Google indexing checker&lt;/strong&gt; because it provides information directly from Google's systems.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the Result Means
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Search Console result&lt;/th&gt;
&lt;th&gt;What it tells you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;URL is on Google&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Google has indexed the URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;URL is not on Google&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The page is currently not indexed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;URL has issues&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A technical or indexing problem may need attention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;URL is available to Google&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Google can potentially crawl the page, but this does not guarantee indexing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Don't confuse "Google can access this page" with "Google has indexed this page." They are not the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Try a site: Search
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You can conduct a fast manual verification by searching with Google:&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;site:yourdomain.com/page-url&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Another search you can perform is&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;site: yourdomain.com&lt;/p&gt;

&lt;p&gt;Using these searches will enable you to see which pages of your website have been indexed. Google itself supports the use of the site operator as a conventional method of finding out if pages have been indexed.&lt;/p&gt;

&lt;p&gt;But it is crucial to point out that using the site search does not guarantee that every single URL will be visible. Thus, Google suggests using the URL Inspection tool as the more suitable method for resolving page-related issues.&lt;/p&gt;

&lt;p&gt;Therefore, you should consider the site search as a preliminary check only.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Don't Assume "Not Ranking" Means "Not Indexed"
&lt;/h2&gt;

&lt;p&gt;It is a frequent misconception about SEO. This is why using a &lt;strong&gt;Google indexing checker&lt;/strong&gt; should be the first step before assuming that a page has an indexing problem.&lt;/p&gt;

&lt;p&gt;The process of indexing indicates that Google has taken the page into its index, which doesn’t mean the page will immediately appear at the top of search results.&lt;/p&gt;

&lt;p&gt;Google employs a wide range of technologies and signals to establish which indexed pages will appear for specific queries.&lt;/p&gt;

&lt;h3&gt;
  
  
  How Are Indexing and Ranking Different
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&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;Is my page indexed?&lt;/td&gt;
&lt;td&gt;Can Google include the page in its search index?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is my page ranking?&lt;/td&gt;
&lt;td&gt;Is Google showing it for relevant searches?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is my page getting traffic?&lt;/td&gt;
&lt;td&gt;Are people actually clicking and visiting it?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Keeping these three questions separate makes SEO reporting much easier.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Check the Page Indexing Report
&lt;/h2&gt;

&lt;p&gt;Managing a larger website can result in the impracticality of monitoring all the URLs individually.&lt;/p&gt;

&lt;p&gt;To make things easier, the &lt;strong&gt;Google Page Indexing&lt;/strong&gt; report from Google Search Console gives you a wider overview of the pages that Google has crawled, indexed, or disregarded.&lt;/p&gt;

&lt;p&gt;Thus, you can spot things like&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pages that are not indexed.&lt;/li&gt;
&lt;li&gt;Pages that are not crawled properly.&lt;/li&gt;
&lt;li&gt;Duplicate URLs.&lt;/li&gt;
&lt;li&gt;Pages affected by the noindex attribute.&lt;/li&gt;
&lt;li&gt;URLs discovered by Google but not indexed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This report is especially useful after publishing multiple blogs or introducing a new section of your website.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Check for Common Indexing Problems
&lt;/h2&gt;

&lt;p&gt;If you cannot &lt;strong&gt;check Google index status&lt;/strong&gt; successfully, start by reviewing the technical factors that may be preventing Google from accessing or processing the page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checklist For Technical Issues&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Can Googlebot access the page?&lt;/li&gt;
&lt;li&gt;Does the URL show a responsible HTTP 200 response?&lt;/li&gt;
&lt;li&gt;Is there a noindex instruction?&lt;/li&gt;
&lt;li&gt;Is the page banned by robots.txt?&lt;/li&gt;
&lt;li&gt;Does the page include materials that can be indexed?&lt;/li&gt;
&lt;li&gt;Is the canonical URL leading to another location?&lt;/li&gt;
&lt;li&gt;Is Google able to reach the URL using the sitemap or links within the site?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As described by Google's standards, Google needs to get access to the page, have it function fine, and provide materials that can be indexed.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Use Your Sitemap to Help Google Discover Pages
&lt;/h2&gt;

&lt;p&gt;Sitemaps provide Google access to important URLs of the website and facilitate URL discovery on websites that are large or new but submitting a URL through a Sitemap does not provide a guarantee of indexing.&lt;/p&gt;

&lt;p&gt;A Sitemap serves the purpose of allowing Google to discover your content.&lt;/p&gt;

&lt;p&gt;Rather, you can think of a &lt;a href="https://developers.google.com/" rel="noopener noreferrer"&gt;Sitemap&lt;/a&gt; as a tool to help Google to find and discover the content you have created instead of a tool to ensure that it actively indexes the URLs.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Request Indexing When Appropriate
&lt;/h2&gt;

&lt;p&gt;If you've updated or published a major webpage, you can ask Google to crawl that page through the URL Inspection tool in Search Console.&lt;/p&gt;

&lt;p&gt;While this works on a page-by-page basis, please keep in mind that there is no guarantee regarding the timing of indexing.&lt;/p&gt;

&lt;p&gt;Google states that crawling can take between a few days and a few weeks, and making multiple requests will not speed up Google’s crawlers.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Simple Google Indexing Checklist
&lt;/h2&gt;

&lt;p&gt;Before assuming that your newly published page is invisible to Google, run through this checklist:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;URL Inspection&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Check the URL in Search Console&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Google Search&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Try a site search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Google Page Indexing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Look for exclusions or errors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Technical setup&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Check robots.txt, status code, and noindex&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Internal links&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Make sure relevant pages link to the new URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sitemap&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Confirm the URL is included where appropriate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Wait and monitor&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Give Google time to crawl and process the page&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Need Help With Your Website's Indexing?
&lt;/h2&gt;

&lt;p&gt;While regularly publishing content may be an important factor, technical SEO is what plays a crucial role in enabling search engines to find and understand that content. If your website suffers from a series of indexing issues, unexplained exclusions or a number of pages that are not indexed in Google, a technical audit will identify the causes.&lt;/p&gt;

&lt;p&gt;At &lt;a href="https://boffincoders.com/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=check-google-indexing" rel="noopener noreferrer"&gt;&lt;strong&gt;Boffin Coders&lt;/strong&gt;&lt;/a&gt;, we create websites that take into account the company’s SEO, development, structure, performance, and future growth. Our team of in-house specialists is ready to examine the technical aspects of your website and help improve the digital infrastructure of your web resource.&lt;/p&gt;

&lt;p&gt;If you want to get assistance with the development of a website with SEO in mind or make technical improvements, don’t hesitate to turn to Boffin Coders.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://boffincoders.com/blog/how-to-tell-whether-google-actually-indexed-what-you-published?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=check-google-indexing" rel="noopener noreferrer"&gt;Boffin Coders&lt;/a&gt;. If you want a technical team to find out why your pages are not indexed, see &lt;a href="https://boffincoders.com/services/seo-digital-marketing?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=check-google-indexing" rel="noopener noreferrer"&gt;SEO and digital marketing at Boffin Coders&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>web</category>
      <category>webdev</category>
    </item>
    <item>
      <title>You do not need a vector database for your first RAG chatbot</title>
      <dc:creator>Manoj Sethi</dc:creator>
      <pubDate>Tue, 06 Oct 2026 13:23:02 +0000</pubDate>
      <link>https://dev.to/manojsethi/you-do-not-need-a-vector-database-for-your-first-rag-chatbot-21jh</link>
      <guid>https://dev.to/manojsethi/you-do-not-need-a-vector-database-for-your-first-rag-chatbot-21jh</guid>
      <description>&lt;p&gt;You want a chatbot on your website that answers questions from your own help pages. There are forty of them. You search for how to build it, and every tutorial starts the same way: convert your pages into embeddings, store them in a vector database, pay for both every month. Before you do any of that, read this. For forty pages - or four hundred - you probably do not need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a RAG chatbot actually does
&lt;/h2&gt;

&lt;p&gt;RAG stands for retrieval-augmented generation. The plain-English version: the AI model does not know your content. So before it answers, your system finds the few pages that match the question and pastes them into the prompt along with the question. The model reads those pages and answers from them.&lt;/p&gt;

&lt;p&gt;That is the whole idea. Find, then answer. Everything else is detail. And the only real engineering question inside it is the first word: &lt;strong&gt;how do you find the right pages?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to find
&lt;/h2&gt;

&lt;p&gt;Take a real question a customer types: &lt;em&gt;“can I get a refund?”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keyword scoring.&lt;/strong&gt; Count how often the question’s words appear on each page. The refund policy page mentions “refund” six times, so it scores highest. The billing FAQ mentions it three times. The cancellation page twice. Send the top three to the model. This is the same maths that has powered search boxes since the 1990s. It is called BM25, it is about forty lines of code, and it runs on your own server in under a millisecond.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embeddings.&lt;/strong&gt; Turn every page into a long list of numbers that represents its meaning, do the same to the question, and find the pages whose numbers are closest. To do that you call an embedding API (your text goes to the provider), store the numbers in a vector database, and query it for every question.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9zf9adhy9cozaxfuwco3.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%2F9zf9adhy9cozaxfuwco3.png" alt="The same question scored two ways. Keyword scoring finds the refund policy because the word refund appears. Embeddings also find the money-back page, which has no word refund in it." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Look at what embeddings buy you in that picture. One row. The “money-back guarantee” page does not contain the word “refund”, so keyword scoring misses it and embeddings find it. That is the advantage - pages that mean the same thing in different words. It is a real advantage. It is also a smaller one than the tutorials suggest, and there is a cheaper fix for most of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the vector database costs you
&lt;/h2&gt;

&lt;p&gt;Not just money, though there is that. Here is what you take on the day you add one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An API call for every page and every question.&lt;/strong&gt; Your content and your customers’ questions leave your server to be turned into numbers. For a German or Dutch client, that sentence alone can end the project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A database to run or rent.&lt;/strong&gt; One more service with its own password, its own outage, its own bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-embedding when you edit.&lt;/strong&gt; Change a page, and its numbers are stale until you regenerate them. Forget once, and the bot answers from last month’s policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Debugging you cannot see.&lt;/strong&gt; When keyword scoring picks the wrong page, you can print the scores and see why. When embeddings pick the wrong page, you get a similarity of 0.81 and a shrug.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these is fatal. All of them are work that a forty-page chatbot does not need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Numbers from a system that runs this way
&lt;/h2&gt;

&lt;p&gt;The assistant on this website answers from 136 chunks of text - service pages, prices, hiring terms, a few rules. Retrieval is BM25 with the standard settings (k1 of 1.5, b of 0.75), a small synonym list so “price” also matches “cost” and “rate”, and a title boost so a chunk whose heading matches the question scores higher. A typical question sends about 3,700 tokens to the model. Retrieval takes under a millisecond. There is no vector database, no embedding API, and nothing about your question leaves the server until the final call to the model.&lt;/p&gt;

&lt;p&gt;One more thing that design gets right, and it matters more than the retrieval method: the rules do not compete for space.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7wwyhv6oed96pdgvszpd.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%2F7wwyhv6oed96pdgvszpd.png" alt="The model’s context budget split in two: a pinned block with prices and rules that is always sent, and a scored block filled by keyword search for each question." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Prices, hours and the rule “never promise a refund” sit in a pinned block that is sent with every request and never scored. Only the remaining 126 chunks compete for the rest of the budget. If you skip this, a rule can lose its place to an FAQ page on the very question where the rule mattered. The full story of that failure is in &lt;a href="https://boffincoders.com/architecture/why-rag-chatbots-ignore-your-rules?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=rag-without-vector-db" rel="noopener noreferrer"&gt;why RAG chatbots ignore your rules&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you do need embeddings
&lt;/h2&gt;

&lt;p&gt;Be honest about the crossover, because it exists:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Thousands of documents.&lt;/strong&gt; Past a few thousand chunks, keyword scoring starts returning too many near-ties and you want meaning to break them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Users who paraphrase heavily.&lt;/strong&gt; Support for a product with lots of jargon, where customers describe the problem in their own words and never use your terms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More than one language.&lt;/strong&gt; A German question against English pages will not match on keywords.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You measured it.&lt;/strong&gt; This is the real test. Log a hundred real questions from real users. For each one, look at which chunks BM25 returned. Count the misses. If it is under ten, you are done. If it is thirty, you have earned the vector database, and you will know exactly why.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Until you have that log, adding embeddings is guessing with a monthly bill attached.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cheap fix for paraphrase
&lt;/h2&gt;

&lt;p&gt;Most of the “different words, same meaning” problem in a small knowledge base is a synonym list. Twenty lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// synonyms.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SYNONYMS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;refund&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="s1"&gt;money back&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="s1"&gt;money-back&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="s1"&gt;reimburse&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;price&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="s1"&gt;cost&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="s1"&gt;rate&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="s1"&gt;fee&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="s1"&gt;charge&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;start&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="s1"&gt;onboard&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="s1"&gt;begin&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="s1"&gt;kick off&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Expand the question before scoring it.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every time you find a missed question in your log, add a line. After a month the list stops growing, because your customers use a smaller vocabulary than you fear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start here
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Split your pages into chunks of a few hundred words, one idea each.&lt;/li&gt;
&lt;li&gt;Score with BM25. Any language has a library. It is also small enough to write yourself.&lt;/li&gt;
&lt;li&gt;Put rules, prices and prohibitions in a pinned block that is always sent.&lt;/li&gt;
&lt;li&gt;Log every question and the chunks it returned.&lt;/li&gt;
&lt;li&gt;Add synonyms as the log shows misses.&lt;/li&gt;
&lt;li&gt;Add embeddings only when the log proves the misses are about meaning, not words.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That order costs nothing to run, keeps the data on your own machine, and gives you a working assistant this week. The vector database will still be there when you need it, and by then you will be able to say why - which is the difference between building a system and following a tutorial. If you would like the version of this that runs on your own documents, &lt;a href="https://boffincoders.com/services/generative-ai-llm?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=rag-without-vector-db" rel="noopener noreferrer"&gt;that is what our generative AI work looks like&lt;/a&gt;, and it is built by &lt;a href="https://boffincoders.com/hire/ai-engineers?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=rag-without-vector-db" rel="noopener noreferrer"&gt;our AI engineers&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://boffincoders.com/blog/you-do-not-need-a-vector-database-for-your-first-rag-chatbot?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=rag-without-vector-db" rel="noopener noreferrer"&gt;Boffin Coders&lt;/a&gt;. If you want to see what happens when the rules are left to compete with the scored pages, read &lt;a href="https://boffincoders.com/architecture/why-rag-chatbots-ignore-your-rules?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=rag-without-vector-db" rel="noopener noreferrer"&gt;why RAG chatbots ignore your rules&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>rag</category>
    </item>
    <item>
      <title>How to split large Excel files in Node.js: a beginner's guide</title>
      <dc:creator>Manoj Sethi</dc:creator>
      <pubDate>Sat, 03 Oct 2026 06:18:33 +0000</pubDate>
      <link>https://dev.to/manojsethi/how-to-split-large-excel-files-in-nodejs-a-beginners-guide-4bbk</link>
      <guid>https://dev.to/manojsethi/how-to-split-large-excel-files-in-nodejs-a-beginners-guide-4bbk</guid>
      <description>&lt;p&gt;You have an Excel file that is too large to open comfortably, import or email, and you need it as several smaller files, each with the same header row at the top. In Node.js that takes one short script. This guide builds it step by step, then gives you the version to use when a file is so large that reading it all at once runs out of memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://nodejs.org/en/download" rel="noopener noreferrer"&gt;Node.js&lt;/a&gt; installed. Any current long-term support release works.&lt;/li&gt;
&lt;li&gt;A basic idea of how to run a JavaScript file from the terminal.&lt;/li&gt;
&lt;li&gt;The Excel file you want to split. The examples call it &lt;code&gt;input_file.xlsx&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: set up the project
&lt;/h2&gt;

&lt;p&gt;Create a folder for the project and start a Node.js project inside it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;split-excel
&lt;span class="nb"&gt;cd &lt;/span&gt;split-excel
npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then install SheetJS, the library that reads and writes Excel files. Install it the way &lt;a href="https://docs.sheetjs.com/docs/getting-started/installation/nodejs/" rel="noopener noreferrer"&gt;the SheetJS installation guide&lt;/a&gt; says, from its own CDN. The copy on the public npm registry stopped at version 0.18.5, so &lt;code&gt;npm install xlsx&lt;/code&gt; gives you an old release. Copy the install line from the guide, which always carries the current version. It looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: write the script
&lt;/h2&gt;

&lt;p&gt;Create a file called &lt;code&gt;split_excel.js&lt;/code&gt; in the folder and paste this in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// split_excel.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fs&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;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;path&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;XLSX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;xlsx&lt;/span&gt;&lt;span class="dl"&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;splitExcelFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rowsPerFile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;outDir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;output&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`File not found: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c1"&gt;// cellDates keeps dates as dates instead of serial numbers&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;workbook&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;XLSX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cellDates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;sheetName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;workbook&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SheetNames&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;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;XLSX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;utils&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sheet_to_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;workbook&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Sheets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;sheetName&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;header&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="c1"&gt;// an array per row, so the header is row 0&lt;/span&gt;
    &lt;span class="na"&gt;defval&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;// keep empty cells, so columns do not shift&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&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;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;The first sheet has no data rows&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;,&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="nx"&gt;rows&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;baseName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recursive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;totalFiles&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;ceil&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="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;rowsPerFile&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;let&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;0&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;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;totalFiles&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="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;chunk&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;slice&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="nx"&gt;rowsPerFile&lt;/span&gt;&lt;span class="p"&gt;,&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="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;rowsPerFile&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;sheet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;XLSX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;utils&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;aoa_to_sheet&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cellDates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;book&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;XLSX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;utils&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;book_new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nx"&gt;XLSX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;utils&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;book_append_sheet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;book&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sheetName&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;outFile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&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="nx"&gt;outDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_part_&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="s2"&gt;.xlsx`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;XLSX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;book&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;outFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;compression&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Created &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;outFile&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; rows)`&lt;/span&gt;&lt;span class="p"&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;totalFiles&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="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input_file.xlsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;splitExcelFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each part does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;readFile&lt;/code&gt; with &lt;code&gt;cellDates: true&lt;/code&gt;&lt;/strong&gt; opens the workbook and keeps date cells as dates. Without it, dates come back as serial numbers such as 45583, and the split files show numbers where the dates were.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sheet_to_json&lt;/code&gt; with &lt;code&gt;header: 1&lt;/code&gt;&lt;/strong&gt; returns each row as an array, so the first array is the header row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;defval: ''&lt;/code&gt;&lt;/strong&gt; keeps empty cells. Without it, a blank cell in the middle of a row is dropped and every value after it moves one column to the left.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The loop&lt;/strong&gt; takes the data in slices of &lt;code&gt;rowsPerFile&lt;/code&gt; rows, puts the header back on top of each slice, and writes each slice as a new workbook in the &lt;code&gt;output&lt;/code&gt; folder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;compression: true&lt;/code&gt;&lt;/strong&gt; makes the output files smaller. SheetJS writes uncompressed files unless you ask.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 3: run it
&lt;/h2&gt;

&lt;p&gt;Put your Excel file in the same folder, then run the script with the file name and the number of rows you want in each file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node split_excel.js input_file.xlsx 10000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a file with 25,000 data rows it prints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Created output/input_file_part_1.xlsx &lt;span class="o"&gt;(&lt;/span&gt;10000 rows&lt;span class="o"&gt;)&lt;/span&gt;
Created output/input_file_part_2.xlsx &lt;span class="o"&gt;(&lt;/span&gt;10000 rows&lt;span class="o"&gt;)&lt;/span&gt;
Created output/input_file_part_3.xlsx &lt;span class="o"&gt;(&lt;/span&gt;5000 rows&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each file opens in Excel with the original header on the first row. If the file name is wrong, the script stops with "File not found" instead of a long error.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the file is too large to load at once
&lt;/h2&gt;

&lt;p&gt;The script above reads the whole workbook into memory before it writes anything. That is fine for most files. For a very large one, the process can run out of memory, or take so long that it looks stuck.&lt;/p&gt;

&lt;p&gt;We measured both approaches on the same test file: 300,000 rows, five columns, 62 MB, on a laptop running Node.js 22. The script above peaked at about 990 MB of memory and took 11.6 seconds. The streaming version below peaked at about 220 MB and took 4.8 seconds. Its memory stays roughly flat as the file grows, because it holds one row at a time instead of the whole sheet.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Measured on one test file&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Streaming holds one row, not the whole sheet
&lt;/h3&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;Read the whole file (SheetJS)&lt;/th&gt;
&lt;th&gt;Stream row by row (ExcelJS)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Peak memory&lt;/td&gt;
&lt;td&gt;About 990 MB&lt;/td&gt;
&lt;td&gt;About 220 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time&lt;/td&gt;
&lt;td&gt;11.6 seconds&lt;/td&gt;
&lt;td&gt;4.8 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;As the file grows&lt;/td&gt;
&lt;td&gt;Memory grows with it&lt;/td&gt;
&lt;td&gt;Memory stays roughly flat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Script length&lt;/td&gt;
&lt;td&gt;41 lines&lt;/td&gt;
&lt;td&gt;60 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Use it for&lt;/td&gt;
&lt;td&gt;Most files, and quick changes&lt;/td&gt;
&lt;td&gt;Very large files, or a small server&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Streaming needs a different library, ExcelJS, which can read and write a workbook one row at a time. &lt;a href="https://github.com/exceljs/exceljs" rel="noopener noreferrer"&gt;Its documentation&lt;/a&gt; covers the streaming reader and writer. Install it in the same folder:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Then create &lt;code&gt;split_excel_stream.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// split_excel_stream.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fs&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;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;path&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;ExcelJS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;exceljs&lt;/span&gt;&lt;span class="dl"&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;splitLargeExcelFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rowsPerFile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;outDir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;output&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`File not found: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recursive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;baseName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;ExcelJS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;xlsx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WorkbookReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;sharedStrings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// resolve text cells&lt;/span&gt;
    &lt;span class="na"&gt;styles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// needed to recognise dates&lt;/span&gt;
    &lt;span class="na"&gt;hyperlinks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ignore&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;worksheets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;emit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&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;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;outFile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;writer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;sheet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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;closePart&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;writer&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;sheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Created &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;outFile&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; rows)`&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;openPart&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;closePart&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nx"&gt;part&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="nx"&gt;outFile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&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="nx"&gt;outDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_part_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.xlsx`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;writer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;ExcelJS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;xlsx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WorkbookWriter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;outFile&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="nx"&gt;sheet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addWorksheet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Sheet1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &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;worksheet&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &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;row&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;worksheet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="c1"&gt;// the first row is the header&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;writer&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;rowsPerFile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;openPart&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="nx"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&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;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// written to disk, then released&lt;/span&gt;
      &lt;span class="nx"&gt;count&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;break&lt;/span&gt; &lt;span class="c1"&gt;// first sheet only&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;closePart&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;part&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="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input_file.xlsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;100000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;
&lt;span class="nf"&gt;splitLargeExcelFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&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="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How it differs from the first script:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The reader is a stream.&lt;/strong&gt; &lt;code&gt;WorkbookReader&lt;/code&gt; hands you one worksheet, then one row, at a time. &lt;code&gt;styles: 'cache'&lt;/code&gt; lets it recognise date cells, and &lt;code&gt;sharedStrings: 'cache'&lt;/code&gt; turns text cells back into text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Each row is committed as soon as it is added.&lt;/strong&gt; &lt;code&gt;commit()&lt;/code&gt; writes the row to the output file and releases it, so memory does not grow with the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A new output file opens every &lt;code&gt;rowsPerFile&lt;/code&gt; rows&lt;/strong&gt;, with the header written first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run it the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node split_excel_stream.js input_file.xlsx 100000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Which one to use
&lt;/h2&gt;

&lt;p&gt;Start with the first script. It is shorter and easier to change. Move to the streaming version when loading the file takes more memory than the machine can spare, or when the script runs on a small server next to other work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common problems
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dates come out as numbers.&lt;/strong&gt; Read with &lt;code&gt;cellDates: true&lt;/code&gt;, as the first script does. In the streaming version, keep &lt;code&gt;styles: 'cache'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Values move one column to the left.&lt;/strong&gt; A blank cell was dropped. Keep &lt;code&gt;defval: ''&lt;/code&gt; in &lt;code&gt;sheet_to_json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need a different sheet.&lt;/strong&gt; Both scripts split the first sheet only. In the first script, change &lt;code&gt;SheetNames[0]&lt;/code&gt; to the position of the sheet you want.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The script slows down your web server.&lt;/strong&gt; Splitting a file is CPU work, and in a server process it holds up every other request. Run it in a worker thread or a separate process. &lt;a href="https://boffincoders.com/blog/breaking-the-event-loop-bottleneck-handling-cpu-intensive-tasks-in-nodejs?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=excel-split" rel="noopener noreferrer"&gt;How to move CPU-heavy work off the Node.js event loop&lt;/a&gt; shows how.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The code
&lt;/h2&gt;

&lt;p&gt;The original script is in our public repository, &lt;a href="https://github.com/boffincoders/excel-splitter" rel="noopener noreferrer"&gt;boffincoders/excel-splitter&lt;/a&gt;. The versions on this page add the output folder, the command-line arguments, date and blank-cell handling, a clear error when the file is missing, and the streaming option. Both were run against the test files described above before they went on this page.&lt;/p&gt;

&lt;p&gt;If splitting files is one step in something larger, such as a nightly import, a report that feeds another system, or spreadsheets that customers upload every week, the script is the easy part, and the work is making it run unattended: retries when a file arrives half written, a log that shows which rows failed, and an alert when it stops. That is the kind of job &lt;a href="https://boffincoders.com/hire/nodejs-developers?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=excel-split" rel="noopener noreferrer"&gt;Node.js developers by the month&lt;/a&gt; take on inside your own repository, and it is usually a few days of work rather than a project.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://boffincoders.com/blog/how-to-split-large-excel-files-in-nodejs-a-beginners-guide?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=excel-split" rel="noopener noreferrer"&gt;Boffin Coders&lt;/a&gt;. If you want a job like this running unattended inside your own repository, you can &lt;a href="https://boffincoders.com/hire/nodejs-developers?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=excel-split" rel="noopener noreferrer"&gt;hire Node.js developers by the month&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>beginners</category>
      <category>javascript</category>
      <category>node</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
