A deep-research question bundles two workloads in one breath: of calls about wifi issues, how many are 2.4GHz vs 5GHz, and what are the key issues? The first clause is vector retrieval. Everything after it is aggregation over exactly the documents that clause selected — plus a handful of example records to ground the answer. Answering it has meant two systems, an OLAP engine for the aggregation and a vector store for the neighbours, with ETL between them and no join across them; or a two-step round trip, run the kNN, ship the ids back to the client, aggregate with a filter. XERJ answers it in one POST /_search, because the structured columns, the full text and the vector live in the same index — and because the rc.6 engine change runs the aggregations over the retrieved top-k neighbour set, before any from/size windowing. What follows is that feature, its deliberate cost, and its gaps. Every run shown below used a generated 130-conversation corpus, built by a seeded script from about 21 hand-written sentence templates: a correctness demo of a query shape, not real support data, not production scale, and nothing on this page was timed.
THE 80 IS THE k PARAMETER OF THE REQUEST, ECHOED BACK AS hits.total.value — IT IS THE SIZE OF THE RETRIEVED NEIGHBOUR POOL, NOT A COUNT OF MATCHING DOCUMENTS AND NOT A RETRIEVAL-QUALITY FIGURE. 54 OF THE 80 CARRIED A WIFI BAND. THE n/a ROW IS NOT IN THE PUBLISHED OUTPUT — THE DEMO SCRIPT FILTERS THAT BUCKET CLIENT-SIDE BEFORE PRINTING, AND 26 IS THE REMAINDER (80 − 54). THE SPLIT ITSELF IS OUTPUT OF A SEEDED GENERATOR (random.seed(7), ~21 HAND-WRITTEN TEMPLATES), NOT A FINDING ABOUT SUPPORT CALLS — WHAT THE CHART DEMONSTRATES IS THAT THE BUCKETS WERE COMPUTED OVER THE SEMANTIC SLICE IN THE SAME RESPONSE AS THE HITS · SOURCES IN docs/case-studies/calltree-analytics →
knn+aggs and query.knn+aggs both came back with no buckets. (A knn placed inside bool.filter still does — see the limits below.)keyword and integer columns, a text body and a dense_vector with cosine similarity — structured, full-text and vector in a single mapping.k neighbour set — for both the top-level knn section and the query.knn form — computed before the from/size hit page is cut.aggs-bearing kNN is deliberately gated out of the ANN path and routed to the exact brute-force scan, because ANN recall is under 100% and a bucket count must not be approximate.aggs-bearing kNN are both exact — the brute-force scan and the multi-kNN array form, which is brute force per clause; the ANN executor is gated out by the same exactness rule.size:3 returns example documents alongside the buckets; size:0 is a legitimate analytics-only shape that returns aggregations and totals with no hits — use the query.knn form for it, since the top-level knn section widens size to k.Read the question again and mark the clauses. "Of calls about wifi issues" is a kNN over a topic vector. "How many are 2.4GHz vs 5GHz" is a terms aggregation on a keyword column — but only over the documents the first clause returned. "What are the key issues" is a nested terms inside each band bucket. CSAT and handle time are percentiles and avg on integer columns in the same breath, and the report body needs a few real conversations to quote. Five primitives, one document set, one request.
$ curl -s "$XERJ/conversations/_search" -H 'Content-Type: application/json' \
-d '{"query":{"knn":{"field":"vec","query_vector":[ ...768 floats... ],
"k":80,"num_candidates":130}},
"size":3,"_source":["band","issue","body"],
"aggs":{"by_band":{"terms":{"field":"band"},"aggs":{
"key_issues":{"terms":{"field":"issue","size":4}},
"csat":{"percentiles":{"field":"csat","percents":[50]}},
"aht":{"avg":{"field":"handle_time_s"}}}}}}'
# the knn clause selects the slice · the aggs run over that slice · size:3 returns evidence
That is the actual request the demo script issues — a knn clause nested under query, with a sibling aggs block carrying nested terms, percentiles and avg. And here is what came back, with the provenance of every number written next to it rather than in a footnote, because most of them are properties of the generator and not of anything real.
# run against the GENERATED 130-doc corpus — a seeded script, ~21 hand-written sentences "of calls about wifi issues, how many 2.4 vs 5GHz, and what are the key issues?" hits.total.value = 80 # this is the k parameter echoed back, NOT a match count 2.4GHz 29 csat_p50=4.0 aht=897s issues: channel-overlap, congestion, disconnects, legacy-device 5GHz 25 csat_p50=4.0 aht=924s issues: range, disconnects, congestion n/a 26 # DERIVED, not printed: the script drops this bucket client-side, 26 is the remainder (80 - 54) # csat_p50 = 4.0 in both bands is the generator's median: csat is drawn from the # hand-written list [1,2,3,4,5,4,5,5,3], whose population median is 4 — so any # reasonably sized slice of this corpus lands on 4.0. # 897s vs 924s is uniform noise: handle_time_s = randint(120,1800), drawn independently # of band, issue and text. The 27-second gap means nothing. It is not an insight. # the per-band issue labels sit in the SAME template tuple as the sentence, so band and # issue are correlated by construction — the agg recovers what the script inserted.
The repository's own output block prints those two bands as 54% and 46%. Those are shares of the 54 band-carrying documents, computed client-side after the n/a bucket is filtered out — not shares of the 80 that were retrieved. And the demo's closing sentence, the one-line "deep-research narrative", came from an external LLM run over three example hits and truncated at 300 characters; it is non-deterministic, it will not reproduce verbatim, and it is not a XERJ output. XERJ's contribution to that loop is narrower and more defensible: the slice, the exact buckets, and the grounded example documents, in one response, so the model that writes the sentence is reading evidence rather than recalling.
The rc.6 change is small and its placement is the whole point. The shared kNN result assembler ranks the candidates, truncates the pool to k, and — if the request carries aggs — runs the aggregation over those top-k sources before the hit page is cut with from/size. Because every kNN executor funnels through that one assembler, there is no second implementation to drift — but the ANN executor is gated out of aggs-bearing requests by design, so the two paths that can actually serve one are both exact: the brute-force scan and the multi-kNN array form, which is itself brute force per clause. The slice aggregation is computed from the retrieved documents' _source, not from doc-values; the columnar path is the no-kNN recurring-report shape in the next section. hits.total.value for such a query is the size of the retrieved neighbour pool, min(k, matches), computed after the truncate — not the number of documents that carry a vector, and not a count of documents "about" the topic.
POST /conversations/_search
{ "query": { "knn": { "field":"vec", "query_vector":[ ... ], "k":200, "num_candidates":400 } },
"size": 0,
"aggs": { "by_band": { "terms": { "field": "band" } },
"csat_p50": { "percentiles": { "field":"csat", "percents":[50] } } } }
# size:0 is the analytics-only shape — aggregations and totals, no hits at all
# num_candidates is accepted but has NO effect here: an aggs-bearing knn runs the
# exact scan, and the brute-force executor does not take a fan-out parameter
The trade is deliberate, and it is a cost. An aggs-bearing kNN is explicitly excluded from the ANN eligibility condition and sent down the exact brute-force path. The reasoning in the engine is stated plainly: ANN recall is under 100%, so the retrieved neighbour set can differ slightly from exact — tolerable for ranked hits, not tolerable for analytics counts, and approximation must not leak into bucket counts. The consequence is that this path scans, so its cost grows with corpus size times dimensions on every request, it pays a per-document source clone on top of that scan, and the ANN fan-out knob does nothing for it. That is worth stating rather than hiding, and it is the reason this page carries no speed claim of any kind: no query in this demo was timed, at any corpus size.
What is verified, and exactly how far it goes: an integration test indexes five documents — four clustered near a topic vector and split two-and-two across two bands, plus one deliberately distant document — issues a k:4, size:0 kNN with a terms aggregation, and asserts that both band buckets hold two, that the far document's bucket is absent, that hits.total.value is 4, and that no hits come back. That is a proof the aggregation ran over the semantic slice rather than the whole index. It is not a proof of anything about scale, recall or speed, and this page does not present it as one. Two honest caveats about the scan gate. The engine only admits the HNSW path at 1,024 or more live vector documents, so at 130 documents in the demo — and at five in the test — every kNN was already running exact; neither run exercised the rule as an actual switch away from HNSW. And the exclusion is not a corpus-size effect: because the gate tests aggs.is_none(), the HNSW executor can never serve an aggregation-bearing kNN at any corpus size. That is the right design — approximation must not leak into bucket counts — but it means the ANN path is excluded by construction rather than merely unexercised.
The table below is reasoned architecture, not measurement. Nothing in it was benchmarked and no competing system was run — it is a map of where the friction sits in each arrangement when one question needs columnar aggregation and vector retrieval over the same document set. XERJ speaks the Elasticsearch wire so existing clients and tooling connect without a rewrite, but that compatibility is an adoption bridge, not the identity: the store is built for the workload where retrieval and analytics are the same query.
pgvectorGROUP BY works in one query, but scales poorly on large corpora · full SQL and one store · vector search and large analytical scans compete for the same OLTP engine · no object-store columnar tierRead that as a description of shapes, not a scoreboard. Every row on it is a defensible choice for some workload, and none of them was measured here. The narrow claim this page makes is bounded accordingly: Elasticsearch/OpenSearch and pgvector can already express this in one request, and what differs there is the cost model and the operational weight, not whether the shape is possible. The pipeline-or-two-step framing applies to the OLAP-plus-vector-store and warehouse arrangements only — and until rc.6, it applied to XERJ too.
The recommended architecture is the part that survives contact with scale, and it argues against reaching for kNN as often as you might. The expensive semantic work — embedding, clustering, issue labelling, distance to the nearest canonical record — is done once, by your model, when the document arrives, and materialized as ordinary columns on the document. Recurring reports then become plain columnar aggregations over those columns with no vector search in the request at all. kNN plus aggregations is kept for the ad-hoc question, the one nobody pre-computed a column for.
# ONCE, AT INGEST — your embedding model does the expensive part conversation --> embed(body) --> vec (dense_vector) --> cluster / issue label --> issue (keyword) --> distance to nearest canonical --> kb_distance (float) --> band, device, csat, handle_time --> columns (keyword / integer) # THEN, AT QUERY TIME — one index, three shapes of question recurring report --> terms / avg / percentiles on the label columns (no kNN) ad-hoc "calls about X" --> knn + aggs in a single request (rc.6) report body --> the hits returned beside the buckets --> your LLM
Under that arrangement, a question like "how far are we drifting from the canonical guidance" stops being a retrieval problem and becomes avg(kb_distance) grouped by issue, with the high-distance outliers reading as novel or uncovered topics — a straight aggregation over a column. Stated plainly, because the page will not claim what it did not run: that distance-to-canonical column is a design recommendation only. The demo builds a small knowledgebase index, and then no script ever queries it, so nothing about that half of the picture is measured. The same applies to the deployment guidance around it — sharding, one index per tenant versus a shared index with a customer filter, object-store-backed segments — which was reasoned, not exercised. The demo was a single node with two indices — conversations, and the kb index that no script ever queries — no shards, no replicas, no multi-tenancy.
The sizing intuition is worth keeping, though, because it follows from the engine behaviour above rather than from any measurement. k is the neighbour pool your aggregation runs over, so it is a product decision — how many conversations belong in the slice — rather than a tuning knob. num_candidates is the ANN fan-out for the plain retrieval path and is ignored once aggs are present. And because that path scans, the cheapest large-corpus move is to narrow before the vector step with an ordinary keyword or date filter, or to skip kNN entirely and aggregate the materialized labels.
A capability page is only worth reading if it marks its own edges. These are the limits of the evidence above and the gaps in the feature, in the order they would bite you.
random.seed(7) from
roughly 21 hand-written sentence templates, so every document body is a verbatim duplicate of
one of a handful of sentences and retrieval is easier than on real data. It is a correctness
demo of a query shape — not a dataset study, not real support conversations, not production
scale. Treat the band split, the CSAT median and both handle-time averages as generator
artifacts, which is exactly what they are: the CSAT list has a median of 4 by construction, and
handle time is a uniform draw unrelated to band, issue or text.
02_embed_index.py, which is one embedding round trip per document and
measures the embedding model, not XERJ. No query was ever timed, at any corpus size.
And the shape of the cost is known even though it was
not measured: the aggs path is an exact scan, so it grows with corpus size times dimensions on
every request, and the ANN fan-out parameter does nothing for it. The engine admits its HNSW path
only at 1,024 or more live vector documents, so at 130 documents in the demo — and five in the
integration test — every kNN already ran exact. The aggs-force-exact rule is real in code and is
the right call; neither run demonstrated it as an actual switch away from ANN. Nor could a larger
corpus: the gate excludes the ANN executor from every aggregation-bearing kNN at any size, so that
path is closed by construction, not by the demo being small.
terms counts inside the slice, or the two-step pattern (kNN, collect
ids, aggregate with the full index as background) when you need statistical significance.
min(k, matches).
The demo asked for 80 and got 80 back — that number describes the request, not the corpus. Of those
80 neighbours, 54 carried a wifi band and 26 did not, which is the honest way to read the slice:
a nearest-neighbour pool always fills to k, whether or not there are
k good matches. That 54-of-80 wifi share is the only retrieval-quality
number here — a precision figure in all but name, and on 21 duplicated sentences it measures the
generator, not the model. The source also reports 74 wifi in the nearest 100 from a separate run;
neither is a benchmark, and no recall figure exists at all.
query.hybrid with
queries:[…] and fusion:"rrf" (default
k=60, overridable) — but a hybrid query that carries
aggs is rejected with a 400
(aggregations are not supported with hybrid/fusion queries), because a bounded aggregation
over a fused top-N would silently under-count. So the one-request "aggregate the slice" shape on
this page works for a pure knn slice only, not for a lexical-plus-vector
fused one; for hybrid you still run the aggregation as a second, non-hybrid search. The SQL layer is a
thin convenience mapping — roughly SELECT/WHERE/GROUP BY
onto terms and counts — so do not plan on porting warehouse SQL one to
one. The aggregation API is the real interface; generate its JSON.
knn section, a query.knn, a
bool whose single must/should
clause is the kNN (extra filter clauses are ANDed in), and the
multi-knn array form. A kNN placed inside
bool.filter is not peeled — the peeler collects candidates from
must and should only — so it never reaches
the kNN executor and still returns no buckets. Move the clause to
must and put the ordinary predicates in
filter.
The full write-up is published with the three scripts that produced everything above — the dataset generator, the embed-and-index step, and the single-request deep-research query — plus the mapping, the request shapes, the recommended ingest architecture, and the same generated-corpus provenance and limitations restated in the source's own words. The engine change ships with its changelog entry and its integration test. Read both before deciding what this is worth for your workload; the point of stating the gaps this plainly is that the parts that do hold should be usable without a second opinion.
Send us the deep-research question you actually get asked and the columns you already have — the labels, the scores, the timestamps, the embedding model you use. We'll come back with the mapping, the single-request query shape that answers it, and a straight account of which parts of it we have measured.