AI / Knowledge PlatformKnaviaknavia.rasnu.com ↗

The AI That Doesn't Know Everything
On Purpose

How we built a closed-domain knowledge assistant that refuses to hallucinate — and why that was harder than building a general-purpose AI.

0Hallucinated facts by design
HybridDense + sparse retrieval
RRFReciprocal rank fusion
<4KToken budget, enforced

Everyone Wants an AI That Answers Questions. Almost Nobody Wants an AI That Makes Things Up.

The story of every business deploying a customer-facing chatbot follows a predictable arc. They hook up a general LLM to their product. It's impressive for the first week. Then a customer asks what the refund policy is, and the model — having no idea — invents a policy with complete confidence. Then a user asks about a product that doesn't exist. The model describes it in vivid detail. By week three, the chatbot is a liability.

This is not a bug. It is the fundamental behavior of language models. They are trained to produce fluent, confident text. Uncertainty is uncomfortable to them. When they don't know something, they approximate — and approximation at scale, in production, is hallucination.

Knavia was built to solve this in a way that's actually verifiable. Not with better prompting or "just tell it to be careful." With architecture. Every layer of the system — from how documents are ingested, to how knowledge is retrieved, to how the prompt is assembled — is designed to make hallucination structurally unlikely, and the closed-domain rules make unsupported answers structurally forbidden.

"The challenge wasn't building an AI that knows things. It was building one that knows exactly what it doesn't know — and stays quiet about everything else."

Retrieval-Augmented Generation Is Not Enough On Its Own

The standard RAG setup looks simple: embed documents, store in a vector DB, embed the user's question, find the nearest chunks, stuff them into the prompt. This works well for demos. In production, it fails in four distinct ways.

01

Vector similarity misses exact keyword matches

A user searching for a specific product code or model name may get no relevant results from dense embedding search, because embedding space doesn't preserve exact token identity. The model may then fill the gap from training data.

02

Short chunks destroy context

Splitting documents into 300-character chunks to maximize retrieval precision throws away the surrounding context that makes answers accurate. A chunk about "our refund policy" without the surrounding paragraph is barely useful to an LLM.

03

Follow-up questions have no retrieval signal

"What are its features?" embedded alone will not retrieve the right document, because "it" refers to something discussed a turn earlier. The retrieval system doesn't know what "it" means.

04

The model leaks training data anyway

Even with good retrieval, a general LLM will sometimes supplement retrieved knowledge with what it learned during training. Without hard structural rules in the prompt, this is nearly impossible to prevent consistently.

Knavia addresses all four. Not with heuristics, but with deliberate, layered engineering choices at every stage of the pipeline.

Documents Are Not Trusted Until They Are Verified

Knavia accepts three document sources: file upload, URL ingestion, and raw text. Each path goes through validation that is more careful than it might appear.

01

File type validation via finfo, not browser claims

The browser's reported MIME type is ignored. PHP's finfo library reads the actual magic bytes of the file. Only application/pdf, text/plain, text/html, text/csv, and application/json are accepted. An .exe renamed to .pdf is detected and rejected.

// DocumentIngestionService.php$mime = $file->getMimeType(); // finfo reads magic bytes — not the browser claim if (!in_array($mime, self::ALLOWED_MIME_TYPES)) { throw new Exception("Invalid or executable MIME type: {$mime}"); }
02

SSRF protection on every URL and every redirect hop

When a user submits a URL, the host is resolved via DNS and checked against private and reserved IP ranges. This prevents Knavia from being weaponized to probe internal services. Each redirect in the chain is independently validated — not just the initial URL.

// DocumentIngestionService.php — validateUrlForSsrf() $ip = gethostbyname($host); if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { throw new Exception("SSRF Protection: private/reserved IP blocked."); } // Each redirect is re-validated independently: for ($i = 0; $i < $maxRedirects; $i++) { $response = Http::withoutRedirecting()->get($currentUrl); if ($response->isRedirect()) { $currentUrl = $response->header('Location'); $this->validateUrlForSsrf($currentUrl); // re-check every hop } }
03

SHA-256 checksum written at ingest time

Every document gets a sha256 checksum computed from the raw file bytes at ingest time. This enables future deduplication, integrity verification, and version comparison when documents are updated.

