Engineering drawing search with AI: what Kuken built and how to build your own

Andreas Nigg
Andreas Nigg · Co-Founder, Pondhouse Data
Published 18 min read
Engineering drawing search with AI: what Kuken built and how to build your own
On this page

Kuken Industries, a Japanese maker of cooling towers and ventilation equipment, has made more than 70 years of engineering drawings searchable with AI. Engineers type a drawing or part number, or upload an image or PDF of a drawing or a parts list. The system reads the numbers, proposes matching drawing pages with previews, and the engineer decides by checking the original. During the trial, Kuken says, a search took 4.5 minutes on average instead of tens of minutes to half a day.

Kuken has not published the model, the architecture or the cost, so we built the pattern ourselves: a vision model reads the title blocks, Postgres searches normalized numbers, full text and visual similarity, and a second model call compares the candidates. We tested it on 134 pages, including 60 real scans from 1909. This post walks through the Kuken case, the building blocks of a drawing search, our prototype with code and measurements, and the places where it breaks.

What Kuken built

Kuken Industries (空研工業) is based in Fukuoka, was founded in 1956 and, according to its press release, has 572 employees. It builds air-conditioning and ventilation equipment for large buildings, shopping centers and factories and holds one of the largest shares of Japan's cooling-tower market.

The problem will sound familiar to any equipment maker with a long history. When a cooling tower needs a replacement part, parts made today do not always fit older towers, and without the original drawing the part may have to be designed again from scratch. A search by model or part name often returns several drawings, because Kuken has adapted its equipment to individual customers for decades, so the design depends on the customer and the installation date. When the archive was on paper, a search could take half a day. Even after digitization, engineers had to know which name or number to look up and where the file was. Newer engineers asked experienced colleagues.

Kuken built the system with Recursive, a Tokyo AI firm and official OpenAI partner, on the OpenAI API. According to OpenAI, the two companies tested several models on a range of drawings to see how accurately, and at what cost, each could extract the details.

The workflow in the web app, as described by OpenAI and the press release:

  1. The engineer enters a drawing or part number, or uploads an image or PDF of a drawing or parts list. A screenshot of a parts list saves typing in each number.
  2. The AI extracts the part numbers and structures them.
  3. The search returns exact matches plus candidates that allow for different revision numbers, sub-numbers (枝番) and similar spellings.
  4. The app shows the matching drawing pages with previews, including drawings of similar designs.
  5. The engineer checks the tower's specifications and past replacement records and decides.

The press release puts the principle in one sentence: the AI supports searching and comparing, and the engineer checks the original and makes the final call.

A second feature is still a prototype: a drawing check. Per the press release, it compares the internal factory order notice with the outline drawing and the delivery specification, shows the differences and where they are, and also runs drawing-specific checks such as missing mandatory notes or balloons. It explicitly does not catch every handwritten note or layout error, and the engineer has the final say.

The results Kuken reports

All figures come from Kuken and have not been independently verified:

MetricValueType of figureSource
Search time in the trial (Fukuoka plant)4.5 minutes on average, previously tens of minutes to an hour, sometimes half a daymeasured in the trialpress release, MONOist
Search hours per yearabout 90 % fewerprojection based on actual figures for fiscal 2025OpenAI, MONOist
Drawing checkabout 90 % of the issues the AI flagged were genuine discrepanciesprototype testOpenAI

The 90 % for the drawing check is precision: nine out of ten flagged issues were real. How many issues the tool missed is not reported. OpenAI adds that experienced engineers spend some of the saved time mentoring newer colleagues, and that reviewing drawings in the app cuts printing. According to the press release, Kuken aims to roll the system out fully at its Fukuoka and Chiba plants by the end of the year.

Kuken has not disclosed the model, the architecture, the cost, the size of the archive or how the times were measured. Everything this post says about the technology from here on comes from our own prototype, not from Kuken.

An engineering drawing is mostly graphics. Searchable text sits almost entirely in the title block and the parts list, and that is exactly where the numbers engineers search for are printed. That leads to a pipeline with four stages:

