Skip to content

LV matching

Match free-form positions from a bill of quantities (a Leistungsverzeichnis in Germany, a DPGF in France, a Computo Metrico in Italy) against your own product catalog, even when the wording differs. The pipeline is two endpoints: /v1/extract parses the GAEB file into positions, and /v1/rerank scores each position against the catalog. No vector database is needed for this recipe.

  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 (pip install requests).

Cost of the full run: one /v1/extract call is EUR 0.02 flat, and 15 rerank calls against a 40-entry catalog are roughly 30,000 tokens at EUR 0.40 per 1M tokens, about EUR 0.01. The whole recipe costs about EUR 0.03, so the free credit covers it more than a hundred times over.

  • sample-lv.x83: a synthetic GAEB DA XML 3.3 tender for a small industrial building, 15 positions in three groups (Rohbau, Trockenbau, Technische Ausrüstung).
  • sample-catalog.csv: 40 synthetic catalog entries. They overlap the LV positions in meaning but not in wording, which is exactly the situation semantic matching is for. Both files are invented demonstration data; swap in your own .x83 and catalog export any time.

/v1/extract takes the GAEB file as a multipart upload and returns the project header plus the full position tree with quantities, units and texts.

curl https://api.flinq.ai/v1/extract \
-H "Authorization: Bearer flq_your_key_here" \
-F "file=@sample-lv.x83"

The tree nests group, item and remark nodes. Flatten the tree from the call above into a list of positions, joining short and long text into one match string:

def flatten(tree):
positions = []
def walk(nodes):
for node in nodes:
if node["type"] == "item":
text = node["short_text"]
if node["long_text"]:
text += ". " + node["long_text"]
positions.append(
{"rno": node["rno"], "qty": node["qty"],
"unit": node["unit"], "text": text}
)
elif node["type"] == "group":
walk(node["children"])
walk(tree)
return positions
positions = flatten(tree)

Step 2: Match each position with /v1/rerank

Section titled “Step 2: Match each position with /v1/rerank”

For each LV position, send the position text as the query and the catalog texts as the candidate pool. The response comes back sorted by relevance, and each hit keeps its index into your input array, so you can map back to the SKU.

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": "Zementestrich CT-C25-F4, d = 60 mm. Zementestrich als Estrich im Verbund, Nenndicke 60 mm.",
"documents": [
"Verbundestrich zementgebunden CT-C25-F4. Zementgebundener Estrichmörtel, Einbaudicke 50 bis 70 mm im Verbund",
"Calciumsulfat-Fließestrich CAF-C30-F5. Selbstnivellierender Anhydrit-Fließestrich",
"Transportbeton C25/30 XC2 GK16. Lieferbeton für Bodenplatten und Fundamente"
],
"top_k": 3
}'

Fifteen sequential calls stay far below the rate limits. If you parallelize larger runs, keep at most 4 requests in flight per key; see Rate limits and errors.

Loop over the positions, take the best hit per position and print a match table. This is the actual output of the script below against the sample data:

15 positions, 40 catalog entries
01.0010 1 psch Baustelle einrichten und räumen. Baustellene BE-CONT-01 0.61
01.0020 850 m2 Stahlbetonbodenplatte C25/30 XC2, d = 25 cm. BET-C2530-XC2 0.72
01.0030 96 m3 Streifenfundamente C25/30. Streifenfundament BET-C2530-XC2 0.66
01.0040 420 m2 Mauerwerk Kalksandstein, d = 17,5 cm. Innenw KSM-175-D 0.78
01.0050 18 St Stahlbeton-Fertigteilstützen C45/55. Fertigt FST-4040-FT 0.82
01.0060 780 m2 Zementestrich CT-C25-F4, d = 60 mm. Zementes EST-CT25F4-V 0.82
02.0010 260 m2 Metallständerwand, GK 12,5 mm beidseitig dop TBW-075-2x125 0.73
02.0020 120 m2 Vorsatzschale GK auf Metallständerwerk. Vors TBW-050-VSS 0.72
02.0030 380 m2 Abgehängte Gipskartondecke. Unterdecke aus G DEK-GK-125 0.69
02.0040 45 m2 Brandschutzbekleidung F90 für Stahlstützen. BSP-F90-ST 0.78
02.0050 12 St Revisionsklappen 40/40 cm. Revisionsklappen REV-400 0.83
03.0010 85 m Grundleitung PVC-U DN 100. Grundleitung aus ROH-PVCU-100 0.69
03.0020 240 m Heizungsrohr Kupfer 22 x 1,0 mm. Heizungsroh CU-22-10 0.72
03.0030 14 St Flachheizkörper Typ 22. Flachheizkörper Typ HK-T22-600 0.74
03.0040 65 m Lüftungskanal verzinkt 400/200 mm. Lüftungsk LK-VZ-400200 0.78

