Upload PDFs, Markdown or text files and ask questions in plain language. AskDocs retrieves the most relevant passages with hybrid search (BM25 + embeddings), streams a grounded answer token by token, and cites the exact document and page. It runs fully offline by default, with Claude or a local Ollama model one setting away.
| Grounded answers | The model sees only the retrieved passages, numbered [1]…[n], and must cite them. If nothing relevant is found, the relevance gate answers "not found" without calling the model, so there's nothing to hallucinate from. |
| Hybrid retrieval | BM25 catches exact terms (names, codes, figures); vector similarity catches paraphrases; Reciprocal Rank Fusion merges the two rankings without score calibration. |
| Page-accurate citations | PDFs are parsed per page and chunks never cross a page boundary, so every [n] maps to one page. Click a citation to see the passage that matched. |
| Streaming | Answers stream over Server-Sent Events (sources first, then tokens), with stop / cancel. |
| Pluggable models | Extractive (default: no model, quotes the best sentences) · Ollama (free, local, private) · Claude (official Anthropic C# SDK, claude-opus-5-5). Embeddings: offline feature hashing or Ollama nomic-embed-text. |
| Ingestion pipeline | Uploads return immediately; a background worker extracts, chunks (sentence-aligned, overlapping) and embeds. Re-indexes automatically if you switch embedding models. Duplicate uploads are detected by SHA-256. |
| Safety & limits | Extension and PDF magic-byte checks, size limits, generated storage names (no client paths on disk), per-IP rate limiting on /api/ask, and prompt instructions to treat document text as untrusted. |
| Light | Dark | Mobile |
|---|---|---|
![]() |
![]() |
![]() |
flowchart LR
subgraph Ingest[Ingestion · background worker]
U[Upload PDF / MD / TXT] --> X[Extract text<br/>per page · PdfPig]
X --> C[Chunk<br/>sentence-aligned, overlapping,<br/>never crossing pages]
C --> E[Embed]
E --> S[(SQLite<br/>documents · chunks · vectors)]
E --> I[In-memory hybrid index]
end
subgraph Ask[Question]
Q[Question] --> QE[Embed query]
QE --> R{Hybrid search<br/>BM25 + cosine → RRF}
I --> R
R -->|no relevant passage| NF[“Not found” · model not called]
R -->|top-k passages| P[Grounded prompt<br/>numbered sources]
P --> G[Claude / Ollama / Extractive]
G -->|SSE: sources, tokens, done| UI[Angular chat<br/>clickable citations]
end
dotnet run --project src/AskDocs.Api # http://localhost:5200 - offline mode, no setupUpload the files in samples/ (fictional documents written for this demo) and try
"How many annual leave days can be carried forward?"
Use Claude (the key is read from the environment, never from config files):
export ANTHROPIC_API_KEY=... # PowerShell: $env:ANTHROPIC_API_KEY="..."
dotnet run --project src/AskDocs.Api -- --AI:Provider=ClaudeThe Claude integration uses the official Anthropic SDK with streaming, claude-opus-5-5 at medium effort (configurable in
appsettings.json), handling for refusal and max_tokens stop reasons, and server-side refusal fallback enabled.
Use a local model with Ollama:
ollama pull llama3.2 && ollama pull nomic-embed-text
dotnet run --project src/AskDocs.Api -- --AI:Provider=Ollama --AI:Embeddings=OllamaDocker: docker compose up --build (offline mode on http://localhost:8080). See docker-compose.yml for the Ollama and Claude variants.
Developing the UI: cd src/web && npm install && npm start serves on http://localhost:4200 and proxies /api to the running API.
| Setting | Default | Meaning |
|---|---|---|
AI:Provider |
Extractive |
Extractive · Ollama · Claude |
AI:Embeddings |
Hashing |
Hashing (offline) · Ollama |
AI:Claude:Model / Effort / MaxTokens |
claude-opus-5-5 / medium / 8000 |
Claude request settings |
AI:Ollama:BaseUrl / ChatModel / EmbeddingModel |
localhost:11434 / llama3.2 / nomic-embed-text |
Ollama settings |
Retrieval:TopK |
6 |
passages given to the model |
Retrieval:MinSimilarity |
0.35 |
relevance gate for passages with no keyword match |
Storage:MaxUploadMb |
25 |
upload size limit |
RateLimit:AsksPerMinute |
20 |
per-IP limit on questions |
dotnet test # 31 tests, fully offline
cd src/web && npm run test:ci # SSE parser + citation rendering- Retrieval and quality: chunking (page boundaries, overlap, hard-wrap), PDF extraction per page (PDFs generated in the test), Markdown cleanup, tokenizer, embedding determinism, ranking cases for hybrid search, document scoping, snippet windows, extractive sentence ranking.
- Claude request shape: asserts the serialized request (model, effort, fallback, history) without calling the API.
- HTTP + SSE integration: boots the real app, uploads files over multipart, waits for background indexing and parses the event stream. Covers grounded answers with citations, the not-found gate, scoping, dedupe, rejected uploads, delete, and provider failure turning into an
errorevent.
Design trade-offs (why hybrid search, why an in-memory index, why an extractive mode) are in docs/decisions.md.
- OCR for scanned PDFs (Tesseract) and DOCX support
- Re-ranking the top 20 with a cross-encoder before answering
- A retrieval eval set (questions → expected passages) tracked in CI, with recall@k
- Per-user libraries with authentication; a vector database (pgvector / Azure AI Search) for large corpora
Nitish Kumar, Senior .NET / Full Stack Developer Portfolio · LinkedIn · GitHub
More projects: MiniERP (multi-tenant GST invoicing SaaS) · OrderFlow (event-driven microservices with sagas and an outbox)



