Working with Talentix, I built a system meant to answer a more specific question than a typical job board. Not "here are jobs similar to yours." Instead: "here's what people in your position actually tend to move into next, and roughly what that transition looks like." That distinction shaped the whole architecture.
A Graph of What Actually Happens
The core is a Neo4j graph modeling career trajectories as transitions between roles. Each edge carries three things: how often that transition occurs, average tenure before the move, and typical salary change. That turns "what's a plausible next step" into a graph traversal grounded in observed behavior. Not a guess based on how similar two job titles sound.
Semantic similarity alone doesn't tell you what's realistic. Two titles can look nearly identical and represent a transition nobody makes. Or look unrelated and be a well-worn path. The graph captures the behavioral pattern embeddings alone can't see.
Why Matching Still Needs Two Layers
The graph answers "is this transition realistic." It doesn't do the matching itself. You still need to compare a person's skills and experience against candidate roles. A full detailed comparison against every job isn't practical at real scale. So matching happens in two layers.
Layer 1 is a fast, broad screen. Sentence Transformers embeddings do the narrowing, mainly all-MiniLM-L6-v2 for speed. They take every job title and narrow it down to a plausible candidate set, based on title-level semantic similarity. Layer 2 reranks that set with a more detailed comparison: full skills and description similarity. It uses all-mpnet-base-v2 here, since quality matters more than speed at this stage. The expensive comparison only runs against Layer 1's shortlist. Never against everything.
candidates = layer1_screen(profile.title_embedding, all_job_titles, top_k=200)
ranked = layer2_rerank(profile.skills_embedding, candidates, top_k=20)
Screen-then-rerank isn't unique to career matching. It shows up anywhere a full pairwise comparison is too expensive against the whole dataset. Cheap broad filter first. Expensive precise comparison only on what survives. Reach for that shape whenever you're tempted to throw a bigger model at the whole problem.
The Data Pipeline Mattered as Much as the Model
None of this works if profile data is unreliable. LinkedIn and resume data is messy by default. An Apache NiFi pipeline handles extraction and validation before anything reaches Postgres. Custom schema validators cover certifications, education, employment history, and licenses. A graph built on "what transitions actually happened" is only as good as the profile data feeding it. A meaningful share of the engineering went into validation, not modeling.
The Rest of the Stack
FastAPI with async Postgres handles core profile and job data. MongoDB stores user preferences separately. That data has a different shape and update pattern than structured profile records. Pinecone holds the vector embeddings for both matching layers. Neo4j sits alongside as the transition graph.
As with the other systems I've written about, I'm not attaching a performance number. This project doesn't have one published. The point is design reasoning, not a scoreboard.
The General Lesson
Semantic similarity tells you what looks alike. A graph from real transition data tells you what's actually plausible. Career-path recommendation needs both. It's tempting to build only the easier piece: the embedding search. Skipping the graph is tempting too, because it needs messier, harder-to-source data. The graph is what makes recommendations trustworthy rather than just plausible-sounding.
Learn More
The full project is open source: AI Career Pathing System on GitHub.