Skip to content

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.

  1. Create an account at console.flinq.ai/signup and create an API key. You get EUR 5 starting credit, no card required.
  2. Python 3.10+ with requests and psycopg (pip install requests "psycopg[binary]").
  3. 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
  1. 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-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.

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"

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.

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()

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?"
}'
SELECT rno, path, text FROM lv_chunks
ORDER BY embedding <=> %s
LIMIT 20;

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
}'

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.

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 psycopg
import requests
API = "https://api.flinq.ai"
KEY = os.environ["FLINQ_API_KEY"] # create a key at https://console.flinq.ai
HEADERS = {"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/v1
LLM_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_here
export LLM_BASE_URL=https://api.your-provider.com/v1
export LLM_API_KEY=your_llm_key
export LLM_MODEL=your_model_id
python rag_lv.py

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.

  • Ingest all your tenders: loop chunk_and_embed over your .x83 files and add a document column to the table. Chunking without a model is free, so you can also preview chunking on a corpus before committing to embed it.
  • context=headers prefixes chunks with more surrounding header text than path; 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: pass dimensions in 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.