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.
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
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 data
Section titled “Sample data”- 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
.x83and catalog export any time.
Step 1: Parse the LV with /v1/extract
Section titled “Step 1: Parse the LV with /v1/extract”/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"import requests
with open("sample-lv.x83", "rb") as f: r = requests.post( "https://api.flinq.ai/v1/extract", headers={"Authorization": "Bearer flq_your_key_here"}, files={"file": f}, )tree = r.json()["tree"]import { readFile } from 'node:fs/promises';
const form = new FormData();form.append('file', new Blob([await readFile('sample-lv.x83')]), 'sample-lv.x83');const r = await fetch('https://api.flinq.ai/v1/extract', { method: 'POST', headers: { Authorization: 'Bearer flq_your_key_here' }, body: form,});const { tree } = await r.json();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 }'def match(position_text, catalog, top_k=3): r = requests.post( "https://api.flinq.ai/v1/rerank", headers={"Authorization": "Bearer flq_your_key_here"}, json={ "model": "flinq-pilot-otter", "query": position_text, "documents": [c["text"] for c in catalog], "top_k": top_k, }, ) r.raise_for_status() return r.json()["data"]async function match(positionText, catalog, topK = 3) { 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: positionText, documents: catalog.map((c) => c.text), top_k: topK, }), }); return (await r.json()).data;}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.
Step 3: Read the results
Section titled “Step 3: Read the results”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.6101.0020 850 m2 Stahlbetonbodenplatte C25/30 XC2, d = 25 cm. BET-C2530-XC2 0.7201.0030 96 m3 Streifenfundamente C25/30. Streifenfundament BET-C2530-XC2 0.6601.0040 420 m2 Mauerwerk Kalksandstein, d = 17,5 cm. Innenw KSM-175-D 0.7801.0050 18 St Stahlbeton-Fertigteilstützen C45/55. Fertigt FST-4040-FT 0.8201.0060 780 m2 Zementestrich CT-C25-F4, d = 60 mm. Zementes EST-CT25F4-V 0.8202.0010 260 m2 Metallständerwand, GK 12,5 mm beidseitig dop TBW-075-2x125 0.7302.0020 120 m2 Vorsatzschale GK auf Metallständerwerk. Vors TBW-050-VSS 0.7202.0030 380 m2 Abgehängte Gipskartondecke. Unterdecke aus G DEK-GK-125 0.6902.0040 45 m2 Brandschutzbekleidung F90 für Stahlstützen. BSP-F90-ST 0.7802.0050 12 St Revisionsklappen 40/40 cm. Revisionsklappen REV-400 0.8303.0010 85 m Grundleitung PVC-U DN 100. Grundleitung aus ROH-PVCU-100 0.6903.0020 240 m Heizungsrohr Kupfer 22 x 1,0 mm. Heizungsroh CU-22-10 0.7203.0030 14 St Flachheizkörper Typ 22. Flachheizkörper Typ HK-T22-600 0.7403.0040 65 m Lüftungskanal verzinkt 400/200 mm. Lüftungsk LK-VZ-400200 0.78Every 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.
The full script
Section titled “The full script”"""Match LV positions from a GAEB X83 against a product catalog with flinq."""import csvimport os
import 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"
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_herepython match_lv.pyScaling up: precompute the catalog
Section titled “Scaling up: precompute the catalog”/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 cachepos = np.array(embed([p["text"] for p in positions])) # per tenderscores = pos @ cat.Tbest = 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.
Run it in n8n
Section titled “Run it in n8n”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.
Adapt it
Section titled “Adapt it”- Swap
sample-lv.x83for your own tender file;.x81to.x86in GAEB DA XML 3.2 or 3.3 all parse. - Export your catalog or article master as
sku,name,descriptionand reuseload_catalogunchanged. - 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.