USE CASE · SEMANTIC ANALYTICS

AGGREGATE THE
SEMANTIC SLICE.
ONE REQUEST.

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.

FOR·DATA / ML ENGINEER·ANALYTICS ENGINEER·AI PRODUCT
WHAT CAME BACK FROM ONE REQUEST · TERMS BUCKETS OVER THE RETRIEVED NEIGHBOUR SET · GENERATED 130-CONVERSATION CORPUS · NOT REAL DATA · NOT A BENCHMARK
band 2.4GHz
29
36%
band n/a · non-wifi (derived: 80 − 54)
26
33%
band 5GHz
25
31%

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 →

THE TWO-SYSTEM PROBLEM

THE XERJ ANSWER

1
REQUEST FOR THE WHOLE ANSWER
semantic slice · band split · per-band issue terms · csat percentile · handle-time average · example hits — one POST
1
SHARED RESULT ASSEMBLER
exact brute force and multi-knn both funnel through it; the HNSW executor is excluded from aggs-bearing kNN by the same exactness gate
130
GENERATED CONVERSATIONS IN THE DEMO
seeded script · ~21 hand-written templates · a correctness demo of the query shape, not a benchmark

ONE QUESTION, ONE ROUND TRIP.

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.

WHAT THE ENGINE DOES, AND WHAT IT COSTS.

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.

WHY THE SHAPE IS AWKWARD EVERYWHERE ELSE.

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.

ARRANGEMENT · REASONED, NOT BENCHMARKED
COLUMNAR
VECTORS
ONE STORE
OLAP engine + separate vector store
yes
yes
no
ETL between the two · no join across them · two systems to operate · the id list is the only thing that crosses the boundary
Postgres + pgvector
yes
yes
yes
kNN + GROUP 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 tier
Elasticsearch / OpenSearch
yes
yes
yes
the closest analogue on this axis — kNN + aggregations already supported in one request · heavier operations, JVM memory, and a hot-storage cost model
Warehouse + vector add-on
yes
bolt-on
partial
best-in-class SQL for the analytics half · embedding retrieval is a second-class citizen in an interactive report loop
Dedicated vector database
limited
yes
vectors
strong retrieval, thin columnar analytics · the SQL half still has to live somewhere else, which puts the ETL back
XERJ
yes
yes
yes
one index holds the columns, the text and the vector · knn + aggs in a single request (rc.6) · open gaps, stated below: aggs over an RRF-fused slice rejected (400), thin SQL surface, significant_terms over a kNN slice pending

Read 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.

DO THE SEMANTIC WORK ONCE, AT INGEST.

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.

WHAT THIS DOES NOT SHOW.

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.

01
THE CORPUS IS GENERATED.
130 conversations produced by a script with 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
NOTHING HERE WAS TIMED.
There are no latency, throughput, memory, concurrency or cost numbers on this page because the only wall-clock number in the source is the embed-and-index elapsed print in 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.
03
SIGNIFICANT_TERMS OVER A SLICE RETURNS EMPTY.
Not a mystery, a traceable gap: the kNN path calls the two-argument aggregation entry point, which forwards the same document set as both foreground and background — so there is no background corpus for a term to be over-represented against, and the result is empty. Use raw 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.
04
hits.total IS k, NOT A MATCH COUNT.
For a kNN query the total is the size of the retrieved neighbour pool, 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.
05
NO AGGS OVER A FUSED SLICE, AND THE SQL SURFACE IS THIN.
Reciprocal-rank fusion is exposed — 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.
06
ONE QUERY SHAPE IS STILL NOT COVERED.
rc.6 covers the shapes that funnel through the shared assembler: a top-level 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.
07
THE MODEL DOES THE SEMANTICS.
Retrieval quality is a property of whatever produced the vectors — here an external 768-dimension embedding model served locally — not of XERJ, which stores and searches them. Clustering and labelling quality is yours to own, and it is where the leverage is. Likewise the narrative sentence in the demo output: it came from a separate general-purpose model reading three retrieved examples, it is non-deterministic, and it is not a XERJ capability. The claim that belongs to the store is retrieval plus aggregation plus grounded evidence in one response.
08
THE ARCHITECTURE SECTIONS ARE GUIDANCE.
The comparison table is reasoned analysis and no competing system was run for it. The distance-to-canonical column is a recommendation: the knowledgebase index is created and populated by the demo and then never queried, so nothing about it is measured. Scaling, sharding and multi-tenant notes were not exercised — one node, two indices, no shards and no replicas. Reproducing the run needs the engine on its port plus a local embedding model and an LLM.

READ THE WALKTHROUGH.

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.

OPEN THE WALKTHROUGH OPEN THE RC.6 CHANGELOG
READY?·REQUEST ACCESS

RUN IT ON
YOUR DATA.

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.

We only use this email to follow up about XERJ — nothing else. No spam, no reselling. ✓ THANKS. WE'LL FOLLOW UP WITHIN ONE BUSINESS DAY.