04

Processing dispatched to a chained queue pipeline

Document processing — text extraction → chunking → QA generation → embedding → vector indexing — runs as a chained sequence of Laravel queue jobs. The HTTP response returns immediately. The user sees live progress updates. Heavy work happens off the web process.

Small Chunks for Precision. Large Chunks for Context. Both at Once.

Standard RAG chunking forces a trade-off: small chunks give retrieval precision but throw away context. Large chunks give context but retrieve noisily. Knavia resolves this with a parent-child architecture that stores both sizes and uses the right one for each purpose.

CHILD CHUNK — used for retrieval
~600 chars

The small unit. Embedded and stored in Qdrant. Precise enough that semantic search finds the right section, not just the right document. Overlaps 100 chars with neighbors to avoid context gaps at boundaries.

PARENT CHUNK — used for context
~2500 chars

The large unit. When a child chunk is retrieved, the parent is what gets injected into the prompt. The model receives the full surrounding context — not just the precise sentence — which produces far more accurate answers.

// SimpleChunker.php — chunk()// Step 1: Split the full document into large parent chunks (2500 chars, 200 overlap) $parentChunks = $this->splitIntoChunks($content, 2500, 200); // Step 2: Split each parent into small child chunks (600 chars, 100 overlap) foreach ($parentChunks as $parent) { $childChunks = $this->splitIntoChunks($parent, 600, 100); foreach ($childChunks as $child) { $finalChunks[] = [ 'content' => $child, // embedded in Qdrant'parent_content' => $parent, // injected into the LLM prompt ]; } }

The splitter respects paragraph boundaries (\n\n) before falling back to sentence boundaries ([.?!] followed by uppercase). Chunks don't cut mid-sentence or mid-paragraph whenever possible.

Documents Don't Know What Questions They Answer. We Teach Them.

This is one of the most impactful improvements over standard RAG, and also one of the least understood. Here's the problem: documents are written in declarative form. Questions are asked in interrogative form. The embedding space between "Our refund policy allows 30-day returns" and "Can I return this?" is surprisingly large. Vector similarity can miss this match entirely.

Knavia solves this with a background job that runs after chunking: for every chunk, a fast LLM generates five hypothetical questions that the chunk would answer. These questions are stored in a qa_content column and concatenated with the chunk text before embedding is generated.

// GenerateQaPairsJob.php — prompt sent per chunk $prompt = "You are an expert search indexing assistant.\n\n" . "Read the following text block and generate exactly 5 hypothetical questions " . "that a user might ask, which this text directly answers.\n" . "Generate the questions in the EXACT SAME LANGUAGE as the original text.\n" . "Output ONLY the questions, one per line.\n\n" . "Text:\n" . $chunk->content;

The generated QA pairs are concatenated before the embedding is generated:

// GenerateEmbeddingsJob.php $texts = $chunks->map(function ($chunk) { return trim($chunk->qa_content . "\n\n" . $chunk->content);// The embedding now represents questions AND answers in the same vector })->toArray();

The effect: a chunk about a refund policy now scores high on both "30-day returns" (declarative) and "can I return this?" (interrogative). Retrieval quality improves substantially, especially on short or ambiguous queries. Arabic and English documents produce language-matched QA pairs automatically, improving multilingual retrieval without special configuration.

The QA job uses Http::pool() to parallelize requests across chunks in each batch, with automatic token rotation across a token pool file and retry logic for rate-limited responses.

Dense Vectors Find Meaning. Keywords Find Exactness. RRF Merges Them Without Picking a Winner.

Neither dense vector search nor keyword search is universally superior. Dense search finds semantically similar content but can miss exact product names, codes, and technical identifiers. Keyword search finds exact matches but can't rank by conceptual relevance. Knavia runs both in parallel and fuses the ranked results using Reciprocal Rank Fusion (RRF).

