docs-assistant
An AI support assistant that answers from your documents, cites every answer, and hands the conversation to a person when it doesn't know.
An embeddable RAG chatbot in .NET 10: hybrid search in PostgreSQL with pgvector, Claude Haiku behind a swappable port, and a 9 KB widget that installs with one line.
- Role:
- Design and development
- Year:
- 2026
- retrieval hit@5 on a 54-question bilingual eval
- 98%
- prompt-injection attacks resisted
- 12/12
- model cost per 1,000 questions
- $0.33
- embeddable widget
- 9 KB
Demo
The problem
Small businesses answer the same questions all day, and generic chatbots make up answers that the business then has to correct. The goal was an assistant a customer can trust: it answers only from the business’s own documents, shows where each answer came from, and when it cannot answer, it says so and passes the conversation to a person.
Constraints
- No invented answers: abstaining beats guessing
- Questions and documents in different languages, Spanish and English
- A small, visible cost per answer, with hard spending caps
- It has to embed in any website without breaking its styles
- Several businesses on one deployment, each seeing only its own data
How it works
- Documents (PDF, DOCX, Markdown and web pages) are split along their structure and indexed twice in PostgreSQL: as vectors, for meaning across languages, and as full text, for exact words, numbers and names.
- A question searches both indexes and the two rankings are fused with Reciprocal Rank Fusion. Passages shaped like injected instructions are quarantined before they reach the model.
- Below a measured similarity floor the assistant abstains without calling the model. Otherwise Claude answers from those passages only and cites them as [1], [2]; the citations are validated before the answer streams to the widget over SSE.
- The widget is framework-free TypeScript inside a Shadow DOM: 9 KB, one script tag, and styles that never leak into the host page.
Key decisions
Abstain before calling the model
When retrieval finds nothing similar enough, the answer is “I can’t find that” plus a person, decided in code at zero cost.
Trade-off: A few answerable questions are refused when retrieval misses. The eval tracks that rate, so the floor is tuned on data rather than taste.
Hybrid search, not vectors alone
Vectors handle meaning and cross-language questions; full text catches the prices, codes and names that embeddings blur. Guaranteeing one vector hit in the fused list raised cross-lingual hit@5 from 86% to 95%.
Trade-off: Two indexes to maintain and a fusion step to tune, though both live in the PostgreSQL the app already needs.
The model behind a port, with spending guards
Claude sits behind Microsoft.Extensions.AI, so the model is a configuration choice, and an extractive mode works with no model at all. The evals refuse to spend without an explicit flag, a call cap and a cost cap.
Trade-off: A thin abstraction layer to keep current, in exchange for never being locked to one provider or surprised by a bill.
Results
- Retrieval finds the right passage for 98% of the 54 eval questions, and for 95% when the question and the document are in different languages.
- With Claude Haiku 5.5: 0 invalid citations, abstention on 100% of the unanswerable questions, and USD 0.33 per 1,000 questions.
- 12 of 12 prompt-injection attacks resisted (8 direct, 4 hidden inside documents), while 0 of the 54 real questions were wrongly refused.
- Every number comes from an eval in the repository that anyone can rerun, and CI fails if retrieval drops below its thresholds.
Stack
- .NET 10
- C#
- PostgreSQL
- pgvector
- Aspire
- Claude Haiku 5.5
- TypeScript
- Playwright