How an AI drawing search works: prepare, read, search, decide

  1. Prepare: scans and PDFs become images, blueprints (white lines on a dark background) are inverted, skewed scans are straightened. The title block in the lower right is also saved as a separate crop.
  2. Read: a vision model reads drawing number, revision, title, material, date and the parts list into a fixed schema. A local OCR engine reads the title block independently, and mismatches get flagged.
  3. Search: the exact number, in normalized form, comes first. After that, several ranked lists are merged: fuzzy matching on the numbers, full-text search over titles and OCR text and, when an image was uploaded, visual similarity.
  4. Decide: the engineer sees previews and, on request, an AI comparison per candidate, checks the original and confirms or rejects. Those decisions can later improve the ranking.

The order has a reason. Numbers are precise and cheap to search, so they carry the search. Visual similarity helps when there is no number, for example with a photo taken on site. And because drawings in one product family often differ only in their dimensions, a person makes the final call.

Our prototype and the test data

To measure whether a search works, you need drawings whose correct answer you know. We used two datasets:

  • 74 generated drawings from a fictional manufacturer: six kinds of parts (flange, cover, shaft, bushing, bracket, valve body), five sizes each and one to four revisions, with a title block, a revision table and German material designations. Older sheets print the number in the old format KT 2041/03, newer ones as KT-2041-03, and 60 of the 74 sheets are aged to look like old scans (noise, ink loss, skew, JPEG). On top of that come five parts lists with 40 lines in messy notation and 30 photographed crops taken at an angle.
  • 60 real scans from the US National Archives: engine drawings of the destroyer USS Preston from around 1909, in the public domain, with valves, pumps, turbine parts and printed parts lists on the sheet.

Generated A3 drawing of a flange with title block and revision table

Real drawing from 1909: standard composition valve of the USS Preston, inverted (source: National Archives)

The prototype uses a handful of components, all in Python 3.13, tested on November 11, 2026:

ComponentToolVersion
Reading title blocksOpenAI gpt-6.1-terra via the Responses APIopenai 3.49.0, pydantic 2.14.3
OCR cross-checkRapidOCR with PP-OCRv6 on onnxruntime3.10.0 / 1.31.1
Database and searchPostgreSQL with pg_trgm and pgvector17.11 / 1.6 / 0.8.0
Image vectorsfacebook/dinov2-base on CPUtransformers 5.22.2, torch 2.15.0+cpu
Image preparationPyMuPDF, Pillow, OpenCV2.1 / 13.0.0 / 5.0.0.93
UIFastAPI with a single Jinja page0.162.4

One installation note: pip install torch pulls the CUDA build from PyPI, several gigabytes of it. For a CPU-only server, install torch from the index https://download.pytorch.org/whl/cpu.

Reading title blocks with a vision model

The model gets two images per page: the whole sheet at high resolution (detail: "high") and the lower-right crop at original resolution (detail: "original"). The whole sheet provides context, such as a parts list in the top right corner. The crop keeps small digits in the title block from getting lost when the sheet is scaled down.

The answer is bound to a Pydantic schema. That guarantees valid JSON with fixed fields and turns "not on the sheet" into null instead of an invented value:

1class PartRow(BaseModel):
2 pos: str | None = Field(description="Position or piece number in the parts list")
3 name: str | None
4 part_no: str | None = Field(description="Part, pattern or drawing number of this row exactly as printed")
5 material: str | None
6
7
8class DrawingMeta(BaseModel):
9 drawing_no: str | None = Field(description="Drawing number from the title block exactly as printed, without the revision")
10 revision: str | None = Field(description="Current revision / index letter or number; null if the sheet has none")
11 title: str | None = Field(description="Part or sheet title as printed")
12 material: str | None
13 date: str | None = Field(description="Drawing date as printed")
14 scale: str | None
15 other_numbers: list[str] = Field(description="Other identifiers on the sheet (order, file, project, ship numbers)")
16 parts_list: list[PartRow] = Field(description="Rows of a parts list / bill of materials printed on the sheet, else empty")
17 legibility: Literal["good", "partly_illegible", "illegible"]

The call itself is short. responses.parse returns a DrawingMeta object directly, and setting reasoning to low keeps it fast and cheap:

