🔎

Country of Origin - Search Agent

A proof of concept for agentic search: instead of one embedding lookup and a hopeful summary, the agent plans its own queries, reads specific PDF pages, checks its own claims against what it actually read — and abstains when the corpus can't support an answer.

TypeScriptNext.jsVercel AI SDKSQLite FTS5sqlite-vecOpenRouter
View on GitHub
The agent answering a Swedish question about Syrian passport cost and validity — its own search queries listed above, the answer citing sources [S1][S2][S3]
The agent writes its own search queries, then answers in Swedish with a citation on every claim. Click to enlarge.
⚠️

A proof of concept, not a product. Not affiliated with, endorsed by, or built for any government authority or agency, and nothing it produces is legal advice or a case decision. The sample corpus is public material only — COI reports published openly by the EUAA, the UN, Landinfo (NO), the Danish Immigration Service, the UK Home Office, the Netherlands MFA and similar bodies. No internal, restricted or case-related material was used anywhere in the project.

~1,165 Pages in Corpus
22 Source PDFs
3 Abstention Gates
~40 Golden Eval Questions

Why This Exists

The real subject here isn't migration casework — it's how well a model designs. Anthropic's Fable 5 was handed a short problem statement and nothing else, and produced the full architecture and a phased implementation plan (DESIGN.md and PLAN.md). Opus 4.8 then implemented them. Both documents were written first and have never been retrofitted to the code, so they read as what they are: a model's unaided design work, and the result of building it.

The domain was picked because it's unforgiving. Country-of-origin information (COI) is what migration caseworkers consult to assess conditions in an applicant's home country: dense, multilingual reports, thick with footnotes, where a fact's truth value flips depending on the publication date — everything about Syria before and after Assad's fall in December 2024 reads differently. A retrieval system that bluffs in that setting is worse than no system at all.

This is the entire prompt the design step was given:

Design and implement a plan for an AI-powered search and Q&A agent for so-called "Country of Origin Information" (COI) documents (PDFs), for use in migration case work. Requirements: Real-world sample files are provided in the @pdf-syria/ folder. For the AI component, any available LLM from openrouter.com may be used. The model(s) and API key should be configurable via a .env file. The goal is high-quality retrieval. Answers must be correct, or the agent must inform the user that it cannot provide a reliable answer. The language of the source files is mixed (mostly English, some Swedish). The agent should primarily accept input in Swedish and respond in Swedish.

Everything that isn't in that paragraph — hybrid BM25 + dense retrieval with RRF fusion, the LLM reranker, section-aware chunking, the groundedness judge and the abstain path, the verification badge, the golden eval set — came out of the design step, not the prompt.

What It Looks Like

