Prefer Swagger UI? Click hereThe same API in the classic OpenAPI explorer.
// how-to · 14 min read

Build RAG over documents

Answer questions over your own files, with citations back to the source chunk. This guide walks the full managed-RAG path on Ringside: create a vector store, upload and attach files, poll asynchronous ingest to completed, then query through the file_search tool inside an Assistants run and parse the citations out of the response. Every parameter, default, status value and error code below is taken from the live route code.

What this is

Retrieval-augmented generation means the model answers from chunks pulled out of your documents at query time instead of from its own weights. Ringside runs the whole pipeline for you. You upload a file, Ringside parses it, splits it into chunks, embeds each chunk with the model you picked and stores the vectors in a per-store tenant. At query time it embeds the question, does a hybrid (vector plus keyword) search over that tenant and hands the top chunks to the model as tool output. You get an answer plus a citation array that points at the exact file_id, chunk index and page each claim came from.

There are three objects in play and they map cleanly onto three calls. A vector store holds the embedded chunks and pins an embedding model. A file is the raw bytes you uploaded. An assistant is the reusable config (model, instructions, tools) you run against a thread. The retrieval itself happens inside a run when the assistant calls the file_search tool, scoped to the store ids you pass in tool_resources.

Mental model

A vector store is per-customer and tenant-isolated. Cross-tenant reads return 404, never 403, so store ids are not enumerable. The store pins one embedding model at create time. You can never search a store with a different query model than the one the chunks were embedded with, which is why the embedding model is fixed on create and only changes via an explicit migration.

The end-to-end shape

  1. POST /v1/vector_stores                 -> vs_...   (pins embedding_model)
  2. POST /v1/files            (multipart) -> file_... (purpose=attachments)
  3. POST /v1/vector_stores/{vs}/files     -> status: pending
        |
        |  async ingest (parse -> chunk -> embed)
        v
     GET /v1/vector_stores/{vs}/files/{f}  -> status: completed
  4. POST /v1/assistants                   -> asst_... (tools: file_search)
  5. thread + run with
        tool_resources.file_search.vector_store_ids = [vs_...]
        |
        v
     file_citation annotations on the answer message
Five calls: create store, upload, attach, wait for completed, run.

Step 1: Create a vector store

POST /v1/vector_stores with a developer key (Authorization: Bearer ko_..., scope api:write). The only field the server insists on is name. embedding_model defaults to text-embedding-3-small if you omit it. The response is a 201 with the store id (vs_...), which you will reference everywhere downstream.

curl -sS https://api.fightclub.pro/v1/vector_stores \
  -H "Authorization: Bearer ko_$FC_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "acme-handbook",
    "embedding_model": "text-embedding-3-small",
    "metadata": { "customer_id": "cus_42" }
  }'
{
  "id": "vs_8x3k9m2p7q",
  "object": "vector_store",
  "name": "acme-handbook",
  "embedding_model": "text-embedding-3-small",
  "embedding_dimensions": null,
  "graphrag_enabled": false,
  "encryption": "none",
  "vector_sealing": "none",
  "vector_index": "auto",
  "status": "active",
  "file_counts": { "total": 0 },
  "bytes": 0,
  "metadata": { "customer_id": "cus_42" },
  "created_at": 1750000000
}

Create parameters

FieldTypeDefaultNotes
namestring(required)Non-empty, max 256 bytes. Empty name -> 400 invalid_name.
embedding_modelstringtext-embedding-3-smallMust be one of the supported models below. Fixed after create. Unsupported value -> 400 invalid_embedding_model.
dimensionsintegernullOptional override of the model native dimension. Must be a positive integer or you get 400 invalid_dimensions.
metadataobject{}Up to 4 KB, 16 keys, key <=64 chars, value <=512 chars. Over 4 KB -> 400 metadata_too_large. Key it with your customer id for one-store-per-customer lookup.
graphrag_enabledbooleanfalseAlso build a knowledge graph; file_search then returns a facts array alongside chunk citations. Bills as a per-GB-day line (first 1 GB-day per store per day free).
encryptionstringnonenone | managed | byok. Sealed stores (managed/byok) keep chunk text and vectors encrypted at rest.
vector_sealingstringfull when sealednone | source | full. Ignored when encryption=none. Setting it on an unsealed store -> 400 invalid_vector_sealing.
vector_indexstringautoauto | flat | ivf. ivf requires a sealed store; on an unsealed store -> 400 ivf_requires_encryption.
keyobject(byok only)For encryption=byok: { "type": "passphrase", "passphrase": "..." }, passphrase >=12 chars. Never persisted. Omitting it -> 400 byok_key_required. type:"kms" -> 400 byok_kms_unavailable.
One store per customer

