<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Nomic-Embed-Text 2048 Tokens on RockB</title><link>https://baeseokjae.github.io/tags/nomic-embed-text-2048-tokens/</link><description>Recent content in Nomic-Embed-Text 2048 Tokens on RockB</description><image><title>RockB</title><url>https://baeseokjae.github.io/images/og-default.png</url><link>https://baeseokjae.github.io/images/og-default.png</link></image><generator>Hugo</generator><language>en-us</language><lastBuildDate>Sun, 01 Nov 2026 00:16:00 +0000</lastBuildDate><atom:link href="https://baeseokjae.github.io/tags/nomic-embed-text-2048-tokens/index.xml" rel="self" type="application/rss+xml"/><item><title>Your Embedding Server Stops at 2,048 Tokens: Measuring the Local Embedding Context Ceiling That Silently Empties Agent Memory (2026)</title><link>https://baeseokjae.github.io/posts/local-embedding-context-ceiling-2026/</link><pubDate>Sun, 01 Nov 2026 00:16:00 +0000</pubDate><guid>https://baeseokjae.github.io/posts/local-embedding-context-ceiling-2026/</guid><description>Ollama 0.20.3 served an embedding model whose Modelfile asked for 8,192 tokens and allocated 2,048. Here is how to measure the real ceiling, prove which chunks were clipped, and size a chunker against the number the server actually uses.</description><content:encoded><![CDATA[<p>If your agent&rsquo;s memory is written through a local embedding endpoint, the context window that decides what gets remembered is not the one printed on the model card. On the machine used for this article, Ollama 0.20.3 allocated 2,048 prompt tokens to <code>nomic-embed-text:latest</code> while the installed model&rsquo;s own parameters said <code>num_ctx 8192</code>, and it accepted a 64,599-character document with HTTP 200 by silently evaluating only the first 2,048 tokens. The number that governs your ingest never appears in the embedding call, the client library, or the config file you wrote. It appears in <code>ollama ps</code>.</p>
<p>That is the whole finding, and it is narrower than it sounds. If you are comparing advertised context on hosted APIs, <a href="https://baeseokjae.github.io/posts/llm-context-window-comparison-2026/">that comparison already exists</a> and this article will not repeat it. This one measures the endpoint under your own ingest loop: what it allocates, when it clips without complaint, and how to prove after the fact which of your chunks were shortened before they ever became vectors.</p>
<h2 id="the-symptom-memory-that-only-remembers-page-one">The symptom: memory that only remembers page one</h2>
<p>A local memory pipeline looks healthy while it fails. The chat model and the embedder run on the same box, the ingest job walks a document directory, chunks the text, posts each chunk to <code>/api/embed</code>, and writes the returned vector to a store. Short notes — preferences, a name, a one-line decision — come back perfectly. Long documents do not. Ask about something from page six of a design doc and the agent answers from page one, or says it does not know.</p>
<p>Every layer you inspect looks fine. The chunker split the document. The HTTP calls returned 200. The index has a row for every chunk. The store answers queries in milliseconds. So the diagnosis drifts to the retriever, then to the embedding model&rsquo;s quality, then to prompt construction — three hypotheses that all cost real time and all miss the actual defect, which is that the vector for a long chunk was computed from its first two thousand tokens and nothing in the pipeline recorded that.</p>
<h2 id="find-the-allocation-not-the-parameter">Find the allocation, not the parameter</h2>
<p>Two Ollama commands disagree about the same model, and the disagreement is the point. <code>ollama show</code> reports what the model&rsquo;s Modelfile asks for:</p>
<pre tabindex="0"><code>  Model
    architecture        nomic-bert
    parameters          137M
    context length      2048
    embedding length    768
    quantization        F16

  Parameters
    num_ctx    8192