Dense Search (Qdrant)
Query embedded. Top-N chunks returned by cosine similarity. Filtered by project_id scope. Similarity threshold applied.
⊕
Keyword Search (MySQL FTS)
FULLTEXT search against content and qa_content columns in NATURAL LANGUAGE MODE. Exact and near-exact matches surface here.
↓
Reciprocal Rank Fusion
Each chunk gets an RRF score: 1 / (k + rank) summed across both result lists. Final list sorted by combined score. Top-K returned to the prompt.
// RetrievalService.php — RRF scoring (k=60, standard constant) foreach ($vectorResults as $rank => $result) { $rrfScores[$id] += 1 / ($k + $rank + 1); // dense rank contribution } foreach ($keywordResults as $rank => $dbChunk) { $rrfScores[$id] += 1 / ($k + $rank + 1); // keyword rank contribution } // A chunk appearing in both lists at rank 3 (dense) and rank 5 (keyword):// 1/(60+3+1) + 1/(60+5+1) = 0.01563 + 0.01515 = 0.03077// vs. a chunk appearing only in dense results at rank 1:// 1/(60+1+1) = 0.01613// RRF rewards cross-list agreement without score normalization arsort($rrfScores); // descending sort — take top-K

The result set is deduplicated by content hash before being assembled into the context string. The ContextBuilder uses each matched chunk's parent (2500 chars) for injection, giving the model full surrounding context.

Telling an LLM What It Cannot Do Is Harder Than Telling It What It Can

This is where most RAG products fail silently. They retrieve good content, put it in the prompt, and say "answer only from the context provided." And the model complies, mostly — until it doesn't. Because that instruction is easy to override when the model has a strong training signal about the topic.

Knavia's prompt architecture has two layers: a configurable system prompt that administrators can customize, and a set of mandatory runtime rules appended after the configurable part. These rules cannot be overridden by any administrative configuration — they're always appended last.

// PromptBuilder.php $systemPromptText = $customPrompt ?? $defaultSystemPrompt; // admin-configurable// Mandatory rules APPENDED AFTER — not replaceable by any admin config:$systemPromptText .= <<<'PROMPT' ======================== MANDATORY RUNTIME RULES ======================== Every factual claim must be supported by KNOWLEDGE. FALLBACK: If the requested factual answer cannot be supported by KNOWLEDGE, output the configured fallback response, translated into the user's language. Do not add anything before or after the fallback. Do not explain why the fallback was used. ======================== KNOWLEDGE ======================== PROMPT;

The prompt also handles a subtle but critical behavioral distinction between using conversation history to understand a question versus using it to answer one:

✓ ALLOWED — history for understanding

User asks "what are its features?" after discussing Voice Chat Apps. Model is allowed to understand this as asking about Voice Chat App features — and retrieve accordingly.

✗ FORBIDDEN — history as a factual source

A previous assistant turn mentioned "$99 pricing." User asks a follow-up about cost. The model cannot use that previous answer as fact unless the price exists in the current KNOWLEDGE retrieval.

