Building a RAG-Based Immigration Assistant for Finland
AI Engineering · RAG · Finland
Finnish immigration information is scattered across several government portals in dense bureaucratic language. FinnPermit is a RAG-powered assistant that combines official sources with citations and provides answers in the simplest form.
The problem
Finnish immigration is not complex because the rules are complicated - it’s complex because the information is spread across multiple sources: migri.fi, kela.fi, dvv.fi, vero.fi, tyosuojelu.fi and more.
The siloed pieces of information have left many with challenges, including high consulting fees for publicly available information. Like them, I did face the same struggle while trying to educate myself on the correct immigration application requirements. Although the official website of the Finnish Immigration, Migri.fi, has always had a basic chatbot called Kamu, but has since been taken down.
From a solution standpoint, I tried using general LLMs but didn’t find the responses credible enough, so I decided to scrape and query the official websites directly. That became FinnPermit - finnpermit.com.
The Journey
I had been learning the following by self: data analysis, machine learning, and AI engineering through YouTube videos and online course, building mini projects that taught me web scraping, vectorising data, querying chunks, LangChain, and working with LLMs. With the problem statement in hand and deep research into the build, I chose the tech stack.
The project was developed using Claude Code, Anthropic’s agentic coding tool in VS Code. The key advantage over standard LLM chat is project-wide context, including debugging. It understands how the scraper output connects to the chunk format, Pinecone schema, and API response model, and not just the function in focus.
I trained and improved the system by feeding it increasingly complex prompts and fixing the errors that surfaced. Three places where it made a real difference:
Language-mismatch bug: the bot was responding in Finnish to English questions. Claude Code traced it through the full chain to the exact line.
MMR implementation: described what MMR does conceptually; got a vectorised NumPy implementation to review and validate.
Table extraction fix: described the problem and example output; got the conversion function that preserves income threshold tables.
From testing to live
After the initial build, I shared the app with potential users and iterated on real feedback. The early bugs were instructive:
Timeout errors: long queries were timing out on the free Cloud Run tier. Fixed by increasing the timeout window and optimising retrieval latency.
Salary requirement errors: the model was quoting income thresholds from training data rather than retrieved chunks. Fixed with a strict system prompt rule: never state a specific EUR figure unless it appears verbatim in a retrieved chunk.
Scope creep: early versions answered too broadly. Fixed by tightening synthesis to directly retrieve chunks only.
Once responses were consistently accurate, I made it live by purchasing a domain on Cloudflare and deploying the frontend to Cloudflare Pages. The backend runs on Google Cloud Run in europe-north1, keeping latency low for Finnish users.
I then shared the tool with the official team at the Finnish immigration, also known as Migri. They tested it and confirmed that it provides factual, useful information, noting that it correctly cited official links in many cases. Although they mentioned that they currently cannot participate in it, and are not associated with it.
“Thank you for your message and transparency. It is great that you want to help other people with this kind of tool. I also tried your AI assistant, and in many cases it provided helpful high-level guidance.”
- Santtu Heikkonen, Senior Specialist, External Communications, Finnish Immigration Service (Migri)
There is still a lot of scope for improvement before this becomes a truly robust product, such as data freshness, Finnish-language coverage, and a proper evaluation framework are all on the list. But the core system works, and it’s helping real people navigate a genuinely difficult process.
The query pipeline
Every user question passes through six stages before a response is returned:
Pre-filter: Typo correction, abbreviation expansion (”rp” → “residence permit”), off-topic detection before any retrieval runs.
Query rewrite: GPT-4o-mini rewrites the input — in any language — into a clean English search query with session context injected.
Multi-query decomposition: Complex questions split into 1–3 focused sub-queries, each targeting one distinct aspect.
MMR retrieval + topic boost: Each sub-query runs MMR search against Pinecone. Topic boost adds targeted chunks for income thresholds, language requirements, processing times.
LLM synthesis: GPT-4o-mini with a 20-rule system prompt: no hallucination, respond in user’s language, cite sources, calculate from user’s numbers. Output is structured JSON.
FastAPI (Cloudflare Pages): Stateless backend on Google Cloud Run (europe-north1, scales to zero). Frontend on Cloudflare Pages at finnpermit.com.
Tables destroy naive scrapers
Income threshold tables on migri.fi are HTML <table> elements. Plain get_text() garbles them — numbers and headers become noise. The fix: convert tables to pipe-delimited text before chunking. Without this, family reunification income questions return hallucinated figures.
scraper/table_parser.py
def table_to_pipe_text(table_tag) -> str:
rows = []
for tr in table_tag.find_all(”tr”):
cells = [td.get_text(strip=True)
for td in tr.find_all([”td”, “th”])]
if cells:
rows.append(” | “.join(cells))
return “\n”.join(rows)
# Before: “Permit typeApplicantIncome required1399”
# After: “Permit type | Applicant | Income required”
# “Family reunification | Spouse | 1,399 €/month”Single similarity search fails complex questions
A question spanning three permit types and a job loss event touches three distinct topics. One vector search misses most of them. Multi-query decomposition splits the question into sub-queries that each retrieve independently, then merge. The Pinecone SDK doesn’t expose MMR natively — it’s implemented from scratch in NumPy.
rag/retriever.py
# Multi-query MMR retrieval
def retrieve_with_mmr(sub_queries: list[str], index, embedder, top_k=5):
all_chunks = []
for query in sub_queries:
query_vec = embedder.embed_query(query)
candidates = index.query(
vector=query_vec, top_k=20, include_metadata=True
).matches
selected = mmr_select(
query_vec=query_vec, candidates=candidates,
k=top_k, lambda_mult=0.6 # 0 = diversity, 1 = relevance
)
all_chunks.extend(selected)
seen = set()
return [c for c in all_chunks
if not (c.id in seen or seen.add(c.id))]Standard top-k returns the most similar chunks, often near-duplicates. MMR penalises redundancy and forces retrieval to spread across different facets. For multi-part immigration questions, this is the difference between a partial and a complete answer.
api/main.py
@app.post("/query")
async def query(req: QueryRequest):
sub_queries = await decompose_query(req.question, req.session_id)
chunks = retrieve_with_mmr(sub_queries, index, embedder)
answer = await synthesise(
question=req.question, chunks=chunks, language=req.language
)
return {
"answer": answer["text"],
"sources": answer["citations"],
"response_type": answer["type"], # frontend uses this for styling
"session_id": req.session_id
}The system prompt is half the product
Key rules that weren’t obvious until a test case failed:
Never quote a specific EUR threshold unless it appears verbatim in a retrieved chunk
When a user mentions job loss, surface the grace period immediately (3 months employer-tied, 6 months A permit)
Respond in the user’s language regardless of what language the knowledge base is in
Every response carries a response_type field — the frontend uses this to style the answer differently
What’s next
Automated weekly re-scrape with diff-based patching — only re-embed changed pages
Finnish-language source coverage (current knowledge base is English-only)
“Last verified” date shown on each answer
User feedback loop — flagged wrong answers feed into system prompt improvements
Proper evaluation framework to catch retrieval regressions before they reach users
Try it at finnpermit.com. All code in this post is from the production codebase.
· · ·
Rohit
AI developer based in Helsinki. Currently open to AI roles in the Nordics.






