We use cookies. Cookies and browser storage keep you signed in and remember your preferences. With your permission we also measure how the site is used. You can any time. See our cookie policy.

Guide: RAG with embeddings

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:

StepWhenBilled per
Embedding the corpusOnce, and again when documents changeInput token
Embedding the question + rerankEvery questionInput token / search
Chat answerEvery questionInput 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.