Generating and Storing Embeddings
From a Single Vector to a Reusable Collection
Lesson 1 covered what an embedding is and why similar meanings end up close together in the embedding space. This lesson covers the practical mechanics of actually building a collection of embeddings you can search against later: how to generate them efficiently, what to store alongside each one, how storage format and cost scale with collection size, and how to control a vector's dimensionality when a model supports it.
Embeddings Are Usually Computed Once and Reused
Embedding generation, like every other API call this course has covered, has a real cost per request, though it is typically much cheaper per unit of text than text generation, since the model only needs to produce a fixed-length vector rather than generate new text token by token. The practical implication is that embeddings are usually computed once, when a piece of text is first added to a collection, and reused across every subsequent search against that collection — following exactly the same one-time-indexing-versus-per-query-search separation Unit 9, Lesson 2 established for vector stores, since a vector store is, under the hood, a system for storing and efficiently searching over exactly the kind of embeddings this unit introduces.
def embed_and_store(client, texts: list[str]) -> list[dict]:
response = client.embeddings.create(model="text-embedding-4", input=texts)
return [
{"text": text, "embedding": data.embedding}
for text, data in zip(texts, response.data)
]
documents = [
"Our return policy allows returns within 30 days of purchase.",
"Shipping typically takes 3 to 5 business days.",
"Refunds are processed within 5 to 7 business days after we receive the item.",
]
stored_documents = embed_and_store(client, documents)
print(f"Stored {len(stored_documents)} documents with embeddings.")
This function computes each document's embedding exactly once and stores it alongside the original text — a pattern worth internalizing before Lesson 3, which builds directly on having a collection of pre-computed, stored embeddings ready to search against, rather than recomputing an embedding for every stored document on every single search query, which would be needlessly expensive and slow. Storing the original text alongside its embedding matters just as much as the vector itself: a search that finds the closest matching vector is useless if there's no way to retrieve what that vector actually represents.
Deciding What to Store Alongside Each Embedding
A real application almost always needs more than just text and a vector — a document's source, its category, the date it was added, an identifier linking it back to a database record. Nothing about client.embeddings.create() handles this for you; it is entirely the calling code's responsibility to decide what metadata travels alongside each embedding and how it is stored.
def embed_and_store_with_metadata(client, records: list[dict]) -> list[dict]:
texts = [record["text"] for record in records]
response = client.embeddings.create(model="text-embedding-4", input=texts)
return [
{
"text": record["text"],
"embedding": data.embedding,
"source_id": record["source_id"],
"category": record.get("category"),
}
for record, data in zip(records, response.data)
]
records = [
{"text": "Our return policy allows returns within 30 days.", "source_id": "policy-001", "category": "returns"},
{"text": "Shipping typically takes 3 to 5 business days.", "source_id": "policy-002", "category": "shipping"},
]
stored = embed_and_store_with_metadata(client, records)
Attaching source_id and category to each stored embedding here is what makes the resulting collection actually usable in a real application: source_id lets a search result be traced back to the original record it came from (for citing a source, or for updating that specific record's embedding later without touching any others), and category enables the kind of pre-filtering Lesson 3 covers, where a hard structural filter narrows the search space before ranking by similarity.
Where Embeddings Actually Get Stored
For a small collection — a few hundred to a few thousand documents — storing embeddings as an in-memory Python list of dictionaries, as shown above, or serialized to a JSON file on disk, is entirely reasonable and requires no additional infrastructure. For anything larger, or anything that needs to persist reliably and be queried efficiently across multiple runs of an application, a proper storage layer becomes necessary: a plain relational database with an extra column holding the serialized vector (as a JSON array or a binary blob, depending on the database), a NumPy array saved to disk for fast bulk loading, or a dedicated vector database designed specifically to store and search embeddings efficiently at scale.
import json
def save_embeddings_to_disk(stored_documents: list[dict], filepath: str) -> None:
with open(filepath, "w") as f:
json.dump(stored_documents, f)
def load_embeddings_from_disk(filepath: str) -> list[dict]:
with open(filepath, "r") as f:
return json.load(f)
save_embeddings_to_disk(stored_documents, "embeddings_cache.json")
reloaded = load_embeddings_from_disk("embeddings_cache.json")
print(f"Reloaded {len(reloaded)} stored documents from disk.")
Persisting embeddings to disk (or a database) between runs, rather than recomputing them every time an application starts, is a direct extension of the one-time-computation principle established above: if the underlying text hasn't changed, there is no reason to pay for and wait on a new embedding call just because the application process restarted. This becomes increasingly important as a collection grows — recomputing embeddings for thousands of documents on every application startup is both slow and unnecessarily expensive.
Controlling Vector Dimensionality
Some embedding models support requesting a shorter vector than their default output length, trading a small amount of representational precision for a smaller, cheaper-to-store, and faster-to-compare vector.
response_full = client.embeddings.create(model="text-embedding-4", input="Return policy inquiry")
response_short = client.embeddings.create(model="text-embedding-4", input="Return policy inquiry", dimensions=256)
print(f"Default length: {len(response_full.data[0].embedding)}")
print(f"Requested shorter length: {len(response_short.data[0].embedding)}")
Note: Whether a given embedding model supports the
dimensionsparameter, and what range of values it accepts, depends on the specific model — not every embedding model supports shortening its output vector this way. Confirm current support and valid ranges against your installed SDK version's documentation.
This matters practically for large-scale storage and search: a vector store holding embeddings for millions of documents pays a real cost, in both storage space and per-comparison compute time, that scales directly with vector length, so a model supporting a shorter output length offers a genuine trade-off worth considering deliberately rather than always defaulting to the maximum available dimensionality — mirroring, in spirit, the same "match the setting to the actual need" theme this course has applied repeatedly to image resolution (Unit 7, Lesson 3) and reasoning effort (Unit 3): more dimensions are not automatically better if the application's actual retrieval quality needs are already well served by a shorter, cheaper vector.
Updating a Collection Over Time
A stored embedding collection is rarely static — documents get added, edited, or removed. Because an embedding represents the meaning of a specific piece of text at the time it was generated, editing the underlying text without regenerating its embedding leaves a stale vector that no longer accurately represents what the text now says.
def update_document_embedding(client, stored_documents: list[dict], source_id: str, new_text: str) -> list[dict]:
new_embedding = client.embeddings.create(model="text-embedding-4", input=new_text).data[0].embedding
for document in stored_documents:
if document.get("source_id") == source_id:
document["text"] = new_text
document["embedding"] = new_embedding
return stored_documents
Treating a document's embedding as something that must be regenerated whenever its underlying text changes — never left stale — is a direct consequence of what an embedding actually represents: a snapshot of meaning at a point in time, not a persistent identifier that stays valid regardless of edits. This is the same reasoning behind why source_id, introduced earlier in this lesson, matters in the first place: without a stable identifier to locate the specific record that changed, there would be no reliable way to know which embedding needs updating when its source text changes.
Common Mistakes
Recomputing embeddings for a stored document collection on every search query or application restart, rather than computing each document's embedding once and persisting it, incurring unnecessary repeated cost for text that hasn't changed.
Storing only the embedding vector without the metadata needed to trace it back to its source, making it impossible to update a specific record's embedding later or to attribute a search result to where it came from.
Leaving a document's embedding stale after editing its underlying text, since an embedding represents the meaning of text at the moment it was generated, not a live, automatically-updating representation.
Storing embeddings in a format that doesn't scale to the collection's actual size, such as continuing to rely on an in-memory list and a linear scan well past the point where a proper database or vector store would serve the application better.
Best Practices
Compute an embedding once per piece of text and persist it (to disk, a database, or a vector store), reusing the stored vector for every subsequent comparison rather than regenerating it repeatedly.
Attach a stable identifier and relevant metadata to every stored embedding, not just the vector and raw text, to support updates, filtering, and source attribution later.
Regenerate an embedding whenever its underlying text changes, treating the embedding as derived data that must stay in sync with its source rather than a one-time computation that's valid forever.
Choose a storage approach that matches the collection's actual scale — an in-memory list or a JSON file for a small collection, a database column or dedicated vector store as the collection grows — rather than defaulting to the simplest option regardless of size.