1def call_model(content, schema, effort="low"):
2 t = time.time()
3 resp = client().responses.parse(
4 model=MODEL,
5 input=[{"role": "user", "content": content}],
6 text_format=schema,
7 reasoning={"effort": effort},
8 )
9 ...
10
11
12def extract_drawing(img, layout_hint=None):
13 prompt = EXTRACT_PROMPT + ("\n\nAbout this archive's title block: " + layout_hint if layout_hint else "")
14 content = [
15 {"type": "input_text", "text": prompt},
16 {"type": "input_image", "image_url": to_data_url(img, 2400), "detail": "high"},
17 {"type": "input_image", "image_url": to_data_url(title_block_crop(img), 1600), "detail": "original"},
18 ]
19 return call_model(content, DrawingMeta)

The prompt asks the model to copy numbers character by character as printed and to mark unreadable characters with ?. Normalization happens later in code, not in the model. For an aged sheet from 1973, this came back:

1{
2 "drawing_no": "KT 2041/01",
3 "revision": "A",
4 "title": "Flansch DN80 PN16",
5 "material": "St 37-2",
6 "date": "1973-01-16",
7 "scale": "1:2",
8 "other_numbers": [],
9 "parts_list": [],
10 "legibility": "good"
11}

On the 74 generated sheets, the model got number, revision, title, material and date right every time, aged or not. Plain OCR with a regular expression also found number and revision on all 74 sheets. Mostly that tells you our generated title blocks were too clean: printed lettering, always the same layout. The real scans were a lot harder.

Each page used 4,904 input and 305 output tokens on average. At the list prices for gpt-6.1-terra (1 US dollar per million input tokens, 5 US dollars per million output tokens), that is about 0.6 cents per page. By that arithmetic, 100,000 pages cost about 640 US dollars, and half that with the Batch API according to the price list. The median call took 4.4 seconds.

The OCR cross-check

A vision model does not read perfectly, and its errors look plausible. So a second, completely different technique reads the same title block: the local OCR engine RapidOCR. If the number the model read does not roughly appear in the OCR text, the page gets flagged:

1def cross_check(drawing_no, ocr_lines):
2 """Does the local OCR see the same drawing number as the model? Returns (score, best OCR line)."""
3 from rapidfuzz import fuzz
4 target = norm(drawing_no)
5 if not target or not ocr_lines:
6 return 0.0, None
7 best = max(ocr_lines, key=lambda l: fuzz.partial_ratio(target, norm(l["text"])) if len(norm(l["text"])) >= 4 else 0)
8 return fuzz.partial_ratio(target, norm(best["text"])), best["text"]

Use partial_ratio, not a plain comparison. On the old sheets the number is usually preceded by "Dr." or "Dr. No.", and OCR reads that as part of the number (DR.NO.62-553-69). With a plain comparison we first flagged 30 of the 60 real scans, almost all of them wrongly. With the substring comparison, four flags remained. Two were real misreads by the model, and two were fractions like "1½" that OCR reads as "12".

When the model misreads the title block

The 1909 scans showed an error that will happen in any archive that grew over decades. Our prompt said the revision sits in the "Index" or "Rev" field, as it does on German drawings. The New York Shipbuilding Company sheets, however, have a box in the lower right labeled "INDEX / GROUP / FILE NUMBER", which is a filing reference:

Title block of a 1909 drawing with drawing number N-583L-1½ and the filing box INDEX 24 (source: National Archives)

The model followed our prompt and returned the filing number as the revision:

1{
2 "drawing_no": "N-583L-1½ R",
3 "revision": "24",
4 "title": "STANDARD 1½ COMPOSITION VALVE FOR PRESSURES FROM 0-100 LBS",
5 "material": null,
6 "date": null,
7 "scale": "12\"",
8 "other_numbers": [
9 "T. B. D. No. 19",
10 "Q",
11 "0-134"
12 ],
13 ...
14}

The fix is one sentence per title-block layout, appended to the prompt for pages from that collection:

1LAYOUT_HINTS = {
2 "nara": "1909 US Navy sheets (New York Shipbuilding Co.). The drawing number follows 'Dr' or 'Dr. No.' "
3 "in the title box. The small box 'INDEX / GROUP / FILE NUMBER' is a filing reference, not a revision: "
4 "these sheets have no revision field, so return null. Keep trailing letters and fractions as part of the number.",
5}
PromptPagesRevision wrongly filledOCR confirms the numberParts list found
generic6027 (45 %)57 (95 %)50 (83 %)
with layout hint60056 (93 %)50 (83 %)

