DEV Community

Nerav Doshi
Nerav Doshi

Posted on Originally published at pipelineandprompts.com

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

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
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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):

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
Enter fullscreen mode Exit fullscreen mode

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

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

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. Next entry should replace it with actual chunking. Also still untested: whether querying this store actually retrieves the right document for a real question — that's the actual point of building this, and it's still ahead of us.

Top comments (0)