DE- AI acceleration
- Industries
- Finance
Nearshore software development for finance—secure, scalable, and compliant solutions for banking, payments, and APIs.
- Retail
Retail software development services—e-commerce, POS, logistics, and AI-driven personalization from nearshore engineering teams.
- Manufacturing
Nearshore manufacturing software development—ERP systems, IoT platforms, and automation tools to optimize industrial operations.
- Finance
- What we do
- Services
- Software modernization services
- Cloud solutions
- AI – Artificial intelligence
- Idea validation & Product development services
- Digital solutions
- Integration for digital ecosystems
- A11y – Accessibility
- QA – Test development
- Technologies
- Front-end
- Back-end
- DevOps & CI/CD
- Cloud
- Mobile
- Collaboration models
- Collaboration models
Explore collaboration models customized to your specific needs: Complete nearshoring teams, Local heroes from partners with the nearshoring team, or Mixed tech teams with partners.
- Way of work
Through close collaboration with your business, we create customized solutions aligned with your specific requirements, resulting in sustainable outcomes.
- Collaboration models
- Services
- About Us
- Who we are
We are a full-service nearshoring provider for digital software products, uniquely positioned as a high-quality partner with native-speaking local experts, perfectly aligned with your business needs.
- Meet our team
ProductDock’s experienced team proficient in modern technologies and tools, boasts 15 years of successful projects, collaborating with prominent companies.
- Why nearshoring
Elevate your business efficiently with our premium full-service software development services that blend nearshore and local expertise to support you throughout your digital product journey.
- Who we are
- Our work
- Career
- Life at ProductDock
We’re all about fostering teamwork, creativity, and empowerment within our team of over 120 incredibly talented experts in modern technologies.
- Open positions
Do you enjoy working on exciting projects and feel rewarded when those efforts are successful? If so, we’d like you to join our team.
- Hiring guide
How we choose our crew members? We think of you as a member of our crew. We are happy to share our process with you!
- Rookie boot camp internship
Start your IT journey with Rookie boot camp, our paid internship program where students and graduates build skills, gain confidence, and get real-world experience.
- Life at ProductDock
- Newsroom
- News
Stay engaged with our most recent updates and releases, ensuring you are always up-to-date with the latest developments in the dynamic world of ProductDock.
- Events
Expand your expertise through networking with like-minded individuals and engaging in knowledge-sharing sessions at our upcoming events.
- News
- Blog
- Get in touch
31. Jul 2026 •8 minutes read
Ask anything, share nothing: How we built our AI assistant for internal documents
Jovica Zorić
Chief Technology Officer
Almost every company is having the same conversation these days: can we just ask ChatGPT or Claude about our internal documents? It’s easy to see why this is attractive. Policies, guidelines, and how-to knowledge are scattered across wikis and PDFs, and people keep asking the same questions. The problem is easy to understand, too: those documents are sensitive. Sending them to a third-party API, along with the questions employees ask about them, carries legal, compliance, and trust implications that most organizations are not willing to accept casually.
At ProductDock, we ran into this exact same problem with our own internal documentation. So we built the assistant ourselves: a retrieval-augmented generation (RAG) chatbot where every component runs on infrastructure we control (a GPU VM in our own AWS account, inside our own security boundary). The language model, the vector database, the session storage, all of it. Not a single token leaves our environment or touches a third-party AI API.
This post walks through what we built, the key decisions we made, and the lessons we learned along the way. While we provide context where necessary, we assume familiarity with common LLM serving, inference, and retrieval concepts. By doing so, we can focus strictly on implementation details rather than foundational explanations.
The constraint that shaped everything: no data leaves our infrastructure
Most RAG tutorials start with an OpenAI or Anthropic API key. We couldn’t, and that single constraint drove the architecture. The LLM is self-hosted. We serve an open-weight model with vLLM on the NVIDIA GPU. vLLM exposes an OpenAI-compatible API, so the rest of the stack is written as if it were talking to a cloud provider. We just point it at our own hardware.
Retrieval is self-hosted. Documents are chunked, embedded, and stored in Qdrant, running as a container next to everything else. Sessions, message history, and user feedback live in Postgres. Even observability stays home: every LLM call, including the tool call decisions the model makes, lands as a trace in a self-hosted Arize Phoenix instance via OpenTelemetry, so we get the visibility of a SaaS observability platform without shipping prompts to one.
Self-hosting has become compatible with the developer experience of cloud-based solutions. Since vLLM speaks the OpenAI API dialect, every library, framework, or code written for cloud providers works seamlessly without modification. It also gave us a free dev/prod split: on a laptop, we develop against Ollama, which talks the same API, and the only difference between “running on my machine” and “running on the GPU server” is a base URL in an env file.
The shape of the system
Three processes share the infrastructure (Image 1).