The hint removed the revision error completely, but it also introduced a new one: 62-534-4 became 62-534-A, probably because of the sentence about trailing letters. The OCR cross-check flagged exactly that page. Two other misreads stayed in both versions: 62-553-7 read as 62-333-7 (flagged by the cross-check) and 62-584-6 read as G2-584-6. OCR made the second mistake too, so it was not flagged, but the normalization in the next section absorbs it.

Two practical rules follow. First, give every title-block layout in your archive a short description, and test any change to it on a sample before you re-read the whole archive. Second, put flagged pages on a review list for a person during ingestion. A misread number makes a drawing impossible to find: in our test, the correct search for 62-553-7 did not return the affected sheet in the top three, because the index said 62-333-7.

Normalizing drawing numbers and fuzzy matching

Kuken names revisions, sub-numbers and spelling variants explicitly as the reason a plain search falls short. The same number shows up in many forms: KT-2041-03, KT 2041/03, KT204103, kt-2041-3 or with the letter O instead of a zero. The prototype therefore stores two keys next to the printed number:

1CONFUSABLE = str.maketrans("OILZSGB", "0112568") # characters OCR and models swap with digits
2
3
4def norm(s):
5 """Normalise an identifier for matching: upper case, confusable letters to digits, drop separators."""
6 if not s:
7 return ""
8 return re.sub(r"[^A-Z0-9]", "", s.upper().translate(CONFUSABLE))
9
10
11def key(s):
12 """Like norm(), but leading zeros of each number group are dropped: KT-2041-03 and kt-2041-3 -> KT20413."""
13 if not s:
14 return ""
15 groups = re.split(r"[^A-Z0-9]+", s.upper().translate(CONFUSABLE))
16 return "".join(g.lstrip("0") or "0" if g.isdigit() else g for g in groups if g)

Both functions run on both sides, at ingestion and at search time. Turning real letters into digits (G2 becomes 62) is intended: as long as it happens symmetrically, the search still finds the drawing. Before that, the search splits off the revision when it can recognize one (Rev. B, Ind. C, -B) and sorts the matching revision to the top, otherwise the newest.

For everything that does not match exactly, Postgres uses the pg_trgm extension: it splits strings into three-character groups and finds similar numbers through a GIN index. The schema, shortened:

1CREATE EXTENSION IF NOT EXISTS vector;
2CREATE EXTENSION IF NOT EXISTS pg_trgm;
3CREATE TABLE IF NOT EXISTS drawings (
4 id text PRIMARY KEY,
5 ...
6 drawing_no text, -- as printed (model extraction)
7 drawing_no_norm text, -- normalised for matching
8 drawing_no_key text, -- normalised, leading zeros dropped
9 revision text,
10 title text,
11 ...
12 embedding vector(1536),
13 fts tsvector GENERATED ALWAYS AS (
14 to_tsvector('simple', coalesce(drawing_no, '') || ' ' || coalesce(title, '') || ' ' || coalesce(ocr_text, ''))
15 ) STORED
16);
17...
18CREATE INDEX IF NOT EXISTS drawings_no_trgm ON drawings USING gin (drawing_no_norm gin_trgm_ops);
19CREATE INDEX IF NOT EXISTS parts_no_trgm ON drawing_parts USING gin (part_no_norm gin_trgm_ops);
20CREATE INDEX IF NOT EXISTS drawings_fts ON drawings USING gin (fts);
21CREATE INDEX IF NOT EXISTS drawings_vec ON drawings USING hnsw (embedding vector_cosine_ops);

The drawing_parts table holds the parts-list rows the model found on the sheets (334 rows on 50 of the 60 old scans). That way a part number also finds a drawing where the part only appears in the parts list.

To see how much normalization buys, we searched for all 30 parts in seven notations each. We counted whether the right part was in first place (Hit@1) or in the top three (Hit@3). For the notation with a revision, the revision has to match as well.

Query notationString equalsNormalized equalsHit@1 without zero keyHit@1Hit@3
KT-2041-03 (new format)67 %100 %100 %100 %100 %
KT 2041/03 (old format)53 %100 %100 %100 %100 %
KT2041030 %100 %100 %100 %100 %
kt-2041-30 %0 %20 %100 %100 %
KT-2O41-03 (O for 0)0 %100 %100 %100 %100 %
KT-2041-03 Rev. B0 %100 %100 %100 %100 %
KT-2401-03 (transposed digits)0 %0 %50 %50 %70 %

