logo[мetahunt]
> how it actually works

raw job feeds in.
one clean, ranked market out.

metahunt turns the scattered, duplicated, unstructured Ukrainian tech job market into a single clean dataset — then ranks it against your CV. No black box: here is the whole system, with the real numbers.

built with

a small, honest LLM + vector pipeline — ~$9/mo to run.

01

collect

2
RSS feeds
~16/day
pulls, 06–22 Kyiv
SKIP
overlap policy
$3.25/mo
ETL worker

A Temporal schedule (rss-ingest-hourly) wakes up every hour between 06:00–22:00 Kyiv and fans out one child workflow per source. Each runs a durable chain — fetch → parse → extract → finalize — with automatic retries, so a flaky feed never drops a batch.

  • RSS pull from Djinni & DOU (2 feeds today; a new source is one config row).
  • New items dedup on a content hash before they ever hit the DB (→ bronze).
  • Overlapping runs skipped, no double-ingest.
Why Temporal, not a cron? Ingestion is a multi-step, retryable job over an unreliable network. Temporal makes every step durable and observable — a crash resumes mid-pipeline instead of silently losing a run.
temporal schedule
◷ rss-ingest-hourly · every 1h · SKIP overlap
djinni
rss workflow
dou
rss workflow
fetchfeed → xml
parsexml → items
extracttext → fields
finalizeupsert vacancy
02

parse

15+
fields extracted
$0.14
/1M input tok
$0.28
/1M output tok
off
reasoning mode

A job post is unstructured prose. We hand the full text to DeepSeek (deepseek-v4-flash, reasoning disabled for speed & cost) through BAML — a typed schema language for LLM calls that validates and retries until the model returns exactly the shape we asked for.

  • Stack, split into must-have vs nice-to-have.
  • Seniority · role · work format · locations · English level.
  • Salary range · employment type · company type (product/outsource/…).
  • Flags: has test task, is-tech, reservation.
Why BAML?It turns “prompt an LLM and hope the JSON parses” into a typed function call with schema validation and automatic retries — the structure is guaranteed, not best-effort.per-token pricing verified in code; per-vacancy cost is a fraction of a cent (estimate).
raw text → structured (via BAML)
Шукаємо Senior Golang інженера. Досвід від 5 років. Go, PostgreSQL, Kafka, буде плюсом gRPC. Remote, вилка $6000–8000. Тестове завдання.
↓ deepseek · ExtractVacancy()
{
"seniority": "senior",
"role": "backend",
"required": ["Go","PostgreSQL","Kafka"],
"optional": ["gRPC"],
"experienceYears": 5,
"salary": { "min": 6000, "max": 8000 },
"workFormat": "remote",
"hasTestAssignment": true
}
03

dedup

1536-d
embedding
0.92
merge cosine
±45d
match window
5 min
sweep cadence
89%
recall

The same job is posted on multiple boards, worded differently each time. Collapsing those into one golden record is the hard part — and it runs on two layers.

hard filters — exact, free

  • Same source + same id, parsed from the URL, upserts in place.
  • RSS content-hash blocks re-ingesting an unchanged item.

soft filters — semantic, vectors

  • Each vacancy → a 1536-d embedding (text-embedding-3-small).
  • pgvector finds the top-20 nearest by cosine, within a ±45-day window.
  • Structural gates: same role & seniority, different companies excluded.
  • Join only if pairwise ≥ 0.92 and group-centroid ≥ 0.92; gold tier at ≥ 0.95.
  • Canonical = earliest-published member; every source link is kept.
A Temporal dedup-sweep runs every 5 minutes, re-embedding new rows and resolving groups incrementally (→ gold).
dedup funnel · 2026-07-07 snapshot
all vacancies (100% embedded)
10,839
top-20 nearest · ±45d · role/seniority gates
candidate pairs scored (cosine)
pairwise
≥ 0.92
pairwise + centroid threshold
unique job groups
9,228
of which merged from 2+ postings
multi-member groups merged
1,107
Recall on in-window duplicate pairs: 89% (194/219).recall measured on a labeled set, 2026-07-05.
04

match — the IDF model

10,839
N (job corpus)
0.8
STRONG cutoff
0.5
GOOD cutoff
+5
df smoothing
16.75
example relevance

Ranking your CV against the market isn't keyword counting. A rare skill should count for more than one everybody lists. So each skill is weighted by inverse document frequency — how uncommon it is across all vacancies — and your match score is the sum of those weights over the skills you share.

IDF(s) = √( max( ln( N / (df(s) + 5) ), 0 )
relevance = Σ IDF(s) for s in (CV job )
coverage = ΣIDF(required ∩ CV) / ΣIDF(required)
tier = STRONG if ≥ 0.8 · GOOD if ≥ 0.5 · else STRETCH
N = total vacancies (10,839) · df(s) = vacancies tagging skill s
+5 = smoothing · max(…,0) clamps ultra-common skills to 0
final sort: (on-stack, tier, relevance) descending
example — Go backend CV vs "Middle Golang Developer":
relevance = 16.75 · required coverage → tier STRONG
skill weights = rarity (higher IDF = rarer)
Kubernetesrare · high
Kafkahigh
Gomedium
PostgreSQLmedium
JavaScriptcommon · low
HTML→ clamped 0
Seniority, years, freshness and English are filters, not score inputs — they narrow the set; IDF relevance orders what's left.
#

the numbers

Real figures from the running system. Snapshots dated; estimates flagged.

~$8.7/mo
total infra cost
Postgres $5.42 + ETL $3.25 · Railway, 2026-07-04
10,839
vacancies processed
100% embedded · 2026-07-07
9,228
unique job groups
after dedup collapse
1,107
multi-member merges
groups with 2+ postings · 400 span multiple boards
89%
dedup recall
194/219 in-window pairs
0.92 / 0.95
merge / gold cosine
pairwise + centroid
$0.14/$0.28
LLM $/1M tokens
deepseek-v4-flash in/out
1536-d
embedding vectors
text-embedding-3-small