Construire un RAG avec Velqa.dev — embeddings, pgvector et reranking
Un pipeline RAG répond à partir de vos propres documents. Velqa fournit les trois appels de modèle — embedding, reranking, chat — sur une seule clé API.
Velqa n'héberge pas vos vecteurs : il n'y a pas de service d'indexation à provisionner, pas de base vectorielle à louer, pas de copie de vos documents chez nous. Vos données restent dans votre propre Postgres (ou tout autre magasin de vecteurs) et vous n'appelez Velqa que pour les étapes de modèle. Une clé, une facture.
Le pipeline
| Étape | Ce qui tourne | Où | Facturé |
|---|---|---|---|
| 1. Indexer | découpe + bge-m3 | votre code → /v1/embeddings | tokens d'entrée |
| 2. Chercher | plus proches voisins | votre base (pgvector) | rien |
| 3. Reclasser | qwen3-reranker-8b | /v1/rerank | tokens d'entrée |
| 4. Répondre | modèle de chat | /v1/chat/completions | entrée + sortie |
Les trois modèles sont inclus dans tous les plans (Starter, Dev, Pro, Recharge Boost).
Préparer la base
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE chunks (
id bigserial PRIMARY KEY,
source text NOT NULL,
content text NOT NULL,
embedding vector(1024) NOT NULL -- bge-m3 renvoie 1024 dimensions
);
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);La dimension est figée à la création de la table. Passer de
bge-m3(1024) àqwen3-embedding-8b(jusqu'à 4096) impose une nouvelle colonne et une réindexation complète de vos documents.
1. Indexer
import psycopg
from openai import OpenAI
from pgvector.psycopg import register_vector
client = OpenAI(base_url="https://api.velqa.dev/v1", api_key="VELQA_API_KEY")
conn = psycopg.connect("postgresql://localhost/rag")
register_vector(conn)
def chunk(text, size=1200, overlap=150):
step = size - overlap
return [c for i in range(0, len(text), step) if (c := text[i:i + size]).strip()]
def index(source, text):
parts = chunk(text)
# l'API accepte une liste : un appel par lot, pas un appel par morceau
vectors = client.embeddings.create(model="bge-m3", input=parts).data
with conn.cursor() as cur:
cur.executemany(
"INSERT INTO chunks (source, content, embedding) VALUES (%s, %s, %s)",
[(source, p, v.embedding) for p, v in zip(parts, vectors)],
)
conn.commit()Envoyez les morceaux par lots (100 à 500 selon leur taille) plutôt qu'un par appel : c'est le même prix au token, mais bien moins d'aller-retours et de risques de limite de débit.
2. Chercher
def search(question, k=30):
q = client.embeddings.create(model="bge-m3", input=[question]).data[0].embedding
with conn.cursor() as cur:
cur.execute(
"SELECT source, content FROM chunks ORDER BY embedding <=> %s LIMIT %s",
(q, k),
)
return cur.fetchall()<=> est la distance cosinus de pgvector — le même opérateur que celui déclaré dans l'index HNSW ci-dessus. Utiliser un autre opérateur ici ferait retomber la requête en balayage complet de la table.
Ratissez large à ce stade (30 à 50 candidats). La recherche vectorielle est rapide mais approximative : c'est le reranker qui tranche.
3. Reclasser
import requests
def rerank(question, rows, top=5):
resp = requests.post(
"https://api.velqa.dev/v1/rerank",
headers={"Authorization": "Bearer VELQA_API_KEY"},
json={
"model": "qwen3-reranker-8b",
"query": question,
"documents": [content for _, content in rows],
},
timeout=60,
)
resp.raise_for_status()
ranked = sorted(resp.json()["results"], key=lambda r: -r["relevance_score"])
return [rows[r["index"]] for r in ranked[:top]]Le reranker relit chaque paire (question, document) au lieu de comparer deux vecteurs : il coûte plus cher au token que l'embedding, d'où le passage de 30 candidats à 5. C'est l'étape qui fait le plus pour la qualité des réponses — voir reranking.
4. Répondre
def answer(question):
top = rerank(question, search(question))
context = "\n\n".join(
f"[{i}] ({src})\n{txt}" for i, (src, txt) in enumerate(top, 1)
)
resp = client.chat.completions.create(
model="glm-4.7",
messages=[
{
"role": "system",
"content": (
"Réponds uniquement à partir du contexte fourni. "
"Cite tes sources entre crochets. "
"Si le contexte ne suffit pas, dis-le au lieu d'inventer."
),
},
{"role": "user", "content": f"Contexte :\n{context}\n\nQuestion : {question}"},
],
)
return resp.choices[0].message.contentN'importe quel modèle de chat du catalogue convient. glm-4.7 est un bon compromis pour du RAG en français ; deepseek-v4-flash coûte moins cher sur de gros contextes.
Ce que ça coûte
Trois postes seulement, tous facturés au token :
- L'indexation est un coût unique par document, proportionnel à sa taille. Ré-indexer n'est nécessaire que si le document change ou si vous changez de modèle d'embedding.
- Chaque question paie un embedding (quelques dizaines de tokens, négligeable) puis un reranking sur les candidats retenus — c'est le poste dominant de la recherche, et il grandit avec
k. - La réponse paie le contexte en entrée et la réponse en sortie, comme n'importe quel appel de chat.
Les prix par modèle sont sur la page catalogue des modèles.
Pièges courants
- Morceaux trop gros : le contexte se dilue et le reranker note mal. Trop petits : la phrase perd son sens. 800 à 1500 caractères avec recouvrement est un bon point de départ.
- Envoyer les 30 candidats au modèle de chat au lieu des 5 reclassés : vous payez 6 fois le contexte pour une réponse moins bonne.
- Oublier de réindexer après un changement de modèle d'embedding. Des vecteurs produits par deux modèles différents ne sont pas comparables, même à dimension égale.
- Français, arabe, darija :
bge-m3etqwen3-reranker-8bsont multilingues. Ne traduisez pas vos documents avant de les indexer, et ne craignez pas les questions dans une langue différente de celle des documents. - Limites de débit à l'indexation en masse : voir limites de débit et espacez vos lots plutôt que de les paralléliser à outrance.
Voir aussi
- Embeddings — les deux modèles disponibles et leurs dimensions.
- Reranking — le détail de l'API
/v1/rerank. - Catalogue des modèles — prix par modèle.
