Andrija Birtić
Software developer | Tech lead | Architect
Designing and shipping production ML systems end to end.
Check out my Work
A documentation assistant
Production assistant over thousands of dense technical documents. Hybrid retrieval, research loops, an improvement loop driven by real feedback.
- LangGraph
- Vespa
- BGE-M3
- evals
An entity deduplicator
1.3 million guest profiles from multiple property systems, a quarter of them belonging to someone who already has another one. Hybrid retrieval as blocking, a calibrated neural resolver, guarded merges. All inside a streaming pipeline.
- PyTorch
- entity resolution
- hybrid retrieval
- calibrated nnet
- Anna ONeill14.02.1991anna.oneill91@mail.comNorthbrook0.97dupe
- Anna McNeill22.09.1987missingKingsmere0.11dupe
- A. O'Neill14.02.1991missingNorthbrook0.93dupe
- Alice O'Neill03.05.1991aoneill@mail.commissing0.15dupe
- Anna O'Neill30.06.1969missingmissing0.07dupe
- Anna O'Neill14.02.1992missingNorthbrook0.62dupe
every candidate is paired with the incoming profile, the nnet scores each paira new profile arrives
Hospitality recommender at streaming scale
Multiple source systems unified into a canonical model with Kafka connectors and ksqlDB, streaming services that clean and deduplicate through a fuzzy lake index and a neural deduplicator, and a two tower recommender answering inside a five second contract.
- Kafka
- ksqlDB
- two tower
- dedup nnet
- 1pool morning0.74
- 2bike rental0.71
- 3guided hike0.70
- 4late checkout0.58
Document AI scanner
Identity documents read on a webcam or a cheap flatbed, no dedicated scanning hardware. A pipeline of custom vision models and OCR in one era, one fine tuned vision language model in the next.
- object detection
- classification
- segmentation
- OCR
- VLM
- vision
- surnameO'NEILL
- nameANNA
- doc noN0482913
- birth14.02.1991
- expiry08.11.2031
- nationalityMML
- mrzchecksum ok
a structured answer, the mrz cross checked against a dedicated readerversion one starts, the document is detected in the frame
Sentiment analysis platform
Type a business name, pick the platforms. A nightly job scrapes the public reviews and an agent loop distills large quantities of them, across languages, into one report of severity ranked issues and concrete fixes.
- LangGraph
- scrapers
- agent loop
- automation
Soba je mirisala na vlagu, klima nije radila, a na recepciji nitko nije pomogao.
Das Frühstück war fantastisch, aber das WLAN fiel ständig aus.
Posizione perfetta vicino al mare e la colazione è davvero ricca.
Great pool and friendly staff, but check in took almost an hour.
- roomnegative
- air conditioningnegativehigh
- receptionnegative
Automated migration pipeline
parkedThe legacy system mapped into a Neo4j graph, an agent planning and executing the migration, a human in the loop on every consequential step.
- LangGraph
- Neo4j
- agent
- human in the loop
Check out my Research
Memory of How
activeA frozen model that learns a behavior from a few demonstrations and keeps it, no finetuning, no full training run. What it learned survives a full context wipe.
- PyTorch
- transformers
- meta learning
- continual learning
A documentation assistant
- LangGraph
- Vespa
- BGE-M3
- evals
Thousands of long, dense, interlinked technical documents, and the only working search engine was the support experts themselves. Every question waited in their queue. This assistant answers instead.
An example run. Questions arrive in five languages, retrieval is cross lingual.
Three representations per text, one ranking pass over all of them.
User feedback distills into improvements that visibly change the system.
A customised eval harness with a golden question set.
The problem
The software had thirty years of development behind it and around three thousand technical documents. Long, dense, interlinked. That volume is the first villain. No person holds it in their head, and keyword search never could. The support experts answering questions were the real search engine, and every question waited in their queue.
Solving the volume problem does not touch the second villain. Users ask in their own words, in Croatian, English, German, Italian, Slovenian. The docs answer in the software's words. The two vocabularies rarely meet.
The decision ledger
The system was built in iterations, side by side with the support experts who know the docs best. Each contender faced the same questions and kept its place or lost it.
the method
- plain RAGdocs too dense and intertwined for one pass
- knowledge graphsat par or below dense retrieval, too complex to maintain
- RAPTORsame story, summaries lose the details
- rerankers keptsmall but real gain, the M3 reranker stays in
- hybrid three way retrievaldense, sparse lexical, and ColBERT fused in one ranking pass
the embedder
- OpenAI text-embedding-3-largeweak on multilingual and this corpus, and no offline embedder, privacy says no
- Cohere multilingualsame story, cloud only
- Gemini embeddingsame story, cloud only
- Mistral embedsame story, cloud only
- Voyagesame story, cloud only
- Qwen3 embedding, 0.6B to 8Bevery size worse than BGE-M3 here
- BERT era variantsa generation behind on retrieval
- BGE-M3best cross lingual scores of everything benchmarked, and one pass emits all three representations
the store
- pgvectorno ColBERT support
- Chromadoes not cover all three search paths
- Weaviatehybrid BGE-M3 mode not fully supported at the time
- Qdranttested fine, ranked slower than Vespa
- Elasticsearchcould do it, slower and more complex for less gain
- Milvusmatched Vespa on gain, lost on ease of use
- Vespaarbitrary ranking math over all three representations, fast
the ingestion
- rolling windowssteady but blind to document structure
- sentence break chunkingclean cuts in the wrong places
- recursive splittinggood generic default, still cut in the wrong places
- semantic chunkinggood but slow, and still cut in the wrong places
- structure based chunkingassumes cleaner docs than thirty years produce
- custom chunkerbuilt around how the docs are written, significant boost, generalizes beyond them
the runtime
- in house node framework lineagecame first, shaped how nodes are controlled and routed
- LangGraphthe obvious choice once it matured, no reinventing warm water
The architecture
The entry proxy is the only exposed service, balancing into the web frontend with its admin panel and into the agent runtime. The embedding service and the search engine do retrieval, a cache holds the hot state, a durable store holds the rest. An mcp connector is available to the agent for integrations with the systems around it. The LLM provider is a slot, local models drop in. The same stack ran on AWS, on Azure, and on bare Hetzner. Watch the traffic:
The admin panel
Every knob and process in the system is operable by the support experts themselves, no dev in the loop. Ingestion, retrieval settings, learned improvements, evals, all behind a panel built for the people who answer questions, not for the people who wrote the code. I built the frontend and the panel by hand in React and TypeScript, working with UI/UX designers.
A mockup.
Hybrid retrieval you can argue with
BGE-M3 emits three representations per text, a dense vector, a sparse lexical one, and ColBERT token vectors. Vespa ranks over all three in one pass, in two phases, a cheap hybrid ordering over the full candidate set, then a richer reorder of the head. Move the weights:
reciprocal rank fusion, magnitudes drop out, only ranks countevery candidate the query matched, unranked
marks documents the eval says should win this query.
- 1Persistence concepts
- 2Add thread level persistence
- 3Graph state reference
- 4Use Postgres checkpointer
- 5Runs API reference
- 6Run configuration reference
- 7Quickstart
- 8Cross thread memory store
- 9Durable execution
- 10Compiling a graph
- 11Time travel and forking
- 12Streaming graph outputs
The eval preset is what golden set tuning found for this docs corpus.
Candidate scores exported from the companion repo.
The improvement loop
The vocabulary gap never closes on its own, so the system keeps learning. Some feedback is direct, some is read from usage, and what it changes depends on where it lands in the system. Step through one loop:
1 / 5 A user pushes back
> How do I make the agent remember progress between runs?
I could not find a document about remembering progress. Closest match covers run configuration.
"assistant couldnt help me, my agent starts from zero every time and I dont want that"
Direct feedback, a downvote and the user's own words. The loudest signal, and the rarest.
Scripted frames, a mockup.
The evidence
The judge for every decision above is a customised eval harness. The golden question set was built with the support experts, from real user questions, graded easy to hard, each with known answer documents. Every change faces it before it lands: swapping the LLM, adding a node, tuning retrieval weights.
- candidate D10% the pick
- candidate B6% the pick
- candidate E5% the pick
- candidate C3% the pick
- candidate A0% the pick
Every candidate model runs the same golden set. A scripted mockup.
- plain RAG baseline, answers correct
- 40 to 50%
- the shipped system, answers correct
- 85 to 90+%
Answer correct rate on the golden set.
What it does not do
- Answers are only as good as the documents. Where docs contradict, the answer hedges.
- Research loops trade latency for coverage. On heavily intertwined questions they trade it for accuracy too.
- It answers the docs, not the world. Out of scope questions get declined, not improvised.
- When retrieval comes back thin, it says so. A shrug beats a confident guess.
- Improvements are learned per corpus. A fresh deployment starts unlearned and earns them.
- It never edits the source documents. Learned phrasing lives beside the docs, not in them.
- Cross lingual only reaches as far as the embedder does. New languages get benchmarked, not assumed.
- Evals cover what the golden set covers. Blind spots move with it.
An entity deduplicator
- PyTorch
- entity resolution
- hybrid retrieval
- calibrated nnet
1.3 million guest profiles, one question: same person? Merging two strangers is a disaster, missing a duplicate is just noise, so every decision is three way. Merge, reject, or ask a human. Try beating the system at its own game:
- name
- Anna O'Neill
- born
- 14.02.1991
- anna.oneill91@mail.com
- phone
- not on file
- city
- Northbrook
- document
- not on file
- name
- Anna ONeill
- born
- 14.02.1991
- anna.oneill91@mail.com
- phone
- not on file
- city
- Northbrook
- document
- not on file
- +nameapostrophe variant, near exact
- ++birth dateexact match, hard anchor
- ++emailexact match
- +citysame
Seeded fictional guests.
The problem
Front desks type fast, booking channels import blind, migrations copy everything. Across multiple property systems the guest base grew to 1.3 million profiles, and 26.3% of them belong to a person who already has another one. Histories fragment, loyalty misfires, the same guest gets the same offer three times.
A wrong merge poisons two guests' histories with each other's stays. A missed duplicate just repeats an offer. That is why the decision is three way, merge, reject, or ask a human.
Labels from the failure itself
There was no labeling budget, and none was needed. A proxy signal already sitting in the data turned out to be near ground truth for this problem, so the labels came for free, at corpus scale. What survives unlinked in the data is exactly what humans never caught. The model trains on the residue of the problem it solves.
The system
Blocking is the search index itself, lexical and vector retrieval fused into one candidate list, no hand built blocking keys, near total pair completeness. For the decision itself, four resolvers competed on one shared benchmark, a weighted heuristic, a small feature based neural net, a cross encoder, an LLM judge.
- Anna ONeill14.02.1991anna.oneill91@mail.comNorthbrook0.97dupe
- Anna McNeill22.09.1987missingKingsmere0.11dupe
- A. O'Neill14.02.1991missingNorthbrook0.93dupe
- Alice O'Neill03.05.1991aoneill@mail.commissing0.15dupe
- Anna O'Neill30.06.1969missingmissing0.07dupe
- Anna O'Neill14.02.1992missingNorthbrook0.62dupe
every candidate is paired with the incoming profile, the nnet scores each paira new profile arrives
A service
The resolver ships as one container image consuming a stream of profile events. Partitioning keeps each guest's events in order, scaling is adding a replica, and a poison message lands in a dead letter queue instead of stopping the stream. Verdicts flow out the same way they came in, as events.
a poison message drops to the dead letter queue, the stream keeps movingprofile events stream in, one partition per guest key keeps their order
The evidence
Every resolver faced the same benchmark, held out clusters none of them had seen. The expensive favorites lost. The small feature based neural net reached 0.95 precision, trains in seconds on a CPU, and runs inference in milliseconds.
- pairwise F1, held out clusters
- 0.973
- cluster level B3 F1
- 0.988
The scores apply to a given corpus of guests.
What it does not do
- It does not auto merge through doubt. Conflicting hard anchors are never trusted, doubtful matches go to human review.
- It does not catch every duplicate. Some pairs carry too little evidence for anyone to link, model or human.
- No online learning.
- It makes mistakes. The verdicts are only as good as the data the source systems feed it.
Hospitality recommender at streaming scale
- Kafka
- ksqlDB
- two tower
- dedup nnet
A European hospitality group. 1.3 million guest profiles, 1.9 million stays, 10 million transaction lines, spread across multiple property systems. The main driving question, what should we offer this guest, this party, this season. All inside a five second contract.
- 1.wine cellar tasting0.91
- 2.spa morning0.84
- 3.late checkout0.62
the etl maps every source shape onto one canonical model, hopeless events quarantinetwo property systems emit events about the same world, each in its own shape
Fictional guests, fictional catalog.
The canonical model
Nothing gets recommended while the sources disagree about who the guest is and what a stay even looks like. So the first thing built was agreement. A canonical data model for the whole domain. Kafka connectors pulling every property system into it. ksqlDB and a cleaner service standardizing events, quarantining the hopeless ones. And the deduplication layer on the stream, the deduplicator that earned its own case study.
Three architectures auditioned
LLM as the recommender
lost- promised
- retrieval plus relations, a model reasoning its way to a recommendation
- lost
- prone to LLM failures
Transformer PoC
lost- promised
- recommending as sequence generation, guest history in, next item out
- lost
- needs far more training data than the towers to reach the same quality
Two towers
shipped- promised
- guests embedded on one side, products on the other, a dot product between them
- won
- cheap to serve, honest to evaluate, fast enough
Different ways to know a guest
No single description of a guest is enough. The model reads each guest through several views at once and learns to weigh them, leaning on whichever carries real signal for that particular guest. Build a guest below and watch the ranking follow:
- 1pool morning0.74
- 2bike rental0.71
- 3guided hike0.70
- 4late checkout0.58
- 5spa morning0.55
Guests and catalog fictional.
The architecture
The evidence
What it does not do
- No realtime features. The model sees the world as of the last snapshot.
- Cold start answers in tiers, the highest quality tier that applies answers first, and quality degrades tier by tier below it.
- Not a finished product yet. The system is still in development.
Document AI scanner
- object detection
- classification
- segmentation
- OCR
- VLM
- vision
Identity documents read at the reception desk on a webcam or a cheap flatbed, no dedicated scanning hardware. Two generations of the scanner do it.
- surnameO'NEILL
- nameANNA
- doc noN0482913
- birth14.02.1991
- expiry08.11.2031
- nationalityMML
- mrzchecksum ok
a structured answer, the mrz cross checked against a dedicated readerversion one starts, the document is detected in the frame
A fictional document from an invented country, scanned by both generations.
The problem
Dedicated document scanners are expensive, lag years behind new document formats, and the rarer IDs and travel documents are often not supported at all. Every fix waits on the vendor.
So the scanner replaces them with whatever camera is present. Passports, national IDs, driving licences, and health cards, read at the desk and submitted as structured fields to every system that needs them. And training such a pipeline is hard for one reason above all, identity documents are sensitive personal data under GDPR. You cannot collect a training set of real ones.
The era of multiple models
Version one is a pipeline. Detect the document, warp it flat, fix the orientation, classify the type, find each field, read it, fuse the results. A model per stage, designed and trained in PyTorch, exported to ONNX, all running locally inside the desktop app.
a P2 lane added end to end, the tiny fields on a card need itthe baseline detector, a stock backbone neck head stack
An illustrative replay of the tuning loop.
The training data was fabricated, all of it. Fake identities rendered onto document templates with passport fonts, ink bleed, finger and stamp occlusions, and zero real personal data.
print wear, the ink bleeds like a real desk drawer documenta document template, no identity on it yet
How a training set exists with zero real people in it.
Building an OCR
The OCR was prototyped in house, a custom transformer OCR designed from scratch and trained on the synthetic lines, then benchmarked against a specialized classic engine. On par on accuracy, at a smaller memory footprint. A conv encoder reads the crop, a transformer decoder attends its way across it, character by character.
the decoder attends to a region of the line per charactera text line crop comes in
The era of texting with your scanner
Version one earned its place, around 90 percent balanced accuracy on exact field match, and in production it reads nearly flawlessly. At around a second per document. But its pain lived at the edges. A document type it had never seen, a border pass, came through degraded, and every improvement meant retraining a whole family of models.
Version two replaces the entire extraction stage with one fine tuned vision language model. The desk app literally texts it, a document photo goes in, one strict JSON object comes back. It is even trained to refuse bad shots, glare or a covered corner gets a warning to adjust the document instead of a bad read. In practice it generalises far better on unseen niche formats, exactly where version one degraded.
Models from 0.6B to 8B were tried across vendors, the sweet spot settling around 2B and 4B, similar in speed and accuracy, served on a single consumer GPU at 96.6 percent field accuracy, under three seconds a document. Version two is in final fine tuning and early testing.
one strict JSON object back, under three seconds on a consumer GPUthe desk app texts the model a scan
A fictional conversation.
Shipping it
The same system ships two ways. A desktop app with local inference where the hardware allows it, remote inference where it does not. Either way there is one point of control, a central server pushing delta updates, so every desk runs the version it should.
The evidence
- balanced accuracy, v1
- ≈90%
- time per document, v1
- ≈1 s
- balanced accuracy, v2
- 96.6%
- time per document, v2
- < 3 s
Measured on a held out document test set, 29 countries.
What it does not do
- A language model can hallucinate no matter how accurate it is. Critical data such as the machine readable zone is cross checked against a dedicated deterministic reader.
- Non Latin scripts are out of scope, the schema enforces Latin output.
- The vision model needs its GPU box. The CPU fallback measured roughly ten times slower and was rejected.
Sentiment analysis platform
- LangGraph
- scrapers
- agent loop
- automation
Type a business name, pick the platforms, and that is the whole setup. From there it is a nightly job, scrapers collect the public reviews, and an agent loop distills large quantities of them, across languages, into one report with severity ranked issues and concrete suggested fixes.
Soba je mirisala na vlagu, klima nije radila, a na recepciji nitko nije pomogao.
Das Frühstück war fantastisch, aber das WLAN fiel ständig aus.
Posizione perfetta vicino al mare e la colazione è davvero ricca.
Great pool and friendly staff, but check in took almost an hour.
- roomnegative
- air conditioningnegativehigh
- receptionnegative
Seeded fictional reviews in four languages, click a language to replay one.
The problem
Review dashboards give a business a star average and a list of frequent words, a score with no instructions. The reviews themselves are scattered across platforms, written in several languages, and polluted by namesake businesses on other continents. And an LLM asked to summarize them will happily paraphrase, invent, and silently skip. The problem is getting from that mess to a to do list you can trust.
The robust solution
One failure mode is precision and scope. Scrapers are unreliable, so the platform is built to know its scrapers and control them. Several scrapers per platform, so one failing means a fallback.
Search a hotel name and you get the right one plus its doubles on three continents. A layered identity filter kills the impostors, a review only enters the analysis when every layer agrees it belongs to the right business.
The other failure mode is the LLM itself, and it is tamed with orchestration rather than hope. The current generation runs as a LangGraph flow with structured output at every step, which turns a moody generalist into a robust, well performing worker.
An LLM will still sometimes skip reviews in a batch, short and foreign language ones especially. The loop notices and recovers, so every review gets an answer and none get invented.
drop what does not belongresolve the business
One nightly job.
Provenance
Every claim the system makes must quote the review verbatim, an exact substring, checked. Every number in the report reconciles back to an extracted aspect, every aspect back to a quote, and every quote back to the original review on the platform it came from.
- platform A#4f2c
Das Frühstück war fantastisch, aber das WLAN fiel ständig aus.
- platform B#a913
Wifi kept dropping every evening, impossible to work.
- platform C#07de
Internet je bio prespor i za osnovne stvari.
Fictional reviews. Click an issue to open its receipts.
The evidence
The report is category cards, source and month breakdowns, rising and declining keywords, critical issues with severity, and deduplicated action items each with a suggested fix.
- reviews analyzed
- ≈700
- total cost
- ≈$1.40
What it does not do
- The LLM can make mistakes. The quotes are verbatim, but the judgment attached to them is still a model's.
- Subtle sentiment can be missed. Sarcasm, irony, and mixed language reviews are harder than plain praise or complaint.
- Coverage ends at the scrapers. What is not public, or not reachable, is not in the report.
Automated migration pipeline
- LangGraph
- Neo4j
- agent
- human in the loop
A legacy ERP, 725 modules in a proprietary format. The source ships as binary, unreadable by a model, and converting it yields 400 MB of markup, far more than any context window holds. 80% of that is generator boilerplate. A coding agent out of the box is useless here. The project is everything built around the agent so it stops being useless.
A fictional module graph.
The problem
725 modules of binary source, converted to 400 MB of machine generated markup that is mostly noise. And nothing stands alone. A module reads tables another one writes, calls shared libraries, inherits from common ancestors. The real problem is separating behavior from boilerplate and cutting one module free of the tangle, not translating syntax.
The graph
Every module goes through a full grammar for the legacy language into a Neo4j knowledge graph. Clustering the graph finds whatever repeats, screen shapes, trigger layouts, data access patterns, and the corpus collapses into a handful of families. Migrate one member well and the same recipe carries across its family, biggest families first.
The agent
The agent never reads source files. It queries the graph through a small set of narrow tools. The graph hands it the full closure of what one module needs, its screens, its triggers, the tables it touches, the libraries it calls, so a single module migrates standalone, keeping context lean by design.
the closure, screens to libraries, fits with room to sparethe corpus does not fit any context window
Sizes illustrative.
The loop
The agent migrates a module, the result must build and pass its tests, then a human reviews and tests it for real. Findings feed the next cycle, and the cycles repeat until the migration is done.
What it does not do
- It has not been run on the whole application, only on individual modules so far.
- Complex modules still require significant human intervention.
- The LLM can make mistakes no matter how good the harness is. Nothing ships without a build, green tests, and a human review.
Memory of How
- PyTorch
- transformers
- meta learning
- continual learning
Imagine your assistant learning a behavior from a few demonstrations and keeping it, no finetuning, no full training run. The model stays frozen. And what it learned survives a full context wipe.
two frozen models, the right one carries a tiny memorytwo frozen models, the right one carries a tiny memory
A fictional dialogue.
Abstract
Language models exhibit effective in-context adaptation, but this adaptation does not persist beyond the context window. Existing approaches address this by editing model weights. We instead ask whether a frozen model can retain knowledge acquired in context and reuse it across sessions. We attach a compact learned memory module to frozen open-weight models spanning 0.6B to 8B parameters, and provide a small number of in-context demonstrations of behaviors such as coding practices, formatting styles, and naming conventions. After the context and KV cache are fully cleared, the behaviors persist. In preliminary runs, demonstrated habits survive at or near ceiling while arbitrary associations such as specific names or values collapse to zero. Transplanting one session's memory state into a new session carries the rule to content neither session contained. In its current form, the memory generalizes within the family of demonstrated behaviors and does not yet carry novel rules outside it. Results are preliminary, and the mechanism, full quantitative results, and ablations will appear in the preprint once the remaining experiments are complete.
About
Software developer, tech lead and architect. I mainly specialise in building dense neural networks and the systems that use them end to end.
I care about honest evals, clean architectures, and solutions that actually make a change.

Contact
Write me directly.