# Built My First Local Embeddings Pipeline (Files + a Live URL)

> Source: <https://pipelineandprompts.com/posts/05-local-embeddings-pipeline/>
> Published: 2026-08-21 00:00:00+00:00

**Context:** An embedding model doesn’t generate text — it converts text into a list of numbers (a vector) that represents the *meaning* of that text. Two pieces of writing about similar topics end up with similar vectors, even if they don’t share any exact words, which is what makes semantic search possible: you can find “things that mean something like this,” not just “things that contain this exact word.” One detail that surprised me going in: the vector is always the same fixed length, no matter how long the original text is — a one-sentence note and a 10-page article both come out as the same-sized list of numbers, like a fingerprint that summarizes something much bigger into a fixed format. RAG (retrieval-augmented generation) is built on this: embed a bunch of documents, embed a question the same way, and find whichever documents are numerically closest to the question’s meaning.

**Ran:** Built this up one command at a time instead of writing the full script upfront — easier to see what each piece actually does before combining them.

**Step 1 — Pull the embedding model**

```
ollama pull nomic-embed-text
```

**Step 2 — Test the embedding API directly, before writing any Python**

Ollama exposes embeddings over a local HTTP API, so you can sanity-check it works with a plain `curl` before involving any code:

```
curl http://localhost:11434/api/embeddings -d '{"model": "nomic-embed-text", "prompt": "hello world"}'
```

This returns a JSON object with a single `embedding` field — a list of 768 numbers. That’s the whole idea of an embedding in one command: text in, fixed-length list of numbers out.

**Step 3 — Install the Python pieces**

```
pip3 install ollama chromadb requests beautifulsoup4 --break-system-packages
```

**Step 4 — Embed one local file, interactively**

Rather than run a full script blind, this is small enough to do a few lines at a time in a Python shell (`python3`):

``` python
import ollama
text = open("02-oc-cli-mentor-system-prompt.md").read()
response = ollama.embeddings(model="nomic-embed-text", prompt=text)
len(response["embedding"])   # → 768
```

Hit `ModuleNotFoundError: No module named 'ollama'` on the first attempt here — Step 3 hadn’t been run yet. A reminder that even a five-step walkthrough has room to skip a step by accident.

**Step 5 — Store it in Chroma**

``` python
import chromadb
client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection(name="today_i_ran_notes")
collection.upsert(
    ids=["02-oc-cli-mentor-system-prompt.md"],
    embeddings=[response["embedding"]],
    documents=[text],
)
collection.count()   # → 1
```

**Step 6 — Add a longer file, and hit a real limit**

Repeating Step 4 against a longer draft article failed differently:

```
ollama._types.ResponseError: the input length exceeds the context length
```

`nomic-embed-text` has a 2048-token context window, and the longer draft (13,985 characters) blew past it. Quick fix for now — truncate to a safe length before embedding:

```
text = text[:6000]
```

Not the right long-term fix (more on that below), but enough to keep moving.

**Step 7 — Add a live URL as a source**

Pulling in a web page instead of a local file needs one extra step first: fetching the page and stripping out the HTML noise (nav bars, scripts, footers) so only the article text gets embedded.

``` python
import requests
from bs4 import BeautifulSoup

response = requests.get("https://pipelineandprompts.com/posts/03-1b-vs-3b-memory-comparison/",
                         headers={"User-Agent": "Mozilla/5.0"})
soup = BeautifulSoup(response.text, "html.parser")
for tag in soup(["script", "style", "nav", "footer", "header", "aside"]):
    tag.decompose()
article_text = soup.get_text(separator="\n", strip=True)
len(article_text)   # → 3359
```

That gets fed into the same `ollama.embeddings()` call from Step 4.

**Result:** Three sources ended up embedded across the two files plus the URL from the steps above:

| Source | Type | Characters sent | Vector dimensions | 
|---|---|---|---|
| `cloud-without-chaos-01.md` | local file | 6,000 (truncated from 13,985) | 768 | 
| `02-oc-cli-mentor-system-prompt.md` | local file | 2,941 | 768 | 
| Entry 03 (live URL) | web article | 3,359 | 768 | 

Two things stood out. First, every vector came back at exactly 768 dimensions regardless of source length — confirms the “fixed-size fingerprint” idea from the context section wasn’t just theory. Second, and more important: the truncation fix meant **57% of the longest draft’s content (7,985 of 13,985 characters) never made it into its embedding at all.** That’s not a rounding error — over half the article is invisible to any future search against that vector. This is exactly why real RAG pipelines chunk long documents into smaller overlapping pieces instead of truncating: chunking keeps everything searchable, truncation just quietly throws away whatever didn’t fit.

The URL fetch also worked cleanly — 3,359 characters extracted from the live page is close to the article’s actual body length, suggesting the nav/footer/script stripping in the script did its job without pulling in much boilerplate.

**Takeaway:** The pipeline works end-to-end — files and live URLs, embedded into a queryable local store — but truncation is a real data-loss bug, not just a technical footnote, once you’re working with anything longer than a short note. [Entry 06](https://pipelineandprompts.com/posts/06-querying-embeddings-store/) queries this store for the first time, and [Entry 07](https://pipelineandprompts.com/posts/07-chunking-retrieval-bias/) replaces truncation with real chunking. For the production-grade version of this — FastAPI, proper chunking, and a real API — see [Build a RAG Pipeline for Internal Runbooks](https://pipelineandprompts.com/posts/ai-in-the-stack-02-rag-runbooks/) in the AI in the Stack series.