The indexer is a one-shot job that reads our markdown documents, chunks them, embeds them, and stores them in Qdrant. It’s incremental: it hashes documents between runs, so a rerun only re-embeds what actually changed, and documents deleted at the source get pruned from the index. Getting this right matters more than it sounds. The naive “re-embed everything” approach is either slow or, worse, silently duplicates chunks and degrades subsequent answers.
The backend API is FastAPI sitting in front of vLLM, Qdrant, and Postgres. The design is agentic: instead of always searching before answering, the model itself decides when to call the search tool, meaning a vague follow-up gets a clarifying question instead of a search, while a real question triggers one or more targeted searches.
The frontend widget is where we made a deliberately unfashionable choice. It’s not a React app. It’s a web component built with Lit: one script tag and
<pd-chat-widget api-url=”…” api-key=”…”>
drops the assistant into an internal portal or any page that lets you add HTML. Shadow DOM keeps the host page’s CSS out and ours in (Image 2).

Retrieval: the boring details are the quality
Two retrieval decisions moved the answer quality more than anything else we tried.
The first: embed the headers, not just the text. A chunk that reads “Employees may take up to 20 days per calendar year” is topically anonymous. The embedding model lacks any contextual understanding of the subject matter, such as , e.g., parental leave policies. But the chunk lives under a heading path like Benefits > Parental Leave, and that context is free. We prepend the heading path and filename to the text the embedding model sees, while the LLM still receives the clean original chunk. For documents with numerous headings, this single adjustment noticeably improved recall, at zero runtime cost.
The second: hybrid search with graceful degradation. Dense vector search handles paraphrasing, so “time off when my baby is born” finds the parental leave section. Traditional sparse keyword search handles the rare proper nouns and internal acronyms that embeddings compress into mush. We fuse both with Qdrant’s score fusion, possibly expand the user’s question into several keyword-style variants, and rerank the merged results with a cross-encoder against the original question (Image 3).

The design rule behind the dotted lines in that diagram: every stage above basic hybrid search is optional. If query expansion fails, we search the original question. If the reranker throws an exception, we fall back to fusion order. Retrieval quality features should be optional enhancements that can be disabled when they fail, not dependencies on which the system relies.
The local LLM lesson: buy parameters with quantization
We cannot overstate this: poor decision-making in small models is the primary reason local-LLM initiatives often fall short. We started with Llama-3.1-8B-Instruct, a sensible-looking choice for a single GPU. Over the following months, our git history reads like a tour of the open-weight model zoo: Qwen3-14B-AWQ, mistral-nemo, a 2B-class Gemma, to name a few. Every one of them worked in the demo sense (fluent answers, plausible citations) and every one of them fell apart when the agentic behavior mattered. Our design leans on the model’s judgment: deciding whether to search, extract keywords, ask a clarifying question, or decline an off-topic request.
We know exactly how they fell apart, because we measured it. One of our eval sets is a routing eval: 42 hand-written messages, each labeled with the top-level decision the model should make: search, ask for clarification, decline, or just respond to a greeting. Here is the scorecard:

