When your result set is a sample pretending to be a census, every aggregate downstream is a guess wearing the costume of a fact.
The first lie in the $7.2M answer was “12 accounts.” There were not 12 accounts. There were 12 accounts in the top 50 chunks returned by a vector search configured by whoever wrote the tool description. The real number might be 40. It might be 12. The retrieval gives you no way to know.
This chapter builds the annotation that prevents this from happening silently: a completeness type that travels with every intermediate result, a composition rule that propagates it through joins and unions, and a gate that converts aggregation over incomplete data from a confident number into a labeled bound.
Here is the pipeline that produced the lie:
chunks = vector_search(
query="churn risk, non-renewal, evaluating competitors",
filter={"created_at": {"gte": "2026-05-01"}},
k=50,
)
accounts = {c.account_id for c in chunks} # 12 accounts
Then the agent says “12 accounts.”
Relational algebra assumes sets are complete, unordered, and stable. Vector search returns a result that is incomplete by construction, ordered by a score, and unstable across index updates. Compose them without saying so and you get COUNT(*) over a truncated ranking, reported as a count.
Elastic’s own kNN documentation says the quiet part directly: aggregations are computed over the top k nearest documents, not the matching set. That is a correct and honest description of the behavior. It is also a loaded gun pointed at every agent that treats a vector store as a database.
The obvious fix — “raise k” — doesn’t work. There is no k at which the result becomes exact, because ANN recall is below 1 and the index lags the source. Raising k makes the lie smaller and no less a lie. Worse, it makes the lie harder to notice, because the number stops moving between runs and starts to look stable.
A 2026 paper on vector search inside relational engines (“To GPU or Not to GPU,” arXiv 2605.15957) builds a TPC-H variant mixing ANN with relational operators and reports relative revenue error for a SUM over an approximate retrieval — targeting ≤1% relative error at ≥95% per-query recall. That is the right instinct: treat approximation error as a quality knob. But note the framing — nobody downstream is told that the number they’re reading has a 1% error bar, and no LLM narrating it will invent one.
Every result set in our system carries a completeness annotation drawn from this ordered set:
\[ \texttt{exact} \sqsupset \texttt{sampled} \sqsupset \texttt{top\_k}(k) \sqsupset \texttt{truncated} \sqsupset \texttt{unknown} \]The ordering means: exact is the strongest guarantee (every qualifying tuple is present), and unknown is the weakest (we have no idea what fraction of the true result set is here).
| Level | Meaning | Example source |
|---|---|---|
exact | The result is the complete answer to the predicate | Warehouse table with no filters beyond the query |
sampled | A random subset with known sampling rate | Stratified sample table, approximate count-distinct |
top_k(k) | The k highest-scored items under some ranking | Vector search, API with pagination limit |
truncated | Incomplete for operational reasons (timeout, rate limit) | API that hit a rate limit mid-page |
unknown | No completeness guarantee available | Third-party data dump with no freshness SLA |
When two result sets are composed (joined, unioned, intersected), the output’s completeness is the meet — the weaker of the two inputs:
\[ \text{completeness}(A \bowtie B) = \text{completeness}(A) \sqcap \text{completeness}(B) \]In concrete terms:
exact ⋈ top_k(50) = top_k(50)top_k(50) ∪ top_k(100) = top_k(50) (the weaker bound governs)sampled(0.1) ⋈ exact = sampled(0.1)unknown = unknownThis is the single most valuable rule in the design because it prevents an exact warehouse fact from being laundered into exactness by association with an approximate retrieval result.
Certain answers (Imieliński & Lipski, 1984)
A certain answer to a query \(Q\) over an incomplete database \(D\) is a tuple that appears in the result of \(Q\) in every possible completion of \(D\). A possible answer is a tuple that appears in at least one completion.
Our exact level corresponds to certain answers: the result is the same regardless of what the missing data might be. The top_k level corresponds to possible answers: the tuples present are genuine (they really do match), but there may be additional tuples in some completions that we haven’t retrieved.
The meet rule is a corollary: if one input admits multiple completions (is incomplete), then the composed result also admits multiple completions. The guarantee cannot strengthen through composition.
The critical design decision: what happens when you aggregate over an incomplete result?
There are three options:
Option 3 is the right default. Here is what it produces:
// Before: the agent reports a bare number
{ "value": 12, "type": "count" }
// After: the result carries its basis
{
"value": 12,
"type": "count",
"completeness": "top_k(50)",
"bound": "lower",
"qualifier": "at least 12, from the top 50 matching conversations"
}
The aggregation function determines the bound direction:
| Aggregate | Over incomplete data | Bound type |
|---|---|---|
COUNT | Undercounts (missing tuples not counted) | Lower bound |
SUM (positive values) | Undercounts | Lower bound |
MIN | True minimum may be in the missing tuples | Upper bound on the minimum |
MAX | True maximum may be in the missing tuples | Lower bound on the maximum |
AVG | Direction unknown without distributional assumptions | No bound — reject or flag |
COUNT DISTINCT | Undercounts | Lower bound |
AVG is the dangerous case. A COUNT over incomplete data is at least honest about its direction — the true count can only be higher. But an average over a top-k set is biased in the direction of whatever the ranking correlates with, and you cannot bound the error without knowing the distribution of the missing values. The safe choice is to reject AVG over non-exact data entirely, or require the caller to explicitly opt in with an acknowledgment that the result is biased.
Propagation through a two-stage pipeline
An agent answers: “What is the total ARR at risk from accounts that mentioned churn?”
Stage 1: Vector search for “churn” conversations. Returns 50 chunks. Completeness: top_k(50).
Stage 2: Extract account IDs → 12 distinct accounts. Completeness: still top_k(50) (projection doesn’t improve completeness).
Stage 3: Join with monthly_account_arr (exact warehouse table) on account_id. Completeness: exact ⊓ top_k(50) = top_k(50).
Stage 4: SUM(arr) over the result. The aggregation gate fires: input is top_k(50), aggregate is SUM over positive values, so the result is a lower bound.
The agent must say: “At least $X across at least 12 accounts (from the top 50 matching conversations).”
The completeness type and its operations in Python:
from enum import IntEnum
from dataclasses import dataclass
from typing import Optional
class CompletenessLevel(IntEnum):
EXACT = 4
SAMPLED = 3
TOP_K = 2
TRUNCATED = 1
UNKNOWN = 0
@dataclass(frozen=True)
class Completeness:
level: CompletenessLevel
k: Optional[int] = None # only for TOP_K
sample_rate: Optional[float] = None # only for SAMPLED
@staticmethod
def exact():
return Completeness(CompletenessLevel.EXACT)
@staticmethod
def top_k(k: int):
return Completeness(CompletenessLevel.TOP_K, k=k)
@staticmethod
def sampled(rate: float):
return Completeness(CompletenessLevel.SAMPLED, sample_rate=rate)
def meet(self, other: "Completeness") -> "Completeness":
"""The weaker of two completeness guarantees."""
if self.level <= other.level:
return self
return other
def allows_aggregation(self, agg: str) -> bool:
"""Whether this completeness level permits the given aggregate."""
if self.level == CompletenessLevel.EXACT:
return True
if self.level == CompletenessLevel.UNKNOWN:
return False
# AVG over incomplete data is rejected
if agg in ("avg", "median", "percentile"):
return False
return True # COUNT, SUM, MIN, MAX allowed with bounds
def bound_direction(self, agg: str) -> Optional[str]:
"""For non-exact data, what bound does this aggregate give?"""
if self.level == CompletenessLevel.EXACT:
return None # exact, no bound needed
if agg in ("count", "count_distinct", "sum", "max"):
return "lower"
if agg == "min":
return "upper"
return None # no bound determinable
The key design decisions:
meet operation is the core composition rule. Every join, union, or intersection calls it.allows_aggregation is the gate. It returns false for operations that would produce unbounded errors.bound_direction tells the narrator which qualifier to use (“at least” vs. “at most”).A deeper issue lurks behind the top-k truncation: agentic retrievers have no principled stopping condition. They cannot tell when they’re done, because “done” is not a signal the retrieval interface exposes.
A recent paper on structured RAG for aggregative questions (arXiv 2511.08505) puts it plainly: missing a single piece of evidence produces an incomplete answer, and there is no way to detect the miss from within the retrieval loop.
Three approaches, in increasing order of engineering cost:
Accept that the retrieval is incomplete. Report the bound. This is what our annotation system does. It is cheap, honest, and often sufficient — “at least 12 accounts” is a useful answer for many business questions.
Run a second, independent retrieval path (different query formulation, keyword search, or a sample of the full corpus) and estimate what fraction of the true result set the primary retrieval captured. This gives a confidence interval on the bound rather than a bare lower bound. It doubles the retrieval cost but can be cached.
When the aggregation is critical (financial reporting, compliance), bypass the ANN index entirely and run an exact predicate over the full corpus. This is the equivalent of SELECT ... WHERE text ILIKE '%churn%' — exact but coarse. The completeness annotation lets you choose when to pay this cost: only when the aggregate matters, only when the bound is too loose, only when the caller hasn’t opted into approximation.
Your vector store supports hybrid search: a keyword leg (BM25, exact for its terms) and a dense leg (ANN, top-k). Results are fused by reciprocal rank fusion (RRF). What completeness annotation should the fused result carry?
The fused result is top_k. Even though the keyword leg is exact for documents containing the query terms, the fusion re-ranks and truncates the output to a fixed size. Worse, the keyword leg is only exact for its specific terms — semantically relevant documents using different vocabulary are missed entirely. The fusion cannot be exact because the dense leg contributes incomplete results, and the overall output is bounded by the fusion’s output size. If the system returns the top 100 fused results, the annotation is top_k(100).
A query plan has three leaves: (A) an exact warehouse table, (B) a top_k(100) vector search result, and (C) a sampled(0.01) analytics table. The plan computes A ⋈ B, then unions the result with C, then takes COUNT(*). Trace the completeness through each step and state what the final result means.
Step 1: A ⋈ B → exact ⊓ top_k(100) = top_k(100). Step 2: top_k(100) ∪ sampled(0.01) → top_k(100) ⊓ sampled(0.01). Since TOP_K = 2 and SAMPLED = 3 in our ordering, the meet is top_k(100). Step 3: COUNT(*) over top_k(100) → lower bound. The final count is a lower bound on the true answer, drawn from at most 100 items from the vector search leg plus whatever the sample contributed.
Completeness does not exist in isolation. Two interactions matter now and become formal in Chapter 6:
Completeness × Authorization: A result can be incomplete because of retrieval approximation (this chapter) or because of access control filtering (Chapter 5). Both produce the same symptom — missing tuples — but have different fix paths. The completeness annotation tracks the retrieval cause; the guarantee annotation tracks the authorization cause. When both apply, the result carries both, and the most restrictive one governs.
Completeness × Grain: A top-k retrieval at the chunk grain that is de-duplicated to the account grain changes the effective k. If 50 chunks map to 12 accounts, the effective coverage at the account grain is determined by whatever fraction of all accounts had at least one chunk in the top 50. This is unknowable without corpus statistics. The conservative choice: the completeness annotation does not improve through grain coarsening. top_k(50) at chunk grain remains top_k(50) after de-duplication to accounts.
Completeness class to handle the sampled case properly: when two sampled results are joined (inner join on a shared key), the output sample rate is approximately the product of the two rates (under independence). Implement this in the meet method as a special case when both inputs are sampled.QueryPlan tree where each node is an operator (Scan, Filter, Join, Aggregate) and each leaf carries a Completeness. Implement a propagate() method that walks the tree bottom-up and annotates every node. At Aggregate nodes, emit a warning if the input completeness is not exact, and include the bound direction in the annotation.completeness_with_freshness type that combines the spatial (top-k) and temporal (lag) dimensions of incompleteness.