Tag the store with metadata.customer_id at create, then list and filter on that key to find the right store on later calls. This is the v1 idiom for multi-tenant RAG. The lookup costs one list call the first time you touch a customer.

Embedding-model choice

The store embeds chunks and queries with one model, set once. Pick on dimension count (storage cost and recall), provider and language coverage. Smaller vectors cost less to store and search; larger ones generally retrieve better on dense technical corpora. The supported set and native dimensions:

Pass the bare id (no fc: prefix) in embedding_model. The store resolves the provider internally.
ModelProviderDimensions
text-embedding-3-smallOpenAI1536
text-embedding-3-largeOpenAI3072
text-embedding-ada-002OpenAI1536
embed-english-v3.0Cohere1024
embed-multilingual-v3.0Cohere1024
voyage-3-largeVoyage1024
voyage-3-liteVoyage512
mistral-embedMistral1024
text-embedding-004Google768

Got the model wrong after you already ingested? You do not delete and rebuild. POST /v1/vector_stores/{id}/migrate with a new embedding_model (and optional dimensions) re-embeds the corpus under the new model. The query path keeps serving the old vectors until the migration completes. Pick deliberately the first time anyway, because a migration re-embeds every chunk and bills accordingly.

Step 2: Upload the file

POST /v1/files is multipart, not JSON. Send the raw bytes in a file part and a purpose part. For RAG the purpose is attachments. The hard cap is 512 MB per file; over that you get a 413 with code file_too_large. This endpoint is developer-key only. A client token (Authorization: Client ...) gets a 403 endpoint_not_allowed_for_client_token.

curl -sS https://api.fightclub.pro/v1/files \
  -H "Authorization: Bearer ko_$FC_KEY" \
  -F purpose=attachments \
  -F file=@handbook.pdf
{
  "id": "file_a1b2c3d4e5",
  "object": "file",
  "bytes": 4823104,
  "created_at": 1750000020,
  "filename": "handbook.pdf",
  "purpose": "attachments",
  "mime_type": "application/pdf",
  "expires_at": null
}
`purpose`
One of batch, attachments, vision. RAG uploads use attachments. A missing or unknown value returns 400 (missing_purpose or invalid_purpose).
`file`
The bytes. Missing or not a file part -> 400 missing_file. If the part arrives as application/octet-stream the MIME type is recovered from the filename extension.
MIME limits downstream
A vector-store file can be any parseable document. The per-message PDF/doc cap (20 MB, max 2 per chat message) applies to inline chat attachments, not to vector-store ingest, which takes the full 512 MB.

Step 3: Attach the file to the store

POST /v1/vector_stores/{id}/files with { "file_id": "file_..." } links the uploaded file to the store and kicks off ingest. The response is a 201 carrying a vector-store-file record. Notice the status is pending. Attach returns immediately; parsing, chunking and embedding happen asynchronously after the row commits.

curl -sS https://api.fightclub.pro/v1/vector_stores/vs_8x3k9m2p7q/files \
  -H "Authorization: Bearer ko_$FC_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "file_id": "file_a1b2c3d4e5" }'
{
  "id": "vsf_77a0c4e1",
  "object": "vector_store.file",
  "vector_store_id": "vs_8x3k9m2p7q",
  "file_id": "file_a1b2c3d4e5",
  "status": "pending",
  "last_error": null,
  "chunk_count": 0,
  "parser_tokens_used": null,
  "embedding_tokens_used": null,
  "created_at": 1750000030,
  "completed_at": null
}
Attach is idempotent

Attaching a file id that is already on the store returns the existing record instead of starting a second ingest. The record is addressable two ways afterwards: by its own id (vsf_..., the id field) and by the file_... id. Both resolve on the retrieve and detach routes.

Two failure modes worth knowing on attach. If the store id is not yours or does not exist, you get a 404 vector_store_not_found. If you attach a file_id you do not own, you get a 404 file_not_found. A store mid-delete returns 409 vector_store_unavailable; a store suspended for non-payment returns 402 vector_store_suspended.

Bulk attach

For many files at once, POST /v1/vector_stores/{id}/file_batches takes a file_ids array (max 500) and creates one ingest job per newly-attached file. The batch carries a roll-up file_counts object so you can poll the batch rather than each file. Over 500 ids returns 400 too_many_files.

Step 4: Poll ingest to completed

A file is not searchable until its status reaches completed. GET /v1/vector_stores/{id}/files/{fileId} returns the current state. The status moves through this machine:

StatusMeaningTerminal?
pendingAttached, queued, not yet picked up.No
in_progressParsing, chunking and embedding underway.No
completedChunks embedded and queryable. chunk_count and token fields are now populated.Yes
failedIngest errored. Read last_error for the reason. Recoverable with retry.Yes
cancelledYou cancelled it before it finished. Recoverable with retry.Yes

