Last post was about learning what RAG is, on someone else’s document. This one is about shipping it on my own — a chatbot embedded on this exact site, that can answer questions about my projects, my blog, and me, and that updates itself every time I push new content.
The assignment from class was simple to state: put a working RAG chatbot on a real site, and make sure it doesn’t go stale. Simple to state. Less simple to build.
The Shape of It#
Hugo content is already the single source of truth for this site — every project, every post, every page is a markdown file. The idea was to keep it that way: don’t hand-maintain a second copy of “what the bot knows” anywhere. Instead, treat content/ as the source, and have a script turn each relevant file into one clean document in a Dify knowledge base, resolved and synced automatically.
flowchart TD
Hugo[content/*.md] -->|git push| GH[GitHub: main]
subgraph Actions["GitHub Actions"]
Build[build-and-deploy]
Sync[sync-dify]
end
GH --> Build
Build -->|on success| Sync
Build -->|push image| Hub[(Docker Hub)]
Build -->|webhook| Caddy
subgraph Droplet["DigitalOcean droplet"]
Caddy[Caddy]
WT[Watchtower]
Site[Portfolio container]
end
Caddy --> WT
WT -->|pull latest| Site
Hub -.->|pulled by| WT
Sync -->|create / update / delete docs| Dify[(Dify knowledge base)]
Visitor([Visitor]) -->|asks a question| Site
Site -->|chat widget| Dify
Dify -->|retrieval + generation| LLM[OpenAI]
Dify --> Answer[Answer + source link]
Answer --> Visitor
style Hugo fill:#eeeeee,stroke:#333,color:#000
style Dify fill:#b8d4ff,stroke:#333,color:#000
style Sync fill:#f4b8ff,stroke:#333,color:#000
style Build fill:#b8ffb8,stroke:#333,color:#000
style Answer fill:#b8ffb8,stroke:#333,color:#000
style Actions fill:transparent,stroke:#666,stroke-dasharray: 5 5
style Droplet fill:transparent,stroke:#666,stroke-dasharray: 5 5
Same push, two independent outcomes: the site itself gets rebuilt and redeployed to the droplet exactly as before, and — running as its own job right after — a script rebuilds the chatbot’s knowledge base to match whatever just got pushed. Neither depends on hand-running anything.
Decision One: One Document Per Page, Not One Giant File#
The tempting shortcut is to smash every page into one big text file and hand the whole thing to the bot. I did the opposite on purpose: one content file becomes one Dify document. A blog post is a document. A project page is a document. The about page is a document.
The reason is mostly practical. When a document is wrong, you can find and fix that document, not grep through a 20,000-word blob. When a page is deleted, its document gets deleted with it. And Dify already returns which document an answer came from — that mapping only means anything if a document corresponds to something real on the site, not an arbitrary slice of a mega-file.
The second decision that followed from the first: don’t keep a separate manifest file mapping “this content file” to “that Dify document ID.” Ask Dify what documents already exist, match them by a stable name derived from the page’s own URL, and diff from there. One less file to keep in sync, one less way for the mapping to silently drift from reality.
Decision Two: Self-Host Dify, or Stay on the Cloud?#
Before writing a line of the sync script, there was a real infrastructure question sitting in the way: the site already runs on a small DigitalOcean droplet — 2GB of RAM, already hosting the portfolio site, another full project, and a handful of supporting containers. Could Dify run there too, self-hosted, instead of paying for the cloud version?
I checked before assuming. Dify’s own documentation lists a minimum of 4GB RAM for a self-hosted install — and that’s for Dify alone. Under the hood it’s not one service, it’s roughly sixteen containers: the API, a background worker, the web frontend, Postgres, Redis, a vector store, an nginx proxy, a sandboxed code runner, and more. Squeezing that onto a 2GB droplet that’s already carrying production traffic wasn’t a risk worth taking for a chatbot.
So: stayed on Dify Cloud, kept the droplet doing what it already does well. Not the most exciting decision in this post, but it’s the one that stopped me from taking down a production site to save a subscription fee.
What Went Wrong (Several Things)#
I couldn’t find the full schema in the prose docs, so I went digging in the raw spec instead. Parent-child chunking — where a big “parent” chunk gives context and smaller “child” chunks get matched precisely — needs specific fields in the request that I just couldn’t locate in the human-readable API reference. The first attempt failed with "No subchunk segmentation found in rules." I eventually found the complete schema in Dify’s raw OpenAPI spec. Lesson for next time: when an API misbehaves and the prose docs don’t explain why, the machine-readable spec is worth checking before assuming it’s my own code that’s wrong.
Chunking was badly over-fragmented, and it wasn’t obvious until I looked. The first real sync produced 118 parent chunks for a single blog post — some as small as one bullet point, standing completely alone with no surrounding context. The parent-chunk separator was set to split on every blank line, and this blog is written in short, punchy paragraphs. Splitting on section headings instead (## ) dropped that same post to 9 real sections, each keeping its actual context together. Fewer, better chunks also meant less storage used per document — which turned out to matter more than expected.
The account hit a hard storage ceiling mid-sync. The subscription tier caps total vector storage at 50MB. Partway through the first full sync, new documents started failing with "the capacity of the vector space has reached the limit." Working through that meant cutting scope (dropping a set of very technical API reference pages that weren’t essential for a recruiter-facing bot) and, once the chunking fix above landed, discovering the smaller chunk count freed up enough room to fit everything else back in.
Retrieval was worse than it should’ve been, and a second pass caught it. After the pipeline was technically working, real test questions showed a chatbot that could answer specific things (“does this project have JPA in it?”) but stumbled on generic ones (“has this person written any blog posts?”). The fix was embedding a short “content type” and “topics” line directly into each document’s text — data I was already collecting as metadata, but metadata alone wasn’t guaranteed to influence what the model actually sees during retrieval.
The System Prompt Took as Much Tuning as the Pipeline#
Getting the data pipeline right was only half the job. The other half was actually telling the model how to behave — and that took its own round of testing, not just a first draft that happened to work.
A few things I specifically wrote in for, most of them lessons carried straight over from the RAG experiment in the last post:
- Talk about Morten, don’t pretend to be him. Third person only (“Morten built…”, “Morten has experience with…”), and an explicit instruction not to roleplay as him if asked who’s answering.
- Grounding, stated as a hard rule, not a suggestion. Never fill a gap with general knowledge, never infer a skill the portfolio doesn’t clearly support, and answer with one exact fallback sentence —
"I can't find this in Morten's portfolio."— rather than an evasive paraphrase, so it’s consistent and testable. - Sources have to come from the retrieved context, never invented. This is the fix for the “can you link me to this post?” failure from Studie Sheriff — an explicit instruction to reuse a source from earlier in the conversation when the user refers back to “this project” or “that post,” instead of trying to reconstruct a URL from memory.
- Distinguish what Morten built from what he only wrote about. A blog post reflecting on a technique isn’t proof he shipped it in production — the prompt explicitly separates “implemented,” “experimented with,” and “reflected on,” so a reflective post can’t get quietly reframed as a finished feature.
- Answer in whatever language the question was asked in, even though the underlying content is English — translate faithfully, don’t add anything new while doing it.
The actual prompt, current version:
<role>
You are "Meet Morten", the AI assistant for Morten Jensen's portfolio website (corral.dk).
Your purpose is to help visitors, including recruiters and potential collaborators,
learn about Morten, his background, experience, projects, skills, and writing.
Speak ABOUT Morten in the third person, for example:
"Morten built..."
"Morten wrote..."
"Morten has experience with..."
Do not roleplay as Morten and do not claim to be him.
If asked who you are, explain briefly that you are an AI assistant for Morten's portfolio.
</role>
<grounding>
Answer only using information supported by:
1. The retrieved portfolio context.
2. Relevant information already established earlier in the current conversation.
Never use general knowledge to fill gaps.
Never invent or assume facts, dates, technologies, skills, projects, employers,
education, achievements, or personal details.
Do not infer that Morten has a skill or experience unless the portfolio clearly
supports that conclusion.
If the available context does not clearly support an answer, say exactly:
"I can't find this in Morten's portfolio."
</grounding>
<sources>
When information comes from a retrieved document that contains a source URL,
include that source as a clickable Markdown link.
Only use source URLs explicitly available in the retrieved context or already
established earlier in the current conversation.
Never invent, modify, guess, or construct a URL.
If the user refers to something mentioned earlier in the conversation, such as:
"Can you link me to this post?"
"This project"
"Tell me more about that"
use the relevant source, project, or post from the conversation context when available.
Prefer natural source links such as:
[Read the JPA and DAOs post](https://corral.dk/posts/jpa-and-daos/)
or:
[View the MiseOS project](https://corral.dk/projects/miseos/)
</sources>
<language>
Answer in the same language as the user's question.
The portfolio content may be written in English.
Translate faithfully when answering in another language while preserving
the original meaning and facts.
Do not add new information during translation.
</language>
<retrieval>
Use the retrieved context to answer the user's actual question.
When multiple chunks or documents are relevant:
- Combine them into one coherent answer.
- Prefer the most directly relevant information.
- Avoid repeating the same information.
- Use information from multiple sources when they complement each other.
If retrieved sources conflict, mention the discrepancy rather than silently
choosing one version.
</retrieval>
<content_types>
The portfolio contains different types of content, including:
- Projects and technical implementations.
- Blog posts and technical writing.
- Reflections and learning experiences.
- Background, experience, and skills.
Distinguish between:
1. Things Morten actually implemented or built.
2. Things Morten experimented with.
3. Things Morten learned, reflected on, or wrote about.
Do not present a reflection, learning experience, or blog post as proof that
Morten implemented something unless the context explicitly says so.
</content_types>
<interaction>
If the question is genuinely ambiguous and cannot be answered reliably,
ask one short clarifying question.
Do not ask for clarification if the retrieved context clearly answers
the question.
If the question is unrelated to Morten, his work, projects, experience,
skills, or portfolio, politely explain that you can only answer questions
about Morten and his work.
</interaction>
<style>
Be helpful, professional, natural, and concise.
Start with a direct answer.
Then provide a short explanation or relevant details.
Include source links when available and useful.
Use short paragraphs or bullet points when they improve readability.
Explain relevant connections instead of simply listing isolated facts.
Do not mention retrieval, chunks, embeddings, knowledge bases, or internal
system limitations to the user.
Do not be unnecessarily verbose.
</style>None of this was right the first time. Early versions either hedged on everything (the same over-cautious refusal problem from Studie Sheriff) or occasionally slipped into first person. It’s been through a handful of test-and-adjust passes against real questions, the same way the retrieval side was — a prompt isn’t something you write once and trust, it’s something you test like the rest of the system.
What Worked Well#
Treating “unchanged” as a real state, not just re-syncing everything every time. A storage-capped account makes every wasted write expensive. Each document gets a content hash stored alongside it in Dify; a sync only touches documents whose hash has actually changed. Most future pushes will touch zero documents in the knowledge base, because most pushes don’t touch the content that feeds it.
Guardrails on anything destructive. Deletes never happen by default — they’re logged as “would delete” until an explicit flag says otherwise. A dry-run mode shows the exact plan (create / update / delete, by name) before anything touches the live dataset. Given how easy it turned out to be to accidentally overwrite or archive the wrong document mid-debugging, this paid for itself almost immediately.
The Result#
Nineteen documents, synced automatically, every push to main. Ask it about a specific project and it answers from the actual page, with a source link. Ask it something the content doesn’t cover, and — because of exactly the grounding lesson from the last post — it says so instead of guessing.
One thing worth naming honestly for the compliance side of this course: the chatbot introduces itself as an AI assistant up front, which is the actual transparency requirement under the EU AI Act for a system like this. Publishing a “model card” for what it’s trained on isn’t something I owe anyone here — that obligation sits with whoever built the underlying language model, not with someone building a retrieval layer on top of it. Worth understanding the difference instead of assuming more paperwork is always safer.
This is part two of two posts on this semester’s AI-driven applications elective.
