Embeddings
Embeddings
Turn text into vectors for semantic search, retrieval (RAG), recommendations, clustering and deduplication, with OpenAI’s text-embedding-3 models at half OpenAI’s price. Same model IDs, same request and response, same vectors: code written for OpenAI works once you change the base URL and the key.
/v1/embeddingsYou pay for the text you send, and the vectors are free. Both models read up to 8,192 tokens per input. Text Embedding 3 Small is the one to start with: cheap, fast, and good for most search and RAG. Text Embedding 3 Large is the larger, more accurate model, for when retrieval quality matters more than storage and price.
Quickstart
import osfrom openai import OpenAIclient = OpenAI( base_url="https://api.zurelay.com/v1", api_key=os.environ["ZURELAY_API_KEY"],)response = client.embeddings.create( model="text-embedding-3-small", input="The food was delicious and the waiter was friendly.",)vector = response.data[0].embedding # a list of 1,536 floatsprint(len(vector), response.usage.prompt_tokens) # 1536 10Any OpenAI SDK works: point it at https://api.zurelay.com/v1, use your Zurelay key, and call its embeddings method. The answer is OpenAI’s:
{ "object": "list", "data": [ { "object": "embedding", "index": 0, "embedding": [-0.0022748, -0.0093183, 0.0158532, ..., -0.0028842] } ], "model": "text-embedding-3-small", "usage": { "prompt_tokens": 10, "total_tokens": 10 }}data has one embedding per input, in the order you sent them, each with its index. usage.prompt_tokens is what the request was billed on. The headers say it too: x-zurelay-model names the model and x-zurelay-input-tokens the tokens, and x-request-id finds the request in your request log.
Parameters
modelstringrequiredtext-embedding-3-smallortext-embedding-3-large.inputstring or arrayrequired- What to embed: a string, an array of up to 2,048 strings, an array of token IDs (integers from OpenAI’s
cl100k_basetokenizer), or an array of up to 2,048 such arrays. Each input is at most 8,192 tokens, and none may be an empty string. encoding_formatstringfloat(the default): each embedding is an array of numbers.base64: each is a base64 string of little-endian float32 numbers, smaller and faster to download. See Base64.dimensionsinteger- Shorten the vector: 1 to 1,536 on
text-embedding-3-small, 1 to 3,072 ontext-embedding-3-large. Leave it out for the full length. See Shorter vectors. userstring- Optional: an ID for the end user the request is for, as in OpenAI’s API. Accepted, and changes nothing about the vectors.
Batching
Send many texts in one request: input takes an array of up to 2,048, and they come back in the same order. One request with a thousand texts is much faster than a thousand requests, and costs the same: you pay per token, not per request. Keep each request under 300,000 tokens in all.
# pip install tiktokenimport tiktoken# The tokenizer text-embedding-3 counts with, to keep each request under the limitsenc = tiktoken.get_encoding("cl100k_base")def batches(texts, max_inputs=2048, max_tokens=300_000): batch, tokens = [], 0 for text in texts: count = len(enc.encode(text)) if batch and (len(batch) == max_inputs or tokens + count > max_tokens): yield batch batch, tokens = [], 0 batch.append(text) tokens += count if batch: yield batchvectors = []for batch in batches(texts): response = client.embeddings.create(model="text-embedding-3-small", input=batch) vectors += [item.embedding for item in response.data] # in the order sentSplit documents longer than 8,192 tokens into chunks before you embed them. For search and RAG, chunks of a few hundred tokens, a paragraph or two, usually find better matches than whole documents.
Shorter vectors with dimensions
The text-embedding-3 models are trained so that a shortened vector still works: ask for dimensions and you get the first part of the full vector, re-normalised to length 1. Shorter vectors take less storage and memory and search faster, for a little less accuracy. Text Embedding 3 Large at 1,024 dimensions takes a third of the space of its full 3,072.
response = client.embeddings.create( model="text-embedding-3-large", input="The food was delicious and the waiter was friendly.", dimensions=1024,)print(len(response.data[0].embedding)) # 1024Base64
With "encoding_format": "base64" each embedding comes back as a base64 string of little-endian float32 numbers instead of a JSON array: smaller, and faster to download and parse. When you leave encoding_format out, OpenAI’s Python SDK asks for base64 itself and decodes it for you, so you get plain floats either way. If you ask for base64 yourself, you get the string, to decode like this:
import base64import numpy as npresponse = client.embeddings.create( model="text-embedding-3-small", input=["First text", "Second text"], encoding_format="base64",)# Each embedding is a base64 string of little-endian float32 numbersvectors = [np.frombuffer(base64.b64decode(item.embedding), dtype="<f4") for item in response.data]Semantic search
Embed your documents once and store the vectors. To search, embed the query with the same model and rank the documents by cosine similarity to it: the closer to 1, the closer in meaning, even with no words in common. text-embedding-3 vectors have length 1, so the dot product gives the same ranking.
# pip install openai numpyimport osimport numpy as npfrom openai import OpenAIclient = OpenAI(base_url="https://api.zurelay.com/v1", api_key=os.environ["ZURELAY_API_KEY"])MODEL = "text-embedding-3-small"docs = [ "To change your password, open Settings, choose Security and click Change password.", "Invoices are emailed on the first day of each month and can be downloaded under Billing.", "Invite teammates from the Team page; each person signs in with their own email.", "API keys can be limited to a monthly budget, a rate limit and a list of models.",]def embed(texts): response = client.embeddings.create(model=MODEL, input=texts) return np.array([item.embedding for item in response.data])doc_vectors = embed(docs) # embed once, and store the vectors beside the textdef search(query, k=2): query_vector = embed([query])[0] # The vectors have length 1, so the dot product is the cosine similarity scores = doc_vectors @ query_vector best = np.argsort(-scores)[:k] return [(docs[i], float(scores[i])) for i in best]for text, score in search("How can I change my password?"): print(f"{score:.3f} {text}") # the password article comes firstFor more than a few thousand documents, keep the vectors in a vector database or a vector index (pgvector, Qdrant, Pinecone, Weaviate, Chroma and the like) and let it do the ranking.
Retrieval (RAG)
Retrieval-augmented generation answers a question from your own documents: search for the passages closest to the question, then hand them to a chat model with the question. The same Zurelay key does both.
# After the search example above: retrieve, then answer from what was foundquestion = "How do I stop a key from spending too much?"notes = "\n".join(text for text, _ in search(question))answer = client.chat.completions.create( model="gpt-6-luna", messages=[ { "role": "system", "content": "Answer from these notes only. If they don't say, say so.\n\n" + notes, }, {"role": "user", "content": question}, ],)print(answer.choices[0].message.content)LangChain, LlamaIndex and the AI SDK
Frameworks with an OpenAI embeddings class work with Zurelay’s base URL and key, and the model IDs stay OpenAI’s.
LangChain
# pip install langchain-openaiimport osfrom langchain_openai import OpenAIEmbeddingsembeddings = OpenAIEmbeddings( model="text-embedding-3-small", base_url="https://api.zurelay.com/v1", api_key=os.environ["ZURELAY_API_KEY"],)query_vector = embeddings.embed_query("How can I change my password?")doc_vectors = embeddings.embed_documents(["First document", "Second document"])LangChain’s Python OpenAIEmbeddings tokenizes texts itself by default and sends the token IDs. Zurelay takes token arrays, so the default settings work as they are.
LlamaIndex
# pip install llama-index-embeddings-openaiimport osfrom llama_index.core import Settingsfrom llama_index.embeddings.openai import OpenAIEmbeddingSettings.embed_model = OpenAIEmbedding( model="text-embedding-3-small", api_base="https://api.zurelay.com/v1", api_key=os.environ["ZURELAY_API_KEY"],)vector = Settings.embed_model.get_text_embedding("How can I change my password?")With Settings.embed_model set, every index and retriever you build embeds through Zurelay. For the chat model, see the LlamaIndex guide.
Vercel AI SDK
// npm install ai @ai-sdk/openaiimport { createOpenAI } from "@ai-sdk/openai";import { cosineSimilarity, embed, embedMany } from "ai";const zurelay = createOpenAI({ baseURL: "https://api.zurelay.com/v1", apiKey: process.env.ZURELAY_API_KEY,});const model = zurelay.embedding("text-embedding-3-small");const { embeddings } = await embedMany({ model, values: ["First document", "Second document"] });const { embedding } = await embed({ model, value: "How can I change my password?" });console.log(cosineSimilarity(embedding, embeddings[0]));Apps and vector stores
Apps that call OpenAI’s embeddings with a base URL you can set work the same way: set it to https://api.zurelay.com/v1, use your Zurelay key and one of the model IDs above. That includes Open WebUI’s OpenAI embedding engine for documents, LibreChat’s RAG API, n8n’s Embeddings OpenAI node and Dify’s OpenAI-compatible model provider.
Same vectors as OpenAI
Requests to text-embedding-3-small and text-embedding-3-large run on OpenAI’s own models. We checked every route against OpenAI’s API: for the same text and model, the vectors match OpenAI’s, with a cosine similarity of 0.9999 or more. So an index you built with OpenAI’s API keeps working when you embed your queries through Zurelay, and one built through Zurelay works with OpenAI’s. Moving between the two needs no re-embedding.
Switching models means re-embedding
Vectors from different models can’t be compared
text-embedding-3-small vector means nothing next to a text-embedding-3-large one, or one from any other model: each model places text in its own space. So to change models, re-embed everything you’ve stored with the new model, and query with that model from then on.For the same reason, an embeddings request is always answered by the model you named. Smart routing never hands it to another model, whatever your settings: if the model is down you get 503 model_unavailable, to retry, rather than vectors that don’t match your index.
Limits
- Up to 2,048 inputs a request.
- Up to 8,192 tokens in each input, and 300,000 in the whole request.
- No empty strings.
dimensionsfrom 1 to the model’s full length: 1,536 ontext-embedding-3-small, 3,072 ontext-embedding-3-large.- A request over a limit is refused with a
400that names the limit, and costs nothing. - A key limited to some models needs the embedding model on its list. See Key limits and rules.
Paying for embeddings
Embeddings are billed on input tokens only, counted the way OpenAI counts them, and the vectors are free. Text Embedding 3 Small costs $0.01 per 1M tokens and Text Embedding 3 Large $0.065, half OpenAI’s price. A million texts of 100 tokens each, 100M tokens, cost $1.00 to embed with Text Embedding 3 Small and $6.50 with Text Embedding 3 Large, against $2.00 and $13.00 at OpenAI.
There’s no minimum per request: a 10-token request is billed 10 tokens. Shorter dimensions and base64 cost the same. Each reply’s usage and x-zurelay-input-tokens header say what it was billed on, every request is in your request log, and requests that fail cost nothing.
Errors
Errors come in OpenAI’s shape, so OpenAI’s SDKs raise them as they would for OpenAI.
Keys, credit, budgets and rate limits give the same errors as every other endpoint: see Errors. OpenAI’s SDKs retry the ones worth retrying.
Try it first
Questions, or something missing? Ask our support team and we’ll answer by email, usually within a minute.