Every position lands on the right catalog entry despite the different wording: the LV says “Metallständerwand, GK 12,5 mm beidseitig doppelt”, the catalog says “Trennwandsystem CW 75 doppelt beplankt”. On your own data, the numbers to track are top-1 and top-5 match accuracy against a hand-checked sample; see Use cases.

"""Match LV positions from a GAEB X83 against a product catalog with flinq."""
import csv
import os
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"
def extract_positions(path):
"""Parse the GAEB file and flatten the tree into a list of positions."""
with open(path, "rb") as f:
r = requests.post(f"{API}/v1/extract", headers=HEADERS, files={"file": f})
r.raise_for_status()
positions = []
def walk(nodes):
for node in nodes:
if node["type"] == "item":
text = node["short_text"]
if node["long_text"]:
text += ". " + node["long_text"]
positions.append(
{"rno": node["rno"], "qty": node["qty"],
"unit": node["unit"], "text": text}
)
elif node["type"] == "group":
walk(node["children"])
walk(r.json()["tree"])
return positions
def load_catalog(path):
with open(path, newline="", encoding="utf-8") as f:
return [
{"sku": row["sku"], "text": f"{row['name']}. {row['description']}"}
for row in csv.DictReader(f)
]
def match(position_text, catalog, top_k=3):
r = requests.post(
f"{API}/v1/rerank",
headers=HEADERS,
json={
"model": MODEL,
"query": position_text,
"documents": [c["text"] for c in catalog],
"top_k": top_k,
},
)
r.raise_for_status()
return r.json()["data"]
def main():
positions = extract_positions("sample-lv.x83")
catalog = load_catalog("sample-catalog.csv")
print(f"{len(positions)} positions, {len(catalog)} catalog entries\n")
for pos in positions:
best = match(pos["text"], catalog)[0]
sku = catalog[best["index"]]["sku"]
qty = f"{pos['qty']:g} {pos['unit']}"
print(f"{pos['rno']} {qty:>10} {pos['text'][:44]:<44} "
f"{sku:<16} {best['relevance_score']:.2f}")
if __name__ == "__main__":
main()

Run it with your key in the environment:

export FLINQ_API_KEY=flq_your_key_here
python match_lv.py

/v1/rerank embeds the candidate pool on every call, which is the right shape for small pools but re-bills the catalog once per position. For a large catalog or recurring tenders, embed the catalog once with /v1/embeddings, embed the LV lines per tender, and score locally. All flinq vectors are L2-normalized, so cosine similarity is a plain dot product:

import numpy as np
def embed(texts):
r = requests.post(f"{API}/v1/embeddings", headers=HEADERS,
json={"model": MODEL, "input": texts})
r.raise_for_status()
data = sorted(r.json()["data"], key=lambda d: d["index"])
return [d["embedding"] for d in data]
cat = np.array(embed([c["text"] for c in catalog])) # once, then cache
pos = np.array(embed([p["text"] for p in positions])) # per tender
scores = pos @ cat.T
best = scores.argmax(axis=1)

Both helpers live in Bulk & batch: embed for a single request, and for catalogs beyond one request (2048 inputs for flinq-pilot-otter) the embed_many concurrency helper with concurrency=4. If exact codes and units (DN 100, C25/30, XC4) must never lose to paraphrase, run the pool through hybrid search, whose BM25 leg keeps them honest.

The same pipeline exists as a ready-made n8n workflow: flinq-lv-matching.n8n-workflow.json. In n8n choose “Import from File”, open the JSON, create a Header Auth credential named flinq API (header Authorization, value Bearer flq_your_key_here) and assign it to the two API nodes, then execute. Two settings in the template are load-bearing: the download nodes override the User-Agent header because the flinq.ai file host rejects n8n’s default agent, and the rerank node is paced to one request per two seconds to stay within the per-key limit of 4 concurrent requests.

  • Swap sample-lv.x83 for your own tender file; .x81 to .x86 in GAEB DA XML 3.2 or 3.3 all parse.
  • Export your catalog or article master as sku,name,description and reuse load_catalog unchanged.
  • Store matches with a score threshold: on the sample run, correct matches score between 0.61 and 0.83, so review-queue anything below your measured cutoff instead of auto-accepting it.
  • Continue with Early-phase cost prediction to price matched positions, or RAG to make the whole LV searchable.