The failure mode isn’t stupidity, it’s overeagerness. A small model always knows when to search and almost never knows when to stop: vague questions got a confident answer instead of a clarifying question, and off-topic requests got answered instead of declined. On top of that, only 19 of the 23 correctly-routed search calls had well-formed keyword arguments. The rest passed whole sentences or bare pronouns as search queries. No prompt we wrote moved these numbers, because it isn’t a prompting problem. It’s a judgment problem, and judgment scales with parameters.
The fix was a bigger model, made affordable by quantization.
Qwen2.5-32B-Instruct-AWQ is a 32-billion-parameter model compressed to roughly 20 GB of weights, so it fits our single GPU — an NVIDIA L40S with 48 GB of VRAM — on an AWS g6e.xlarge, with room left over for the KV cache. That’s the smallest GPU instance in its family, around $2/hour on demand, and it runs the entire company’s assistant. Our production vLLM configuration:
command: >
Qwen/Qwen2.5-32B-Instruct-AWQ
--quantization awq_marlin
--max-model-len 8192
--gpu-memory-utilization 0.90
--enable-auto-tool-choice
--tool-call-parser hermes
--enable-prefix-caching
Two details in there deserve attention. The tool-call parser is part of the model choice: every model family emits tool calls in its own format, and vLLM needs to be told which one to expect. We tried this as our config went from –tool-call-parser llama3_json to gemma4 to hermes as the models changed. Swap the model and forget to swap the parser, and tool calling fails in ways that look exactly like model stupidity: the model emits a perfectly good call in its native format, the server doesn’t recognize it, and the “call” arrives as plain text in the chat. And prefix caching is free performance: every request starts with the same long system prompt, and with –enable-prefix-caching vLLM computes it once and reuses it.
The takeaway for anyone sizing a local deployment: in an agentic RAG setup, a quantized large model beats a full-precision small one. Spend your VRAM on parameters, not precision.
Evals: turning “I think it’s better” into a number
The routing scorecard above didn’t exist on day one, and building it changed how we work. Every tweak in a stack like this (prompt edit, an embedding swap, a reranker toggle, a model change) raised the same question: did that make things better or worse? Without evals, the answer is vibes. Ours has four surfaces, ordered from cheapest to most expensive to run:
- Routing: the 42-message set above. It replays only the first LLM call, no retrieval involved, so a failure points straight at the prompt or the model. It runs in seconds, which makes it the inner loop for prompt iteration.
- Retrieval: purely numerical, no LLM judge. The trick is how the dataset gets built: a script walks every chunk in Qdrant and asks the LLM to generate a question whose answer comes only from that chunk, along with the corresponding answer. This gives every question a ground-truth chunk ID by construction. The runner scores Recall@1, Recall@3, MRR, and nDCG, then runs the evaluation 4 times, toggling the reranker and query expansion on and off.
- End-to-end: the same questions POSTed at the real /chat endpoint, graded by LLM judges for faithfulness (is every claim supported by the retrieved context?), correctness, and citation quality.
- Safety: 20 hand-written adversarial prompts: prompt extraction, jailbreaks, false premises, social engineering. A judge classifies each response as refused, leaked, or derailed. This one runs before releases.
A confession that doubles as advice: we built this harness three times. Once in plain Python, once on DeepEval, once on Ragas, to see which one we’d actually keep using. Plain Python won. The frameworks earn their complexity on larger teams; at our size, a runner script, a JSONL dataset, and a results file you can sort by failures. The row-level failures are the real product anyway.
Keeping the model honest
A self-hosted model protects your data. It doesn’t protect your users from confident nonsense. The system prompt is strict about it: the model may only answer from content it retrieved via the search tool, and must say so when the documents don’t contain the answer, even when the answer “seems obvious.” Hallucinated policy answers are the fastest way to lose an internal tool’s most fragile asset: trust.
We also close the loop with users. Every answer in the widget includes thumbs-up/down feedback (Image 4), and ratings land in Postgres alongside the context that produced the answer. That’s not just a vanity metric. It’s exactly the dataset you need when you later tune chunk sizes, swap embedding models, or build an evaluation set.

What this means beyond our documents
Nothing in this architecture is really specific to documents. The constraint that shaped it, keeping sensitive data inside a boundary we control, applies to any workload you’d rather not hand to a third-party API. Swap the corpus ( HR policies, contracts, financial records, source code) and the pattern holds: incremental indexing, hybrid retrieval, a quantized open-weight model behind vLLM, and a drop-in interface where people already work.
If there’s one lesson worth carrying into your own build, it’s that self-hosting is no longer the compromise it used to be, and that nothing here felt trustworthy until we could measure it. The real payoff isn’t any single component. It’s that internal knowledge becomes instantly queryable while every byte stays inside a boundary you own, and that shift doesn’t require handing your data to anyone else’s API.
Building AI, the right way
At ProductDock, this is how we build AI: around your data, your constraints, and your definition of done, not around whatever a vendor’s API makes easy. Self-hosted when the problem calls for it, cloud when it doesn’t, and the same discipline either way. If you are looking for a partner who takes both the engineering and the trust seriously, you are in the right place. Reach out.
Tags:Skip tags
Jovica Zorić
Chief Technology OfficerJovica is a techie with more than ten years of experience. His job has evolved throughout the years, leading to his present position as the CTO at ProductDock. He will help you to choose the right technology for your business ideas and focus on the outcomes.