The fallback is configurable per-project and deterministic: if set to a specific string, the model returns exactly that string (translated to the user's language). No improvisation around the edges of the fallback.

The Prompt Has a Fixed Budget. Everyone Fights for a Share. Knowledge Wins.

LLMs have finite context windows. In a multi-turn conversation with a large knowledge base, a naive implementation will silently truncate the history, or the knowledge, or the system prompt — usually whatever is last in the concatenation. The model then answers from whatever is left, which may be nothing relevant, and hallucinates to fill the gap.

Knavia enforces an explicit token budget with a clear priority order: system prompt first, then conversation history (newest-to-oldest, dropped when budget runs low), then retrieved knowledge — but with a guaranteed minimum of 1,000 tokens reserved for knowledge regardless of how long the history is.

// PromptBuilder.php — explicit token budget $inputBudgetTokens = 4000; $budgetLeft = $inputBudgetTokens - $systemTokens - $userQuestionTokens; // History is newest-first. Prepend each message until budget would drop below 1000 tokens: foreach ($conversationHistory as $msg) { $msgTokens = $this->estimateTokens((string) $msg->content); if ($budgetLeft - $msgTokens >= self::MIN_KNOWLEDGE_TOKENS) { // 1000 minimum $budgetLeft -= $msgTokens; array_unshift($messagesToKeep, ['role' => $msg->role, 'content' => $msg->content]); } else { break; // Older messages dropped to protect knowledge budget } } // Knowledge gets whatever is left. Truncated with a marker if over-budget: if ($contextTokens > $budgetLeft) { $finalContextString = substr($finalContextString, 0, $budgetLeft * self::CHARS_PER_TOKEN); $finalContextString .= "\n\n[Knowledge truncated due to input limits.]"; }

Token estimation uses a conservative 4-char-per-token heuristic, with an explicit note in the code that Arabic and multilingual content consumes more. The intentional conservatism means the budget is never unexpectedly exceeded, even for non-English content.

4,000 Token Budget Allocation (Typical Request)
System Prompt
~45%
History
~20%
Knowledge
~30%
Q
~5%
Knowledge is protected with a 1,000-token minimum floor regardless of history length

The Follow-Up Question Problem — And Why It's Harder Than It Looks

Consider this two-turn exchange:

UserVoice Chat Apps?
KnaviaWe build custom voice chat platforms with WebRTC support, sub-100ms latency, and multi-room architectures...
Userwhat are the features?

The second message, "what are the features?", is nearly meaningless without the first. If you embed it alone and send it to Qdrant, you'll retrieve random chunks about features of various things. The answer will be wrong.

The solution has two parts. First, conversation history is included in the prompt in chronological order so the model has full context to understand the reference. Second, the mandatory prompt rules explicitly teach the model how to resolve pronouns and references from history — while reminding it that understanding the question from history does not mean the answer can come from history.

// ChatService.php — history selection $history = $conversation->messages() ->where('role', '!=', 'system') ->orderBy('id', 'desc') ->skip(1) // exclude the message just saved ->take(15) // max history window to consider ->get(); // PromptBuilder.php — images in history get safety buffer if (!empty($msg->metadata['image'])) { $msgTokens = 1000 + $this->estimateTokens((string) $msg->content); // image tokens vary by provider — conservative buffer prevents overrun }

A Document Ingestion System Is a Potential Attack Surface

Any system that fetches URLs on behalf of users is a potential Server-Side Request Forgery (SSRF) vector. An attacker could submit http://169.254.169.254/latest/meta-data/ (AWS metadata endpoint) or http://localhost:6333 (Qdrant's HTTP API) and extract internal system information through the ingestion response.

01

Direct private IP submission

Submitting http://10.0.0.1/admin directly. Blocked by resolving the host and checking against private IP ranges with FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE.

02

DNS rebinding via redirect chain

Submitting a public URL that redirects to an internal IP via HTTP 301/302. Blocked by re-running SSRF validation on every redirect hop, not just the initial URL.

03

Executable file upload disguised as document

Uploading a script with a .pdf extension. Blocked by reading the file's actual magic bytes via PHP's finfo instead of trusting the browser-supplied MIME type or extension.

04

Website crawler weaponized as internal scanner

Triggering the crawler on an internal hostname. The same SSRF validation runs inside CrawlWebsiteJob — every discovered link is validated before being fetched, and the crawler is bounded to the same host as the starting URL.

What We Built, and What It Took

Knavia is not a wrapper around a language model. It is a purpose-built system for one specific job: answering questions from business knowledge, reliably and verifiably, with no hallucination leakage. That job is harder than it sounds. It requires coordination across ingestion, chunking, semantic augmentation, hybrid retrieval, context assembly, and prompt architecture — all of it working together.

⊥
Parent-child chunking

Small chunks for retrieval precision. Large chunks for answer context. Both stored per document segment.

⊕
Semantic QA augmentation

Every chunk is pre-augmented with 5 hypothetical questions, bridging the embedding gap between declarative documents and interrogative queries.

≡
Hybrid retrieval + RRF

Dense vector search and MySQL FULLTEXT run in parallel. Reciprocal Rank Fusion merges results without score normalization.

⊘
Token budget engine

Explicit 4,000-token budget. History drops oldest-first. Knowledge is guaranteed a 1,000-token minimum floor. No silent truncation.

⊛
Mandatory closed-domain rules

Appended after any custom prompt — not overridable. The model cannot use training data for factual answers, regardless of configuration.

⊗
SSRF protection

DNS resolution + private IP check on every URL and every redirect hop. Magic byte validation on all file uploads.

Deploy Knavia for Your Business

Give your customers an AI that knows your products — and nothing else. Available as a managed service through knavia.rasnu.com.