Plain string matching finds only two thirds even with the correct notation, because older sheets carry the old format. The key without leading zeros takes kt-2041-3 from 20 to 100 %. Transposed digits stay hard: for KT-2401-03, trigram search does put the intended KT-2041-03 in first and second place (both revisions), but right behind it comes a cover numbered KT 2014/01. That is why the UI shows several candidates for fuzzy matches rather than a single answer. On 134 pages, the search itself took a median of one millisecond.

Search for kt-2041-3: the exact matches KT-2041-03 in revisions B and A come first, followed by similar numbers with their ranks

Exact matches first, then rank fusion

How do you merge an exact number search, a fuzzy search, full text and visual similarity into one list? The scores of the individual searches are not comparable: a trigram similarity of 0.6 and a cosine distance of 0.2 have nothing to do with each other. Reciprocal Rank Fusion (RRF) sidesteps that and only uses ranks. Each list gives a candidate 1 / (60 + rank) points, and the points add up, the same approach as the pgvector hybrid search example. The prototype puts exact matches in front:

1 scores = {}
2 for name, ids in lists.items():
3 for rank, i in enumerate(ids, 1):
4 scores[i] = scores.get(i, 0.0) + 1.0 / (K + rank)
5 fused = sorted(scores, key=lambda i: -scores[i])
6 order = exact + [i for i in fused if i not in exact]
7 out = []
8 for i in order[:limit]:
9 out.append({"id": i, "exact": i in exact, "rrf": round(scores.get(i, 0.0), 4),
10 "ranks": {n: ids.index(i) + 1 for n, ids in lists.items() if i in ids}})

