Getting started
Guide: RAG with embeddings
Build retrieval augmented generation on api14: chunk documents, create embeddings, search by cosine similarity, rerank results and answer with citations.
Retrieval-augmented generation (RAG) answers questions from your own documents. You split the documents into chunks, embed each chunk once, and at question time you embed the question, find the closest chunks, optionally rerank them, and send the best ones to a chat model with an instruction to cite them.
Everything below uses the base URL https://api14.com/api/v1, your sk-api14-... key and the official openai package. Costs come from your prepaid USD balance and are returned in usage.cost on every response.
import os
from openai import OpenAI
client = OpenAI(base_url="https://api14.com/api/v1", api_key=os.environ["API14_API_KEY"])
EMBED_MODEL = "openai/text-embedding-3-small"
CHAT_MODEL = "openai/gpt-5-mini"
1. Chunk the documents
Chunks should be small enough to be specific and large enough to make sense on their own. A good starting point is 500–1,000 characters with some overlap, split on paragraph boundaries:
def chunk(text, size=800, overlap=150):
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
chunks, current = [], ""
for p in paragraphs:
if current and len(current) + len(p) > size:
chunks.append(current)
current = current[-overlap:] # carry context into the next chunk
current = f"{current}\n\n{p}".strip()
if current:
chunks.append(current)
return chunks
docs = {"handbook.md": open("handbook.md").read(), "faq.md": open("faq.md").read()}
chunks = [{"source": name, "text": c} for name, text in docs.items() for c in chunk(text)]
Keep the source (file name, URL, page number) with every chunk, you need it for citations.
2. Embed with POST /embeddings
Send many chunks per request. One request with 100 inputs counts as one request against your rate limit:
def embed(texts):
vectors = []
for i in range(0, len(texts), 100):
r = client.embeddings.create(model=EMBED_MODEL, input=texts[i:i + 100])
vectors += [d.embedding for d in sorted(r.data, key=lambda d: d.index)]
return vectors
for c, v in zip(chunks, embed([c["text"] for c in chunks])):
c["vector"] = v
Store the vectors and the model id together. Vectors from different models are not comparable, so if you change EMBED_MODEL you must re-embed the corpus. See Embeddings.
3. Search with cosine similarity
For a few thousand chunks, a linear scan in memory is fast enough. Beyond that, put the vectors in a vector database or a Postgres table with pgvector.
import math
def cosine(a, b):
dot = sum(x * y for x, y in zip(a, b))
return dot / (math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b)))
def search(question, k=20):
q = embed([question])[0]
return sorted(chunks, key=lambda c: cosine(q, c["vector"]), reverse=True)[:k]
4. Rerank with POST /rerank
Embedding search is good at recall but rough at ordering. A reranker reads the question and each candidate together and scores relevance more precisely. Retrieve 20–100 candidates, rerank, and keep the top 3–8. The OpenAI SDK has no rerank method, so call it over HTTP (httpx is installed with openai):
import httpx
def rerank(question, candidates, top_n=5):
r = httpx.post(
"https://api14.com/api/v1/rerank",
headers={"Authorization": f"Bearer {os.environ['API14_API_KEY']}"},
json={
"model": "cohere/rerank-v3.5",
"query": question,
"documents": [c["text"] for c in candidates],
"top_n": top_n,
},
timeout=30,
)
r.raise_for_status()
return [candidates[x["index"]] for x in r.json()["results"]]
index points into the documents array you sent. See Rerank.
5. Answer with citations
Number the chunks in the prompt and ask the model to cite those numbers. Tell it to say so when the context does not contain the answer:
def answer(question):
context = rerank(question, search(question))
sources = "\n\n".join(f"[{i + 1}] ({c['source']})\n{c['text']}" for i, c in enumerate(context))
r = client.chat.completions.create(
model=CHAT_MODEL,
messages=[
{"role": "system", "content": (
"Answer only from the numbered sources. Cite them like [1] or [2][3] after each claim. "
"If the sources do not contain the answer, say you don't know."
)},
{"role": "user", "content": f"Sources:\n\n{sources}\n\nQuestion: {question}"},
],
)
return r.choices[0].message.content, context, r.usage.cost
If your app needs the citations as data, use Structured outputs with a schema like { "answer": string, "citations": integer[] } instead of parsing brackets.
The same pipeline in Node
import OpenAI from "openai";
const client = new OpenAI({ baseURL: "https://api14.com/api/v1", apiKey: process.env.API14_API_KEY });
const embed = async (input) =>
(await client.embeddings.create({ model: "openai/text-embedding-3-small", input })).data.map((d) => d.embedding);
const cosine = (a, b) => {
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] ** 2; nb += b[i] ** 2; }
return dot / Math.sqrt(na * nb);
};
async function answer(question, chunks /* [{ source, text, vector }] */) {
const [q] = await embed([question]);
const candidates = chunks.map((c) => ({ ...c, score: cosine(q, c.vector) }))
.sort((a, b) => b.score - a.score).slice(0, 20);
const ranked = await client.post("/rerank", {
body: { model: "cohere/rerank-v3.5", query: question, documents: candidates.map((c) => c.text), top_n: 5 },
});
const context = ranked.results.map((r) => candidates[r.index]);
const sources = context.map((c, i) => `[${i + 1}] (${c.source})\n${c.text}`).join("\n\n");
const res = await client.chat.completions.create({
model: "openai/gpt-5-mini",
messages: [
{ role: "system", content: "Answer only from the numbered sources and cite them like [1]. If they do not contain the answer, say so." },
{ role: "user", content: `Sources:\n\n${sources}\n\nQuestion: ${question}` },
],
});
return res.choices[0].message.content;
}
Estimating cost
A RAG system has three cost lines, all in USD and all reported in usage.cost:
| Step | When | Billed per |
|---|---|---|
| Embedding the corpus | Once, and again when documents change | Input token |
| Embedding the question + rerank | Every question | Input token / search |
| Chat answer | Every question | Input and output tokens |
To estimate before you run, take the model's prices from GET /models/{id} (pricing.prompt and pricing.completion are USD per token) and assume roughly 4 characters per token for English text:
import httpx
def price(model_id):
return httpx.get(f"https://api14.com/api/v1/models/{model_id}").json()["pricing"]
corpus_tokens = sum(len(c["text"]) for c in chunks) / 4
print("One-off embedding:", corpus_tokens * float(price(EMBED_MODEL)["prompt"]), "USD")
p = price(CHAT_MODEL)
prompt_tokens, answer_tokens = 5 * 800 / 4 + 200, 300 # 5 chunks + instructions, short answer
print("Per answer:", prompt_tokens * float(p["prompt"]) + answer_tokens * float(p["completion"]), "USD")
The chat call is usually the largest line. To lower it, send fewer and shorter chunks (reranking makes this safe), keep the system prompt fixed at the start so prompt caching can apply, and set max_tokens. Check real costs afterwards with usage.cost or Generation lookup.