Poll with a backoff loop until you hit a terminal state. Treat failed and cancelled as errors and surface last_error. A failed or cancelled file can be re-queued with POST /v1/vector_stores/{id}/files/{fileId}/retry, which flips it back to pending and clears last_error. A pending or in_progress file can be stopped with the cancel sub-route.

while true; do
  STATUS=$(curl -sS \
    https://api.fightclub.pro/v1/vector_stores/vs_8x3k9m2p7q/files/file_a1b2c3d4e5 \
    -H "Authorization: Bearer ko_$FC_KEY" | jq -r .status)
  echo "ingest: $STATUS"
  case "$STATUS" in
    completed)          break ;;
    failed|cancelled)   echo "ingest did not complete"; exit 1 ;;
  esac
  sleep 2
done
Webhooks beat polling in production

Polling is fine for a script. For a server, register a vector_store.* webhook and let the vector_store.file.completed delivery wake your worker. You stop burning a poll loop and your ingest-to-searchable latency drops to the delivery time.

Retrieval runs inside an Assistants run, so you need an assistant whose tools include file_search. The assistant is reusable config: a model, instructions and the tool list. It holds no conversation state. model is required and must be a prefixed model ref, e.g. fc:openai/gpt-4o-mini. A bare gpt-4o-mini returns 400 invalid_model. The assistant name is unique per developer; a clash returns 409 assistant_name_in_use.

curl -sS https://api.fightclub.pro/v1/assistants \
  -H "Authorization: Bearer ko_$FC_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "fc:openai/gpt-4o-mini",
    "name": "handbook-qa",
    "instructions": "Answer using the supplied files only. Cite the file_id and chunk index for every claim.",
    "tools": [ { "type": "file_search" } ]
  }'

Write the instructions to force citation behaviour. The model decides whether and how to cite; an instruction like the one above makes citations reliable. You can pin the store ids on the assistant via tool_resources, but it is usually cleaner to pass them per run (next step) so one assistant serves every customer store.

Step 6: Run a thread query

A thread is the message container; a run executes the assistant on it. Put the question on the thread, then start a run that points file_search at the store ids you want searched. Only one run can be active on a thread at a time, so a second concurrent run returns 409 thread_locked. The OpenAI SDK drop-in does this in a few lines because Ringside speaks the Assistants shim; the only Ringside-specific touch is the FC-Customer header to attribute spend.

import os, time
from openai import OpenAI

client = OpenAI(
    base_url="https://api.fightclub.pro/v1",
    api_key=os.environ["FC_KEY"],            # ko_...
)
ASSISTANT_ID = os.environ["FC_ASSISTANT_ID"]  # asst_...
STORE_ID = "vs_8x3k9m2p7q"

thread = client.beta.threads.create()
client.beta.threads.messages.create(
    thread_id=thread.id,
    role="user",
    content="What is the expense reporting cut-off?",
)

run = client.beta.threads.runs.create_and_poll(
    thread_id=thread.id,
    assistant_id=ASSISTANT_ID,
    tools=[{
        "type": "file_search",
        "file_search": {"vector_store_ids": [STORE_ID]},
    }],
    extra_headers={"FC-Customer": "cus_42"},   # attribute spend to the end-customer
)

if run.status != "completed":
    raise RuntimeError(f"run ended {run.status}")

Under the hood the run embeds the question with the store pinned embedding model, runs a hybrid search over the store tenant, formats the top chunks as file_search tool output and feeds them to the model. The tool output the model sees is a JSON object: a results array of { index, file_id, chunk_id, text, score, source: { chunk_index, page, type } }, plus a facts array when the store has GraphRAG on. The model then writes its answer and attaches file_citation annotations.

Run status values

StatusWhat to do
queued / in_progressKeep polling (or use create_and_poll, which blocks for you).
requires_actionThe assistant called a function tool. Answer with submit_tool_outputs. Pure file_search assistants do not hit this.
completedRead the latest thread message and parse citations.
failed / expired / cancelledTerminal without an answer. Surface the run error and retry.

Step 7: Parse citations from the output

After the run completes, fetch the newest thread message. Each text block carries an annotations array; the file_citation entries are your sources. A single answer can cite the same chunk twice, so deduplicate by (file_id, chunk_index). To render a human label, resolve file_id to a filename with GET /v1/files/{id}.

messages = client.beta.threads.messages.list(thread_id=thread.id, order="desc", limit=1)
msg = messages.data[0]