Each result carries its rank from every list (trgm #2, fts #3, vector #1), and the UI shows them. That helps adoption: an engineer sees at a glance whether a candidate ranks high because of its number or only because it looks similar. For hybrid search in a managed service, see our post on RAG with Azure AI Search.

Uploading parts lists instead of typing

Kuken's engineers upload screenshots of parts lists instead of typing every number. The prototype does this with a second schema: the model decides whether the image is a drawing, a parts list or something else, and returns every line of a parts list. Each line's number then goes through the same search.

Uploaded parts list with numbers in different notations: KT 5032/01, KT201402, kt-5032-1, KT-6120-03 Ind. A

We tested five parts lists with 40 lines in total, two of them aged:

CheckResult
Recognized as a parts list5 of 5
Lines found40 of 40
Number copied exactly as printed37 of 40
Right drawing in first place (33 lines in the archive)30 of 33
Right drawing in the top three31 of 33
False exact match for parts not in the archive (7 lines)0 of 7

In the three numbers that were not copied exactly, the model silently "corrected" the letter O to a zero, although the prompt says otherwise. The correction happened to be right here, but do not count on it: normalize on both sides anyway. The three misses in first place were transposed digits. For standard parts that are not in the archive at all, the search never reported an exact match, only similar candidates.

Result of an uploaded parts list: the top three candidates per line, exact matches marked

We cover pulling structured data out of PDFs and Office files with language models in more detail in LLM document extraction.

Searching by visual similarity

Sometimes there is no number: a photo of a part on a printout, a crop without a title block. For those cases the prototype computes an image vector for every page with DINOv2, an openly available image model from Meta (Apache-2.0), and stores it in pgvector. Two preprocessing steps are meant to help: the frame and the title block are painted white, so the identical form template does not dominate the vector, and the image is cropped to the line work, so a photo and a full sheet end up at a similar scale.

1def embed(img, mode="mask+crop"):
2 """DINOv2 embedding: CLS token and mean of patch tokens, each L2-normalised, concatenated (1536 dims)."""
3 import torch
4 if mode in ("mask", "mask+crop"):
5 img = mask_page(img)
6 if mode == "mask+crop":
7 img = content_crop(img)
8 proc, model = dino()
9 inputs = proc(images=pad_square(img), return_tensors="pt", do_center_crop=False, do_resize=False)
10 with torch.no_grad():
11 out = model(**inputs).last_hidden_state[0]
12 cls, mean = out[0], out[1:].mean(0)
13 v = torch.cat([cls / cls.norm(), mean / mean.norm()]) / np.sqrt(2)
14 return v.numpy().astype(np.float32)

The image is first padded onto a white 448-pixel square. do_center_crop=False matters: the DINOv2 processor's default cuts out the center and would throw away half of a landscape drawing. On CPU, one vector took 0.83 seconds.

As queries we used 30 photographed crops of the views without the title block, taken at an angle, searched against all 134 pages:

Query image for visual search: angled photo of a flange drawing without the title block

Archive vectorSame part in first placeSame part in the top three
whole sheet20 %43 %
frame and title block painted out30 %43 %
painted out and cropped to the line work27 %43 %

The reason is simple: five flanges differ only in diameter, hole count and thickness, and a general image model sees "a flange" in all five. It did get the kind of part right: in 26 of 30 cases (87 %), the first result was the right kind. On the real scans, where sheets differ a lot, visual search found the source sheet in first place for 12 of 15 rotated, blurred crops covering 55 % of a sheet, and in the top three for 14.

Visual similarity is a good entry point ("show me bushings like this one") and helps spot duplicates, but it does not replace the number. That fits Kuken, whose search, according to the press release, is built on part numbers. If you want to tell size variants apart by image, you need more than a general-purpose vector, for example extracted main dimensions as a filter.

Search results across the 1909 scans, with flags for OCR mismatch and partly illegible sheets

The AI check as a second opinion

The last component follows Kuken's principle: the AI compares, the person decides. At the click of a button, the prototype sends the query and the candidate sheet to the model and gets a fixed result back: same_part with yes, no or uncertain, a list of concrete differences and the evidence on the drawing. The prompt only allows "yes" when the number family and, if requested, the revision match and nothing visible contradicts the query.

We ran this for 14 parts-list lines and their top three candidates each, 42 comparisons in total:

Candidate per test dataModel says "yes""uncertain""no"
wrong part (16)0016
right part (26)1349

The model never said "yes" to a wrong part. Most of the "no" answers for right parts had a good reason. Three times the number in the parts list had transposed digits, and the model rightly objected. Six times it found real contradictions on our drawings: the title reads "Haltewinkel 120x120" while the dimensions say 120 by 100 millimeters, or a shaft was lengthened in a revision without the title being updated. That was a bug in our generator, but exactly the kind of mistake that happens on real drawings. The four "uncertain" answers concerned numbers with the letter O, which the model reported as a possible transcription error.

AI check of a candidate: same number, but the title contradicts the dimensions

This matches what Kuken reports for its own drawing check: the flags are mostly justified. Whether a contradiction matters in a given case is for the engineer to judge. That is why every candidate in the UI has "Confirm" and "Reject" buttons, and the decisions go into their own table.

What the prototype shows and what it does not

The prototype shows that the pattern can be rebuilt from standard components in about 600 lines of Python, and that number search with proper normalization is very reliable. All test runs together cost 1.55 US dollars for 245 model calls, 196 of them for reading pages.

It does not show how well the approach works on your archive. 134 pages is small. With 100,000 pages there are many more similar numbers, and Hit@1 is likely to drop. Our generated title blocks were too clean to really test the model and OCR. The 1909 scans had no answer key, so we checked the disagreements between model and OCR by hand against the images. We did not test handwritten changes, folded sheets, multi-sheet drawing sets or CAD formats. Our test can neither confirm nor refute Kuken's time savings.

For your own project, the measurements suggest this order:

  1. Take a sample of 200 to 500 pages from every era and title-block layout and record the correct numbers by hand. Without that answer key, you cannot measure anything.
  2. Write a description for every layout and test the extraction on the sample, with an OCR cross-check and a review list for flagged pages.
  3. Derive the normalization rules from the notations that actually occur in your parts lists, your ERP system and your email.
  4. Only then add visual similarity and the AI check, and collect the engineers' confirmations to improve the ranking.

If you want to make a technical archive searchable this way and need help scoping it or building a prototype, get in touch.

Sources and image credits

Interested in building high-quality AI agent systems?

We prepared a comprehensive guide based on cutting-edge research for how to build robust, reliable AI agent systems that actually work in production. This guide covers:

  • Understanding the 14 systematic failure modes in multi-agent systems
  • Evidence-based best practices for agent design
  • Structured communication protocols and verification mechanisms
Andreas Nigg

About the author

Andreas Nigg

Co-Founder, Pondhouse Data

Andreas has spent more than eight years building data applications. At Pondhouse Data he designs and ships AI systems and integrates them into complex, real-world environments.