</code></pre><p><code>ollama ps</code> reports what the server actually reserved while the model was loaded, immediately after the probes below:</p>
<pre tabindex="0"><code>NAME                       ID              SIZE      PROCESSOR    CONTEXT    UNTIL
nomic-embed-text:latest    0a109f422b47    565 MB    100% CPU     2048       4 minutes from now
</code></pre><p>Both outputs are from the same host on 2026-11-01. The Modelfile asks for 8,192; the loaded runner serves 2,048. Ollama&rsquo;s own <a href="https://raw.githubusercontent.com/ollama/ollama/main/docs/context-length.mdx">context-length documentation</a> describes memory-tiered defaults for chat models and tells you to check <code>ollama ps</code> rather than assume, but the tiers it lists are VRAM bands — under 24 GiB, 24 to 48 GiB, 48 GiB and above — and this host runs CPU-only, so those bands do not literally decide the value here. Treat 2,048 as an observation about this install, not a rule that other machines will match. The reason to check <code>ollama ps</code> instead of a config is that the allocation is a property of the running server, and any documentation you read — including this article — is second-hand to that.</p>
<p>If you are new to the endpoint itself, read the <a href="https://baeseokjae.github.io/posts/ollama-api-local-llm-integration-developer-guide-2026/">Ollama API integration guide</a> first; it covers the request and response shape, including <code>prompt_eval_count</code>, which is the field this whole measurement depends on.</p>
<h2 id="measure-the-ceiling-with-prompt_eval_count">Measure the ceiling with prompt_eval_count</h2>
<p>The <code>/api/embed</code> response tells you how many prompt tokens the server actually evaluated. That single integer turns &ldquo;the input was too long&rdquo; into an exact boundary, because a truncated call succeeds and reports the cap.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl -s http://127.0.0.1:11434/api/embed <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -d <span style="color:#e6db74">&#39;{&#34;model&#34;:&#34;nomic-embed-text&#34;,&#34;input&#34;:&#34;&lt;long text&gt;&#34;}&#39;</span> | <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  python3 -c <span style="color:#e6db74">&#39;import json,sys; d=json.load(sys.stdin); print(d.get(&#34;prompt_eval_count&#34;), len(d[&#34;embeddings&#34;][0]))&#39;</span>
</span></span></code></pre></div><p>Run that against inputs of growing length and read the number back. A token ladder of synthetic filler on this host produced the values below; the character counts are for that filler, not prose, so the ratio understates what real English costs.</p>
<table>
	<thead>
			<tr>
					<th>Words</th>
					<th>Characters</th>
					<th>prompt_eval_count</th>
					<th>Chars per token</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>50</td>
					<td>377</td>
					<td>58</td>
					<td>6.50</td>
			</tr>
			<tr>
					<td>100</td>
					<td>755</td>
					<td>114</td>
					<td>6.62</td>
			</tr>
			<tr>
					<td>200</td>
					<td>1,511</td>
					<td>226</td>
					<td>6.69</td>
			</tr>
			<tr>
					<td>400</td>
					<td>3,023</td>
					<td>450</td>
					<td>6.72</td>
			</tr>
			<tr>
					<td>1,200</td>
					<td>9,071</td>
					<td>1,346</td>
					<td>6.74</td>
			</tr>
			<tr>
					<td>3,400</td>
					<td>25,703</td>
					<td>2,048</td>
					<td>capped</td>
			</tr>
	</tbody>
