RAG over construction data
Ground a language model in your own tender documents instead of letting it
invent answers. flinq does the retrieval: /v1/chunk
parses and embeds the LV in one call, pgvector stores the vectors, and
/v1/rerank sharpens the shortlist. Generation
stays yours: any OpenAI-compatible chat endpoint works, hosted or local, and
flinq never generates text. The result is an answer with position numbers as
citations.
Prerequisites and cost
Section titled “Prerequisites and cost”- Create an account at console.flinq.ai/signup and create an API key. You get EUR 5 starting credit, no card required.
- Python 3.10+ with
requestsandpsycopg(pip install requests "psycopg[binary]"). - A local PostgreSQL with pgvector (skip if it is still running from the cost prediction recipe):
docker run -d --name recipe-pg -e POSTGRES_PASSWORD=flinq \ -p 5432:5432 pgvector/pgvector:pg17- An OpenAI-compatible chat endpoint of your choice for the final step.
Cost of the flinq side: chunking the sample LV with embeddings is about 800 tokens, and each question costs a query embedding plus one rerank over the shortlist, together about 1,000 tokens. At EUR 0.40 per 1M tokens the whole walkthrough stays well under one cent. What the generator charges depends on your provider.
Sample data
Section titled “Sample data”sample-lv.x83: the same synthetic GAEB DA XML
3.3 tender as in the LV matching recipe, 15
positions plus two Vorbemerkung remarks. Swap in your own .x83 any time.
Step 1: Chunk and embed in one call
Section titled “Step 1: Chunk and embed in one call”With model set and output=both, /v1/chunk parses the GAEB hierarchy,
builds one chunk per position and returns text, metadata and the embedding
vector together. context=path prefixes each chunk with its group labels, so
“Rohbau” travels with the position into the vector.
curl https://api.flinq.ai/v1/chunk \ -H "Authorization: Bearer flq_your_key_here" \ -F "file=@sample-lv.x83" \ -F "level=position" \ -F "context=path" \ -F "model=flinq-pilot-otter" \ -F "output=both"import requests
with open("sample-lv.x83", "rb") as f: r = requests.post( "https://api.flinq.ai/v1/chunk", headers={"Authorization": "Bearer flq_your_key_here"}, files={"file": f}, data={"level": "position", "context": "path", "model": "flinq-pilot-otter", "output": "both"}, )chunks = r.json()["chunks"]import { readFile } from 'node:fs/promises';
const form = new FormData();form.append('file', new Blob([await readFile('sample-lv.x83')]), 'sample-lv.x83');form.append('level', 'position');form.append('context', 'path');form.append('model', 'flinq-pilot-otter');form.append('output', 'both');const r = await fetch('https://api.flinq.ai/v1/chunk', { method: 'POST', headers: { Authorization: 'Bearer flq_your_key_here' }, body: form,});const { chunks } = await r.json();Each chunk carries metadata (rno, path, unit, qty) and a
1024-dimension embedding. The response also echoes a strategy object
(gaeb_hierarchy, version 2). Store that version with your index: if a later
strategy version chunks differently, you know which documents to re-ingest.
Chunk tokens are billed once at EUR 0.40 per 1M; there is no second charge for
the embeddings on top; see Pricing.
Step 2: Store in pgvector
Section titled “Step 2: Store in pgvector”CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE lv_chunks ( id serial PRIMARY KEY, rno text, path text, text text, embedding vector(1024));import psycopg
with psycopg.connect(DSN) as conn: with conn.cursor() as cur: for c in chunks: cur.execute( "INSERT INTO lv_chunks (rno, path, text, embedding)" " VALUES (%s, %s, %s, %s)", (c["metadata"]["rno"], " / ".join(c["metadata"]["path"]), c["text"], str(c["embedding"])), ) conn.commit()Step 3: Retrieve a shortlist
Section titled “Step 3: Retrieve a shortlist”Embed the question and let pgvector return the nearest chunks. The corpus here is 17 chunks, so the shortlist is most of it; at real corpus sizes this first stage is where pgvector carries the load.
curl https://api.flinq.ai/v1/embeddings \ -H "Authorization: Bearer flq_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "model": "flinq-pilot-otter", "input": "Wie ist die Bodenplatte ausgeschrieben, und welcher Beton ist gefordert?" }'r = requests.post( "https://api.flinq.ai/v1/embeddings", headers={"Authorization": "Bearer flq_your_key_here"}, json={"model": "flinq-pilot-otter", "input": "Wie ist die Bodenplatte ausgeschrieben, und welcher Beton ist gefordert?"},)query_vector = r.json()["data"][0]["embedding"]const r = await fetch('https://api.flinq.ai/v1/embeddings', { method: 'POST', headers: { Authorization: 'Bearer flq_your_key_here', 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'flinq-pilot-otter', input: 'Wie ist die Bodenplatte ausgeschrieben, und welcher Beton ist gefordert?', }),});const queryVector = (await r.json()).data[0].embedding;SELECT rno, path, text FROM lv_chunksORDER BY embedding <=> %sLIMIT 20;Step 4: Rerank the shortlist
Section titled “Step 4: Rerank the shortlist”A cheap precision pass: the pool is pre-filtered, so the query-once billing of
/v1/rerank stays small, and the best chunks move
to the front before they enter the prompt.
curl https://api.flinq.ai/v1/rerank \ -H "Authorization: Bearer flq_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "model": "flinq-pilot-otter", "query": "Wie ist die Bodenplatte ausgeschrieben, und welcher Beton ist gefordert?", "documents": ["...shortlisted chunk texts..."], "top_k": 5 }'r = requests.post( "https://api.flinq.ai/v1/rerank", headers={"Authorization": "Bearer flq_your_key_here"}, json={"model": "flinq-pilot-otter", "query": question, "documents": [text for _, _, text in candidates], "top_k": 5},)context = [candidates[hit["index"]] for hit in r.json()["data"]]const r = await fetch('https://api.flinq.ai/v1/rerank', { method: 'POST', headers: { Authorization: 'Bearer flq_your_key_here', 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'flinq-pilot-otter', query: question, documents: candidates.map((c) => c.text), top_k: 5, }),});const { data } = await r.json();const context = data.map((hit) => candidates[hit.index]);Step 5: Generate a grounded answer
Section titled “Step 5: Generate a grounded answer”Build a prompt from numbered context blocks that carry the position number and group path as citation anchors, then call your own chat endpoint. flinq is not involved in this step.
def answer(question, context_rows): blocks = [ f"[{i}] (Position {rno or 'Vorbemerkung'}, {path})\n{text}" for i, (rno, path, text) in enumerate(context_rows, start=1) ] prompt = ( "Answer the question using ONLY the numbered context blocks below. " "Cite the block numbers and position numbers you used. " "If the context does not contain the answer, say so.\n\n" + "\n\n".join(blocks) + f"\n\nQuestion: {question}" ) r = requests.post( f"{LLM_BASE_URL}/chat/completions", headers={"Authorization": f"Bearer {LLM_API_KEY}"}, json={"model": LLM_MODEL, "messages": [{"role": "user", "content": prompt}]}, ) r.raise_for_status() return r.json()["choices"][0]["message"]["content"]This is the actual output of the script below against the sample LV, with a hosted OpenAI-compatible model as the generator:
17 chunks stored
retrieved: 01.0020 (Rohbau)retrieved: 01.0030 (Rohbau)retrieved: 02.0020 (Trockenbau)retrieved: 01.0050 (Rohbau)retrieved: 03.0010 (Technische Ausrüstung)
Die Bodenplatte ist als "Stahlbetonbodenplatte C25/30 XC2, d = 25 cm"ausgeschrieben, und es ist Beton C25/30 gefordert.
**Citations:**- Block [1], Position 01.0020: "Stahlbetonbodenplatte C25/30 XC2, d = 25 cm"The right position (01.0020) is retrieved first and the answer cites it. The generator saw only the five context blocks, which is the point: no hallucinated line items, and every claim traceable to a position.
The full script
Section titled “The full script”Ingestion is guarded: the LV is chunked and embedded once, and every further question pays only for its query embedding and the rerank pass.
"""RAG over a GAEB LV: flinq retrieves, your own LLM generates."""import os
import psycopgimport requests
API = "https://api.flinq.ai"KEY = os.environ["FLINQ_API_KEY"] # create a key at https://console.flinq.aiHEADERS = {"Authorization": f"Bearer {KEY}"}MODEL = "flinq-pilot-otter"DSN = os.environ.get("PG_DSN", "postgresql://postgres:flinq@localhost:5432/postgres")
# Any OpenAI-compatible chat endpoint works here: hosted or local.LLM_BASE_URL = os.environ["LLM_BASE_URL"] # e.g. https://api.your-provider.com/v1LLM_API_KEY = os.environ["LLM_API_KEY"]LLM_MODEL = os.environ["LLM_MODEL"]
def chunk_and_embed(path): """One call parses the LV and returns chunks with embeddings.""" with open(path, "rb") as f: r = requests.post( f"{API}/v1/chunk", headers=HEADERS, files={"file": f}, data={"level": "position", "context": "path", "model": MODEL, "output": "both"}, ) r.raise_for_status() return r.json()["chunks"]
def store(conn, chunks): with conn.cursor() as cur: for c in chunks: cur.execute( "INSERT INTO lv_chunks (rno, path, text, embedding)" " VALUES (%s, %s, %s, %s)", (c["metadata"]["rno"], " / ".join(c["metadata"]["path"]), c["text"], str(c["embedding"])), ) conn.commit()
def shortlist(conn, question, limit=20): r = requests.post( f"{API}/v1/embeddings", headers=HEADERS, json={"model": MODEL, "input": question}, ) r.raise_for_status() qvec = str(r.json()["data"][0]["embedding"]) with conn.cursor() as cur: cur.execute( "SELECT rno, path, text FROM lv_chunks" " ORDER BY embedding <=> %s LIMIT %s", (qvec, limit), ) return cur.fetchall()
def rerank(question, candidates, top_k=5): r = requests.post( f"{API}/v1/rerank", headers=HEADERS, json={"model": MODEL, "query": question, "documents": [text for _, _, text in candidates], "top_k": top_k}, ) r.raise_for_status() return [candidates[hit["index"]] for hit in r.json()["data"]]
def answer(question, context_rows): blocks = [ f"[{i}] (Position {rno or 'Vorbemerkung'}, {path})\n{text}" for i, (rno, path, text) in enumerate(context_rows, start=1) ] prompt = ( "Answer the question using ONLY the numbered context blocks below. " "Cite the block numbers and position numbers you used. " "If the context does not contain the answer, say so.\n\n" + "\n\n".join(blocks) + f"\n\nQuestion: {question}" ) r = requests.post( f"{LLM_BASE_URL}/chat/completions", headers={"Authorization": f"Bearer {LLM_API_KEY}"}, json={"model": LLM_MODEL, "messages": [{"role": "user", "content": prompt}]}, ) r.raise_for_status() return r.json()["choices"][0]["message"]["content"]
def main(): question = "Wie ist die Bodenplatte ausgeschrieben, und welcher Beton ist gefordert?" with psycopg.connect(DSN) as conn: with conn.cursor() as cur: cur.execute("SELECT count(*) FROM lv_chunks") stored = cur.fetchone()[0] if stored: print(f"{stored} chunks already stored\n") else: chunks = chunk_and_embed("sample-lv.x83") store(conn, chunks) print(f"{len(chunks)} chunks stored\n") candidates = shortlist(conn, question) context = rerank(question, candidates) for rno, path, _ in context: print(f"retrieved: {rno or 'Vorbemerkung'} ({path})") print("\n" + answer(question, context))
if __name__ == "__main__": main()export FLINQ_API_KEY=flq_your_key_hereexport LLM_BASE_URL=https://api.your-provider.com/v1export LLM_API_KEY=your_llm_keyexport LLM_MODEL=your_model_idpython rag_lv.pyMeasure retrieval, then trust generation
Section titled “Measure retrieval, then trust generation”Retrieval quality caps everything downstream. Before tuning prompts, build a small test set (five questions with known supporting positions are enough to start) and measure recall@k: the share of questions whose supporting chunk is in the retrieved top k. Only when recall is high does prompt work pay off; see Use cases.
Adapt it
Section titled “Adapt it”- Ingest all your tenders: loop
chunk_and_embedover your.x83files and add adocumentcolumn to the table. Chunking without a model is free, so you can also preview chunking on a corpus before committing to embed it. context=headersprefixes chunks with more surrounding header text thanpath; it raises the token count by design and can help when group labels alone are terse.- Retrieve on a smaller Matryoshka slice (
dimensions: 256) to shrink the index, then rerank at full 1024 for precision: passdimensionsin both calls to control each stage. - Swap the sample question for your own; the pipeline is language-agnostic within European construction language, so ask in the language of the documents or your working language.