Questions go in in Swedish, answers always come back in Swedish with per-sentence citations. Each [S#] marker is clickable and opens the source PDF at the exact cited page, so a caseworker verifies rather than trusts.

COI Search chat interface with example questions in Swedish

Click any screenshot to view it full size.

Architecture

TypeScript end to end. A CLI ingests PDFs into a single SQLite file that holds both the keyword index (FTS5) and the vectors (sqlite-vec); a Next.js app serves the agent, the streaming chat UI, and the PDFs themselves. Nothing leaves the machine except the LLM calls.

Architecture diagram: ingestion CLI to SQLite to Q&A agent to chat UI and CLI

Ingestion pipeline

Run as pnpm ingest <folder>, idempotent per file hash so re-running only picks up new or changed documents.

1

Extract

Per-page text items with font size and position via pdfjs. Reading order, paragraphs and headings are reconstructed from font-size outliers and numbering patterns like 2.1.1.

2

Clean

Strips the furniture: repeated headers and footers (detected by cross-page repetition), page numbers, tables of contents, and boilerplate disclaimer pages.

3

Separate footnotes

Footnote-zone text is stored per page and linked to it rather than embedded mid-paragraph — but stays searchable, because footnotes carry the source names and dates caseworkers ask about.

4

Chunk

600–900 tokens with 15% overlap, never crossing a section boundary. Each chunk records its section_path, page range and detected language.

5

Enrich (contextual retrieval)

A cheap model writes a one-line context prefix per chunk — organisation, report, date, section, topic. Prepended for indexing, never shown in answers. This is what fixes the "orphan chunk" problem in section-heavy reports.

6

Document metadata

An LLM pass over the first few pages pulls out title, publishing organisation, publication date, reference period, language and report number — injected into every citation.

7

Embed

Multilingual embeddings, model and dimensions from .env. The store records which model produced each vector, so changing models forces a re-embed instead of silently degrading retrieval.

🧾

Ingestion is a quality gate, not a loading screen. Every document gets a report — pages parsed, chunks produced, language mix, extraction warnings. A document that fails extraction is flagged, never silently skipped.

Retrieval — the Quality Core

The hard part is cross-lingual: Swedish questions against a mostly-English corpus. Three mechanisms stack on top of each other.

1 · Query understanding

One cheap LLM call turns the Swedish question into a normalized query, an English translation with domain-term expansion ("värnplikt" → "military service, conscription"), optional sub-queries, and any stated filters.

2 · Hybrid search + RRF

BM25 (FTS5 with a trigram tokenizer, so Scandinavian compounds match) and dense cosine search run for both language variants, then merge with Reciprocal Rank Fusion. Embeddings catch sv→en meaning; BM25 catches exact terminology, names and report numbers.

3 · LLM rerank

The top ~40 fused candidates get listwise-reranked against the original Swedish question; 8–12 survive. The reranker's scores double as the input to the abstention gate.

The agentic loop on top

Retrieval isn't a single stuffed prompt — it's exposed to the answering model as tools, so it can reformulate a query, drill into one document, or check a newer report for contradictions before committing to an answer.

🔍
search
Full hybrid + rerank pipeline
📄
read_pages
Pull whole pages when a chunk is truncated
🗂️
list_documents
Corpus overview with orgs and dates

This is what lifts genuinely multi-hop questions — "How do the EUAA's and Landinfo's assessments of the security situation in Latakia differ?" — above what single-shot RAG can do. Every retrieved excerpt carries its document's date, and the system prompt is explicit about it: prefer the newest source on conflict, always state the information date, and flag when the freshest available material is older than the question implies.

Correctness and Abstention

Three independent gates, any one of which can send the request down the abstain path.

1

Retrieval gate — before generation

If the reranker's best scores fall below a calibrated threshold, generation is skipped entirely. Cheap, and the model never even sees the temptation to improvise.

2

Self-assessment — during generation

"I cannot answer this reliably" is a first-class output format, not a failure mode. Partial answers are allowed, but must delimit what is and isn't supported.

3

Groundedness judge — after generation

A second model receives each claim alongside the excerpt it cites and verifies entailment. Unsupported claims trigger one regeneration attempt with the judge's feedback; if they survive that, the claim is dropped or the whole answer degrades to abstention.

The UI shows the verdict rather than hiding it. An answer that is largely supported but contains one claim the judge couldn't tie to a cited extract renders as nedgraderad — downgraded — with the unsupported sentence called out instead of quietly deleted.

Answer downgraded to nedgraderad, with the unsupported claim listed explicitly

When the corpus can't support a reliable answer at all, the agent abstains — and the abstention is informative rather than a dead end. It distinguishes three failure modes (not covered by the corpus, sources conflict too much, information likely outdated) and offers the closest related material instead of guessing.

Abstain rendering showing the closest related sources instead of a guessed answer

Knowing What's Actually in There

A corpus page lists every ingested document with its metadata and extraction health — because "the agent didn't find it" and "the document never parsed" are very different problems, and a user deserves to be able to tell them apart.

Corpus page listing ingested documents with metadata and extraction health

Evaluation Harness

Built early rather than last. A golden set of ~40 Swedish questions written against the sample corpus, in five classes — and every retrieval or prompt change gets validated against it. This harness is the definition of "high quality" for the project.

Question classWhat it tests
Single-document lookupThe answer sits on a known page of a known report
Cross-document synthesise.g. military service across EUAA + Landinfo + DIS reports
Swedish-source questionsRetrieval into the Swedish-language reports in the corpus
UnanswerablePlausible COI questions the corpus doesn't cover — must abstain
TemporalPre- and post-December-2024 distinctions
🎯
≥ 0.90
Retrieval recall @ k=12
✅
≥ 0.95
Answer faithfulness (LLM judge)
🚫
100%
Abstention on the unanswerable class
🇸🇪
Always
Answers in Swedish

Running It

Models are configured per role in .env and are independently swappable — a strong model for answering and judging, a fast one for query understanding, reranking and enrichment.

pnpm ingest pdf-syria/ # extract → clean → chunk → enrich → embed pnpm ask "Hur ser läget ut för kurder i nordöstra Syrien gällande militärtjänst?" pnpm dev # chat UI on http://localhost:3000 pnpm eval # retrieval recall + answer/abstention suites

The CLI prints the Swedish answer, the verification status (verifierad ✓ / nedgraderad / avstår), the cited sources and the full tool trace — the same information the web UI renders, minus the PDF panel.

Technology Stack

TypeScript Next.js (App Router) Vercel AI SDK OpenRouter better-sqlite3 SQLite FTS5 sqlite-vec pdfjs-dist / unpdf Tailwind CSS pnpm

The whole knowledge base is one SQLite file — index, vectors, metadata and eval set together. No vector database, no external infrastructure, nothing to provision. For an evaluation or pilot setting, being able to copy the corpus as a single file is worth more than any ANN index would buy at this scale.