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. With flinq-rerank-2.0 the rerank score is a calibrated
probability, so the same call also tells you when a position has no match in
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, with "task": "lv_matching" so the reranker reads the
query as an LV position. 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-rerank-2.0", "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" ], "task": "lv_matching", "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-rerank-2.0", "query": position_text, "documents": [c["text"] for c in catalog], "task": "lv_matching", "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-rerank-2.0', query: positionText, documents: catalog.map((c) => c.text), task: 'lv_matching', 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. Against the sample data, the output of the script below looks like this
(the last column is the relevance_score of the best hit):
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-rerank-2.0"
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], "task": "lv_matching", "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 reads the whole 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 as documents, embed the LV lines
per tender as queries with "task": "lv_matching", and score locally. All flinq
vectors are L2-normalized, so cosine similarity is a plain dot product:
import numpy as np
def embed(texts, **query_side): r = requests.post(f"{API}/v1/embeddings", headers=HEADERS, json={"model": "flinq-embed-2.0", "input": texts, **query_side}) 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])) # documents: once, then cachepos = np.array(embed([p["text"] for p in positions], # queries: per tender input_type="query", task="lv_matching"))scores = pos @ cat.Tbest = scores.argmax(axis=1)To keep the calibrated no-match decision at scale, rerank only each position’s
local top 20 or so with /v1/rerank: the embedding shortlist does the recall,
the reranker decides whether the best candidate is a match at all.
Both helpers live in Bulk & batch: embed for a single
request, and for catalogs beyond one request (256 inputs) 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 in the template
gallery:
Match GAEB X83 tender positions to catalog SKUs.
Import it straight into your n8n instance from the listing, or download the
raw JSON
(flinq-lv-matching.n8n-workflow.json)
and use “Import from File” instead. Either way, 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. - Decide with the score. On
flinq-rerank-2.0,relevance_scoreis a calibrated probability, so one threshold works across positions: accept the best hit above it and treat positions below it as having no catalog match. Start around 0.5, tune the cutoff on a hand-checked sample, and review-queue borderline scores instead of auto-accepting them. See Rerank. - Keep rerank queries short: on
flinq-rerank-2.0a rerank query may have up to 1,024 tokens and each catalog text up to 3,000. For very long LV texts, send the short text plus the start of the long text. - Continue with Early-phase cost prediction to price matched positions, or RAG to make the whole LV searchable.