</table>
<p>The last row is the ceiling announcing itself: a document that should have cost roughly 3,800 tokens reported 2,048. Two re-measurements on 2026-11-01 confirm it on much larger inputs — 22,799 characters returned 200 with <code>prompt_eval_count</code> 2,048 in 20.28 s, and 64,599 characters returned 200 with <code>prompt_eval_count</code> 2,048 in 17.21 s. The second call was faster than the first despite carrying nearly three times the text, because everything past the first 2,048 tokens was discarded before evaluation.</p>
<p>The binary search is trivial once you trust that field: send the same paragraph padded to increasing word counts, read <code>prompt_eval_count</code>, and stop when the reported count stops tracking the input. On this install the answer is exactly 2,048. It is worth saying what this does <em>not</em> establish: it is not a statement about nomic-embed-text&rsquo;s quality, about other Ollama releases, or about any other runtime. It is what one server on one CPU-only host allocated on one date.</p>
<h2 id="why-nothing-errored">Why nothing errored</h2>
<p>The endpoint can be made to fail loudly, and by default it does not. Ollama&rsquo;s <a href="https://raw.githubusercontent.com/ollama/ollama/main/docs/api.md">API reference</a> documents the parameter that decides this: <code>truncate</code> &ldquo;truncates the end of each input to fit within context length. Returns error if <code>false</code> and context length is exceeded. Defaults to <code>true</code>.&rdquo;</p>
<p>Send the same oversized document with <code>&quot;truncate&quot;: false</code> and the answer changes shape entirely:</p>
<pre tabindex="0"><code>HTTP/1.1 400 Bad Request
{&#34;error&#34;:&#34;the input length exceeds the context length&#34;}
</code></pre><p>That request returned in 0.02 s. So the honest framing is not &ldquo;the embedding server hides errors.&rdquo; The server has a loud mode, and the loud mode costs one extra JSON key. The default is quiet because truncating is a reasonable convenience for search snippets and an unreasonable one for memory ingest, and nothing in the request marks which of those you are doing.</p>
<p>This distinction matters for diagnosis. If your client sends <code>truncate: false</code> — it is a per-request field you control — you get an exception you can catch, log and retry. If it leaves the default alone, you get a vector, a 200, and a memory that quietly covers less than you think. Both pipelines index the same number of rows.</p>
<h2 id="what-a-clipped-vector-costs-the-tail-sentinel-test">What a clipped vector costs: the tail-sentinel test</h2>
<p>Row counts cannot detect this failure, and neither can eyeballing similarity scores. The test that does detect it is per-chunk and cheap: put a unique phrase at the end of each source document, embed and store the document, then query the store with each document&rsquo;s own tail phrase and check whether that document comes back first. A pass means the tail survived tokenisation. A failure means the vector was built from a prefix.</p>
<p>On eight synthetic documents — four at 600 words, four at 3,000 words — the store ended with eight rows for eight documents, zero swallowed errors, and four rows whose <code>prompt_eval_count</code> had pinned at 2,048. Querying each document&rsquo;s own tail phrase returned the correct document in 3 of 8 cases. The number is not a recall rate and should not be quoted as one: the eight documents shared an identical filler body, so the vectors differed mainly by their tails, and the intact 600-word documents also produced misses that the truncation cannot explain. What the fixture demonstrates is the shape of the evidence — one query per chunk, a pass or fail per chunk, a coverage count you can put in a log line — not a ranking of anything. In a corpus of real, distinct documents the misses would concentrate on clipped chunks rather than spread across all of them, but that is an expectation, not a measurement.</p>
<p>Build the test into ingest rather than running it once. A sentinel at the tail of each chunk costs a few tokens and turns &ldquo;recall feels bad&rdquo; into &ldquo;these 37 chunks out of 900 were stored without their endings.&rdquo;</p>
<h2 id="the-row-count-illusion">The row-count illusion</h2>
<p>Here is the loop that produces silent loss, written the way it usually appears:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">for</span> doc <span style="color:#f92672">in</span> documents:
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> chunk <span style="color:#f92672">in</span> split(doc<span style="color:#f92672">.</span>text, <span style="color:#ae81ff">1000</span>):
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">try</span>:
</span></span><span style="display:flex;"><span>            vec <span style="color:#f92672">=</span> embed(chunk)          <span style="color:#75715e"># 200 OK, silently clipped</span>
</span></span><span style="display:flex;"><span>            index<span style="color:#f92672">.</span>add(chunk_id, vec)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">except</span> <span style="color:#a6e22e">Exception</span>:
</span></span><span style="display:flex;"><span>            log<span style="color:#f92672">.</span>warning(<span style="color:#e6db74">&#34;embed failed for </span><span style="color:#e6db74">%s</span><span style="color:#e6db74">&#34;</span>, chunk_id)   <span style="color:#75715e"># never fires</span>
</span></span></code></pre></div><p>Every layer reports success. <code>index_rows == corpus_rows</code> holds exactly, because a clipped chunk is still a chunk. The warning branch is dead code under the default, which is why the log is empty and why the run looks clean. The only artefact that carries the truth is <code>prompt_eval_count</code> on each response, and most ingest loops discard the response body once the vector is extracted.</p>
<p>If you want to see the failure on your own box without a corpus, write a document that is deliberately longer than the ceiling, embed it, and compare the vector against the vector of its own first half. If the two are nearly identical, the tail did not enter the computation. That comparison is a stronger signal than any timing difference, and it needs no ground truth.</p>
<h2 id="sizing-chunks-against-the-measured-number">Sizing chunks against the measured number</h2>
<p>A fixed character chunker is the usual culprit, because characters are not the unit the endpoint enforces. On this install the filler text ran about 6.7 characters per token, so a 1,000-character chunk costs roughly 150 tokens and is safe, while a 20,000-character chunk costs about 3,000 and is not. Prose is denser than that filler, because real sentences produce more tokens per character than repeated ordinary words, so the same character count can cost noticeably more than this ratio predicts. Measure the ratio on your own text rather than importing one from a table.</p>
<p>The guard that prevents the failure has three parts, and none of them are elaborate:</p>
<ol>
<li>Count tokens per chunk before sending, using the model&rsquo;s own tokenizer or a <code>prompt_eval_count</code> probe on the first batch, and record the number next to the chunk.</li>
<li>Fail the document, not the process, when a chunk exceeds the measured ceiling — do not clamp and continue. A partially indexed document is worse than a missing one, because it answers confidently from its opening pages.</li>
<li>Write the ceiling into the index metadata when the collection is created. When someone later upgrades the runtime or swaps the embedder, the stored number is the baseline to re-measure, and the mismatch is detectable without reading this article again.</li>
</ol>
<p>Send <code>truncate: false</code> on the ingest path even after you have sized the chunks. It converts the silent case into a thrown error, which is the only behaviour a retry loop can act on. Keep the default on the query path if you like; a clipped search query degrades gracefully in a way a clipped document does not.</p>
<h2 id="where-this-sits-in-the-write-path">Where this sits in the write path</h2>
<p>This is a precondition failure, not a write-path defect in the usual sense. The <a href="https://baeseokjae.github.io/posts/agent-memory-write-path-reliability-2026/">write-path reliability protocol</a> asks whether a fact was recorded, superseded, contradicted or deleted; every one of those questions assumes the chunk reached the embedder intact. Here it did not: the write succeeded, the content did not. That is why the failure survives write-path testing and retrieval benchmarks alike — it happens before either can observe it.</p>
<p>It is also not a model-quality problem, which is the diagnosis <a href="https://baeseokjae.github.io/posts/local-llm-feels-dumber/">the local-models quality piece</a> addresses. The embedder is doing exactly what it was configured to do. Its allocation is small, the input exceeded it, and the documented default clipped. Changing quantisation, sampling or the chat model will not move this number.</p>
<p>On the hardware side, the <a href="https://baeseokjae.github.io/posts/best-local-llm-models-2026/">local model selection guide</a> is the right place to start when choosing what to run. Just remember that the context figure quoted for a chat model and the context the embedder is served with are separate allocations, read from separate lines of <code>ollama ps</code>.</p>
<h2 id="limits-and-what-to-measure-on-your-own-box">Limits and what to measure on your own box</h2>
<p>Every number here is scoped to Ollama 0.20.3, <code>nomic-embed-text:latest</code> (<code>0a109f422b47</code>, F16, 137M parameters, 768 dimensions), CPU-only, on 2026-11-01. No second embedder was measured, so the 2,048 figure must not be generalised to other models or runtimes — other servers allocate differently and must be measured the same way. Elapsed times include shared-host CPU time and are indicative, not throughput benchmarks. The eight-document fixture was measured on 2026-10-31 and was not re-run on 2026-11-01; only the ceiling, the 400 body and the runtime allocation were re-measured on the later date.</p>
<p>Two vendor documents bracket the gap, and both were fetched on 2026-11-01. The model card states that nomic-embed-text-v1.5 has an 8,192-token sequence length and &ldquo;natively supports scaling of the sequence length past 2048 tokens&rdquo; via RoPE parameters, per the <a href="https://huggingface.co/nomic-ai/nomic-embed-text-v1.5/raw/main/README.md">model README</a>. The same repository&rsquo;s <a href="https://huggingface.co/nomic-ai/nomic-embed-text-v1.5/raw/main/config.json">config.json</a> ships <code>max_position_embeddings: 2048</code> alongside <code>n_positions: 8192</code>. Those two files are the reason the brochure number and the config number disagree before any runtime is involved; the runtime adds a third number. Reading the card alone would have predicted 8,192 tokens of usable input on this box. The server used a quarter of that.</p>
<h2 id="how-this-was-checked">How this was checked</h2>
<p>The measurements above come from scripts that posted to the local <code>/api/embed</code> endpoint and parsed <code>prompt_eval_count</code>, plus the raw <code>ollama show</code> and <code>ollama ps</code> output captured on the same host. The scripts and raw JSON responses are an author-side working record kept with the article&rsquo;s research material; they are not published for download, so nothing in this article depends on you being able to fetch them. The commands in the sections above are the reproduction path: send padded text, read <code>prompt_eval_count</code>, send the same text with <code>truncate: false</code>, and check <code>ollama ps</code> while the model is loaded. If the last of those three prints a number larger than the second-to-last one reports, you have a different allocation than the one documented here, and the measurement — not this article — is the authority.</p>
]]></content:encoded></item></channel></rss>