answer_parts, citations, seen = [], [], set()
for block in msg.content:
    if block.type != "text":
        continue
    answer_parts.append(block.text.value)
    for ann in block.text.annotations:
        if ann.type != "file_citation":
            continue
        fc = ann.file_citation
        key = (fc.file_id, fc.chunk_index)
        if key in seen:
            continue
        seen.add(key)
        src = client.files.retrieve(fc.file_id)   # filename for display
        citations.append({
            "file_id": fc.file_id,
            "filename": src.filename,
            "chunk_index": fc.chunk_index,
        })

print("\n".join(answer_parts))
for c in citations:
    print(f"  [{c['filename']} chunk {c['chunk_index']}]")

Retrieval only runs inside a file_search tool call on a run. There is no standalone retrieve endpoint that hands you raw chunks. Internally the tool builds each citation as { type, text, file_id, chunk_id, chunk_index, page, source_type, score } and the run surfaces those as file_citation annotations on the answer message, which is what step 7 parses. To inspect what retrieval actually returned, list the query log with GET /v1/vector_stores/{id}/queries (covered under the cost model below).

Async ingest, failures and edge cases

Ingest is fire-and-forget by design. The attach call commits the row and returns pending before a single chunk is embedded. Three things follow from that.

  • ·A store with files still ingesting will return partial or empty results. Gate your first query on every attached file reaching completed, or accept that early queries see only the chunks that landed so far.
  • ·An empty result is not an error. The retrieve path sets an empty flag and returns an empty results array. Your prompt should tell the model to say it does not know rather than invent an answer when retrieval comes back empty.
  • ·A failed ingest leaves last_error populated. Common causes are an unparseable or corrupt document. Retry re-queues it; if it fails twice on the same file, the file is the problem, not the pipeline.

Errors you will actually hit

Always branch on the code field, not the human message. The request id is the X-Request-Id response header.
HTTPcodeCause
400invalid_nameCreated a store with an empty name.
400invalid_embedding_modelembedding_model not in the supported set.
413file_too_largeUpload over the 512 MB cap.
400invalid_purposeUpload purpose not one of batch, attachments, vision.
400invalid_modelAssistant model was a bare name, not a prefixed ref.
402vector_store_suspendedStore suspended for non-payment; resolve billing.
403endpoint_not_allowed_for_client_tokenHit /v1/files with a Client token instead of a Bearer dev key.
404vector_store_not_foundStore id not yours or does not exist (never 403).
404file_not_foundAttached a file id you do not own.
409vector_store_unavailableAttached to a store mid-delete.
409assistant_name_in_useAssistant name already taken by another of your assistants.
409thread_lockedStarted a second run while one was already active on the thread.

Hard limits and defaults

File size
512 MB per upload. Larger -> 413 / file_too_large.
Batch size
Up to 500 file ids per file_batches call.
Default embedding model
text-embedding-3-small (1536 dims) when embedding_model is omitted.
Store metadata
4 KB, 16 keys, key <=64 chars, value <=512 chars.
Retrieval top-K
The file_search tool pulls 20 chunks per query. This is internal to the run and is not a request parameter you set.
Query-log pagination
GET /v1/vector_stores/{id}/queries lists past query rows, default 50, max 200. Page with after set to the previous page last_id. Most other Ringside lists default 20, max 100.
Embedding model lock
Fixed at create. Change only through POST /v1/vector_stores/{id}/migrate, which re-embeds the corpus.

Cost model

Three lines bill against your wallet (or the customer wallet when you set FC-Customer). Each call reports the exact charge in the X-Ringside-Wallet-Deducted response header and the running balance in X-Ringside-Wallet-Balance.

  • ·Parse plus embed tokens, one-off per file at the embedding model published rate.
  • ·Query embedding tokens, per retrieval, at the same rate.
  • ·Storage: vector index plus file storage per GB-day, with the first 1 GB-day per store per day free. GraphRAG, if enabled, adds its own per-GB-day line on the same free allowance.
  • ·The completion that consumes the retrieved chunks bills at the assistant model rate.

For a 50 MB handbook plus a hundred queries a day, storage is pennies a month and the query-side completion tokens dominate. GET /v1/vector_stores/{id}/queries lists past queries. Each row carries the query text, top_k returned, the top scores array, file_ids, latency_ms, embedding_tokens and an empty_result flag. That is your retrieval-quality debug surface when a customer says the answers are bad.

Sealed stores in one line

Set encryption to managed or byok and the chunk text and vectors stay encrypted at rest, resolved in-process. byok keeps the key off our servers entirely (you present a passphrase per session). It is the same five-step flow above; the only operational difference is the store idle-locks and you re-present the key. See the sealed RAG reference for the unlock-lease mechanics.

Pin the embedding model once

The single most common RAG mistake on Ringside is changing your mind about the embedding model after ingest. You cannot search a store with a model the chunks were not embedded with. Decide on dimensions and provider before you upload, and reach for migrate only when you genuinely need to.