Engineering

Three Layers for Deterministic Knowledge

Why vector search alone is not enough for regulated environments, and how we split storage, identity, and logic into three distinct layers.

May 18, 2024
Karan Mittal
12 min read

Three Layers for Deterministic Knowledge

Vector search is good at finding documents that feel similar. It is bad at finding facts that are correct. In regulated environments — healthcare, finance, government — "feels similar" is not good enough. You need to know why an answer is true, when it became true, and what it conflicts with.

At Dextar, we have been thinking about this through our research framework, KEOS. The core idea is simple: separate storage, identity, and logic into three distinct layers. Each layer does one job well. Together, they give you deterministic answers instead of probabilistic guesses.

This post explains what those three layers are, why we split them, and what we have learned from two production engagements where these ideas mattered.

Layer 1: Resource — Store What You Know

The Resource Layer is a bitemporal fact store. Every document — PDF, Slack message, SQL row — gets a SHA-256 hash. That hash is its identity. If the content changes, the hash changes. If the content is identical, the hash is identical.

Why bitemporal? Because facts have two clocks:

  • Valid-time: when the event actually happened
  • Transaction-time: when we recorded it

If a contract is signed on March 1 but uploaded on March 5, both dates matter. Valid-time tells you the business reality. Transaction-time tells you what the system knew and when.

from dataclasses import dataclass
from datetime import datetime

@dataclass(frozen=True)
class ResourceNode:
    sha256: str
    raw_content: bytes
    valid_time: datetime
    transaction_time: datetime

The store itself is PostgreSQL. Nothing exotic. We use SHA-256 because it is deterministic, collision-resistant, and lets us deduplicate identical documents across tenants without comparing content byte-by-byte.

What we learned: In our public sector engagement, this audit trail was non-negotiable. Every answer the system gave had to be traceable to a specific policy document, paragraph, and ingestion timestamp. Vector search alone cannot give you that.

Layer 2: Semantic — Map Who and How

The Semantic Layer resolves entities across documents. If "Acme Corp" appears in a contract and "Acme" appears in an email, this layer decides whether they are the same entity.

We use a deterministic identity function:

canonical_hash = SHA256(Name + EntityType + TenantSalt)

Same inputs, same output, every time. No LLM guessing. No probabilistic matching.

The graph lives in FalkorDB. We chose it because relationship traversal is fast and the query language is close enough to Cypher that graph engineers do not need retraining.

@dataclass
class SemanticNode:
    canonical_hash: str
    entity_type: str  # "Company", "Person", "Policy"
    aliases: list[str]
    source_resources: list[str]  # SHA-256 hashes from Layer 1

What we learned: In our FMCG engagement, the client's sales data was spread across 40+ Excel sheets with inconsistent naming. "Acme Ltd" in one sheet and "Acme Limited" in another were the same customer. Resolving them manually took hours. A deterministic identity function reduced that to milliseconds.

Layer 3: Assertion — Record What You Believe

The Assertion Layer is where conflicts live. If two documents give different revenue figures for the same quarter, most systems pick one or average them. We think that is wrong.

Instead, we record the conflict as a first-class object. Both figures stay in the system. Each gets a context score based on:

  • Source reliability
  • Recency
  • How many other assertions support or contradict it

This is inspired by Quantitative Bipolar Argumentation (QBAF), a formal framework for reasoning with conflicting information. We do not claim to have a full QBAF implementation. What we have is a pragmatic version: score conflicts, surface them to humans, and let the user decide.

@dataclass
class Assertion:
    statement: str
    confidence: float  # 0.0 to 1.0
    supporting_evidence: list[str]  # ResourceNode SHA-256 hashes
    contradicting_evidence: list[str]
    status: str  # "accepted", "disputed", "under_review"

What we learned: In regulated environments, the most valuable thing a system can say is "I do not know" or "these two sources disagree." Hallucinating a compromise is worse than admitting uncertainty.

How Documents Flow Through

Documents do not land in the graph directly. They pass through a tiered extraction pipeline:

  1. spaCy — syntactic parsing. Named entities, dates, organizations.
  2. GLiNER — domain-specific extraction. GLiNER is a bi-encoder model fine-tuned for boundary detection. It catches entities that spaCy misses in specialized domains.
  3. NuExtract — relational extraction. Complex nested JSON, parent-child relationships, quantitative fields.
def extract(document: bytes) -> list[SemanticNode]:
    entities = spacy_extract(document)
    entities = gliner_refine(entities, domain="finance")
    relations = nuextract_map(entities)
    return build_nodes(relations)

Each tier filters and structures the document before the next tier sees it. This reduces noise and keeps the graph clean.

Retrieval: Vector + Graph + SQL

When a user asks a question, we do not search one index. We search three:

  • pgvector for semantic similarity
  • FalkorDB for structural relationships
  • PostgreSQL for hard quantitative filters (date ranges, exact values)

Results are merged with Reciprocal Rank Fusion (RRF). RRF is rank-based, not score-based. A #1 result in any source counts more than a #10 result in any source. This matters because vector similarity scores, graph traversal scores, and SQL filter matches are on completely different scales. Normalizing them is fragile. RRF sidesteps the problem entirely.

from typing import List, Tuple

def rrf_fuse(lists: List[List[str]], k: int = 60) -> List[Tuple[str, float]]:
    scores = {}
    for source in lists:
        for rank, doc_id in enumerate(source, start=1):
            scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + rank)
    return sorted(scores.items(), key=lambda x: x[1], reverse=True)

Current Status

KEOS is a research framework, not a shipping product. The three-layer architecture described here is what we are exploring, building, and refining through client engagements.

The public sector pilot taught us that on-premise deployment and human escalation are non-negotiable. The FMCG engagement taught us that deterministic entity resolution saves more time than any LLM prompt engineering trick.

What we have described above is our current thinking. Some of it is implemented. Some of it is still being prototyped. All of it is open to being wrong — and corrected when better approaches emerge.

KM

Karan Mittal

Engineering Team, Dextar. Specializing in production AI systems, deterministic knowledge infrastructure, and GraphRAG architectures.

Dextar Engineering