The trunk: an indexed word
One indexed token prunes the corpus before the engine reads anything. The trunk holds one control for its whole branch — the word llm, Hacker News, the twenty closed months from January 2025 through August 2026 — and every child adds one construct to its parent’s query without touching that control, so the difference between a child’s clock and its parent’s is the construct and nothing else. Group the matches, bucket them by month, derive a key from the text, nest the buckets, rank inside them, take a difference: the rows the engine reads stay at the anchor’s 6.2 M for most of the branch, and the clock says what each step did with them.
- 6.2 M rows
token-anchorCount before you readSELECT count() AS hits FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') LIMIT 10.47 s (median of three) over 20 months of hackernews.items, 6.2 M rows read, 131,838 hits — as measured
You are about to ask twenty months of Hacker News about LLMs and have no idea whether the term matches three hundred items or three hundred thousand. A retrieval query pays for ordering an unknown match set and finds out the hard way. A count settles the size of the question before anything else is spent on it.
technique The count-only probe runs the WHERE clause and nothing else: a closed time window on both ends and one lowercase token against the indexed search_text_lc column. 131,838 hits is the denominator every child of this node divides by. hasToken engages the token index; a bare LIKE over the same column is refused by the validator, and a token with uppercase letters or punctuation never matches. The LIMIT 1 is the bounded-statement idiom the API expects on every query.
why fast 6.2 M rows and 2.1 GB read for the whole window: the token index discards granules that cannot hold the term, and the survivors are read as one column, search_text_lc, in PREWHERE. No title or body is decompressed, no row is sorted, and the aggregate state is a single counter, so the time is the scan of the anchored granules and nothing more. Every entry below reports its rows read against this 6.2 M.
- anchor How are the candidate documents found?
- 6.1 M rows
+ all-tokens-anchorHow much LLM talk is about running models locallySELECT count() AS hits FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') AND hasAllTokens(search_text_lc, ['local', 'llm']) LIMIT 12.24 s (median of three) over 20 months of hackernews.items, 6.1 M rows read, 5,656 hits — as measured
One token finds the LLM discourse; the analyst wants the slice of it about local inference, the ollama-and-a-GPU corner where cost, privacy and hardware get argued. A second token must be required, not merely allowed, and it must still come out of the index rather than a scan of every matched body.
technique hasAllTokens(column, [t1, t2]) is the conjunctive anchor: every listed token must be present, and each is looked up in the same token index hasToken uses. The parent's hasToken line stays and the array repeats 'llm', so the child's token set is a superset of the parent's, which is the rule the tree checks. 5,656 of the 131,838 LLM items (4.3%) also say 'local'. Tokens are order-free: this counts co-occurrence anywhere in the document, which is why the next entry exists.
why fast 6.1 M rows and 2.1 GB read against the parent's 6.2 M: the second token lets the index drop the few granules that hold 'llm' without 'local', and everything else is the same PREWHERE scan of search_text_lc with a single counter behind it. The median elapsed landed at 2.2 s on a loaded box against the probe's 0.47 s; rows read, not seconds, is the cost signal, and it moved by 2%.
- 5.7 M rows
+ phrase-refineCo-occurrence is not the phraseSELECT count() AS hits FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') AND hasAllTokens(search_text_lc, ['local', 'llm']) AND payload ILIKE '%local llm%' LIMIT 12.60 s (median of three) over 20 months of hackernews.items, 5.7 M rows read, 2,245 hits — as measured
5,656 documents contain both 'local' and 'llm'. Many of them are a local variable near an LLM, or a local election in a thread about models. The analyst wants the phrase 'local llm' as people write it, adjacent and in order. The token index stores no positions, so adjacency has to be checked on the raw text, and only after the index has done its narrowing.
technique The residual predicate: payload ILIKE '%local llm%' runs only on rows the two token anchors already passed, and tests the thing the index cannot, order and adjacency. It has to be payload, the raw text column; the validator refuses LIKE over search_text_lc because an unanchored substring scan of the indexed column is the road to the timeout. Story payload carries the title, so titles are covered too. The phrase survives in 2,245 of the 5,656 co-occurrences: token conjunction overcounted the topic by 2.5x.
why fast 5.7 M rows read against the parent's 6.1 M, but 3.3 GB against 2.1 GB: the extra work is decompressing payload for granules that passed both token anchors and running a case-insensitive substring search over those bodies. That pass is bounded by the anchored set, never the window; that bound is why the residual sits behind the index. The rows-read dip is the engine's condition cache skipping granules where an earlier execution found no survivor; the first run of a residual this selective reads the parent's full set.
- scope Within what field or time domain?
- 3.9 M rows
+ interval-windowA window that follows the calendarSELECT count() AS hits FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') AND original_timestamp >= now() - INTERVAL 1 YEAR LIMIT 11.08 s (median of three) over the trailing year inside the 20-month control on hackernews.items, 3.9 M rows read, 88,559 hits — as measured
The control window is fixed literals, which is right for a measurement that must reproduce. A saved check that an agent reruns every week means something else: the last twelve months as of today. The analyst wants to know how much of the twenty-month LLM discourse falls inside the trailing year, and wants that number to keep being true tomorrow.
technique now() - INTERVAL 1 YEAR is a relative bound added beside the literal ones; the engine takes the intersection. This is a scope construct and it tightens the window: on 2026-09-07 the effective range is 2025-09-07 to 2026-08-31, holding 88,559 of the 131,838 hits, 67% of the discourse in the most recent 60% of the months. Rerun it next month and the range slides while the literal control stays put, so the count shrinks; that drift is the construct working, and it is why the fixed trunk uses literals. INTERVAL arithmetic composes: toStartOfMonth(now()) - INTERVAL 1 MONTH names the last full calendar month.
why fast 3.9 M rows and 1.3 GB read against the parent's 6.2 M and 2.1 GB: the relative bound folds to a constant before planning, so the primary key prunes the first eight months of granules exactly as a literal date would. Per surviving row the work is unchanged, one token test in PREWHERE and a counter.
- qualifier Which candidates, or which groups, pass a further condition?
- 2.1 M rows
+ quorum-gateThree words from the agent vocabulary make a topicSELECT count() AS hits FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') AND arrayCount(t -> has(['agent', 'tool', 'harness', 'sandbox', 'orchestration', 'subagent', 'eval'], t), arrayDistinct(tokens(search_text_lc))) >= 3 LIMIT 10.22 s (median of three, repeated executions) over 20 months of hackernews.items, 2.1 M rows read; a first execution reads 6.2 M rows, 363 hits — as measured
The agentic-engineering conversation inside the LLM discourse has no single token. OR over its vocabulary matches everything near the topic; AND over it matches almost nothing. The professional-search middle is the quorum: at least k of n terms present. The analyst wants the LLM items where the agent stack is actually being engineered, not named.
technique Sphinx's quorum operator as a residual: tokens() splits the document, arrayDistinct makes it a set, has() tests each token against the vocabulary, arrayCount tallies the hits, and the gate asks for three. The anchor stays in front so the tokenizing pass never runs on the window, only on the 131,838 anchored rows. Three of seven leaves 363 items; two leaves 2,711, four leaves 37. Drop 'agent' from the list and k=3 leaves 54, which says the vocabulary carries the topic as a set, not through one word.
why fast The first execution of a fresh quorum predicate over this anchor reads the parent's full 6.2 M rows and tokenizes every anchored document once; measured on a variant vocabulary, that cold run took 4.0 s. The number recorded here is a repeated execution: 2.1 M rows and 701 MB, because ClickHouse's query condition cache remembers, per granule, that this predicate found nothing there and skips those granules on the next run. A gate this selective (363 of 131,838) leaves most granules empty, so the cache removes two-thirds of the scan. Per surviving row the work is a tokenize, a dedup and seven set tests.
- 6.2 M rows
+ term-frequencyAbout LLMs, not just mentioning themSELECT count() AS hits FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') AND countMatchesCaseInsensitive(search_text_lc, 'llm') >= 5 LIMIT 10.72 s (median of three) over 20 months of hackernews.items, 6.2 M rows read, 5,291 hits — as measured
Membership is generous: a two-thousand-word comment that says LLM once in passing counts the same as an essay about them. Topical documents repeat their subject and drive-by mentions do not. The analyst wants the items that are about LLMs, and wants the ratio to the plain count, which is the corpus's name-drop rate for the term.
technique countMatchesCaseInsensitive(column, pattern) counts occurrences of a pattern in a string; gating it at k is Westlaw's ATLEASTn as one predicate. The pattern is a regular expression, so 'llm' also counts 'llms' and 'llm-based', which is the right behavior for aboutness. Five mentions leave 5,291 of 131,838 items, 4.0%; two leave 54,211, ten leave 387. Choose k by reading samples at each level: k=2 is a hint, k=5 is a discussion, k=10 is an essay.
why fast The same 6.2 M rows and 2.1 GB as the parent; the token index cannot see a count, so the granule set is identical and the difference is inside run-to-run variance. The added work is one regex pass over search_text_lc per anchored row, which the engine runs in the same PREWHERE step as the token test.
- statistic What summary is computed across documents or events?
- 6.2 M rows
+ adaptive-histogramThe shape of LLM story scores without choosing binsSELECT histogram(8)(toFloat64(upvotes)) AS upvote_shape FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') LIMIT 10.73 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
A bucketed histogram needs a bin width, and on an unfamiliar distribution the wrong guess hides the shape. The analyst wants to see how LLM story scores are distributed, from a one-point Show HN to a 1,773-point essay, before deciding where the interesting range is.
technique histogram(n)(x) returns [(lo, hi, height)] tuples with data-chosen edges. Under the control the first bin, 1 to 64 points, holds 9,747 of the 11,438 scored items; 64 to 189 holds 1,568; the six bins above that hold 122 between them, up to the single story at 1,773. The power law announces itself with no binning decision. NULL upvotes on comments are skipped, so this is the story distribution without a type filter. Heights are streaming-merge estimates and arrive fractional; read the shape, then graduate to intDiv buckets once you know where to look.
why fast The same 6.2 M rows as the probe with upvotes decompressed alongside, 2.2 GB against 2.1 GB. The sketch keeps a fixed set of bins and merges a point into one of them per row: one pass, constant memory, no sort of the 11,438 values.
- 6.2 M rows
+ significance-testDoes putting LLM in the title change the scoreSELECT mannWhitneyUTest(toFloat64(upvotes), if(positionCaseInsensitive(title, 'llm') > 0, 0, 1)) AS title_vs_body FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') LIMIT 10.96 s (median of three) over 20 months of hackernews.items, 6.2 M rows read, p = 3.4e-5 — as measured
Stories that name LLMs in the title look like they score about the same as stories that mention them only in the body, and eyeballed sameness has fooled everyone forever. Scores are power-law noise. The honest question is whether the two populations differ under a rank test, and that test can run inside the query.
technique mannWhitneyUTest(value, group) returns (u_statistic, p_value) for the rank-sum test; nonparametric, so power-law scores do not break it the way a t-test's normality would. The group is any 0/1 expression: positionCaseInsensitive on the raw title splits the 7,548 title-mention items from the 3,890 body-only ones, and comments drop out on their NULL upvotes with no type filter. Result: p = 3.4e-5. The means are 9.1 and 9.0 and both medians are 2; the difference is in the upper tail, where body-only stories reach a 90th percentile of 8 points against 6. With 11,438 samples the test resolves a shift the summary statistics cannot show, which is exactly why it belongs in the query.
why fast The same 6.2 M rows as the probe, with title and upvotes decompressed for the anchored granules, 2.2 GB against 2.1 GB. The test accumulates the two samples in one streaming pass and ranks them once at the end, 11,438 values, so the added work is one substring test per row and a single sort of the scored items.
- 6.2 M rows
+ top-k-sketchWho does the talking about LLMs, in one rowSELECT topK(8)(original_author) AS loudest_voices FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') LIMIT 10.98 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
Before committing to a per-author histogram the analyst wants one glance: which handles dominate the LLM conversation on Hacker News? A GROUP BY over 131,838 items by author holds tens of thousands of groups to answer a question whose answer is eight names.
technique topK(n)(value) is an approximate heavy-hitters sketch (the SpaceSaving algorithm): one row, the n most frequent values, no GROUP BY. Under the control it returns simonw, Aurornis, ACCount37, tptacek, embedding-shape, TeMPOraL, simianwords and HarHarVeryFunny. The sketch is for orientation: two runs can swap adjacent names, and it carries no counts, so graduate to GROUP BY original_author when the numbers need to be quoted.
why fast The same 6.2 M rows as the probe, plus original_author decompressed for the anchored granules, 2.3 GB against 2.1 GB. The sketch holds a fixed number of counters (a small multiple of eight) and updates one per row in a single streaming pass, so the state never grows with the number of distinct authors and nothing is sorted.
- 6.2 M rows
+ weighted-sketchWho talks about LLMs versus who scoresSELECT topK(8)(original_author) AS loudest_voices, topKWeighted(8)(original_author, toUInt64(greatest(upvotes, 0))) AS most_rewarded FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') LIMIT 10.88 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
topK ranks by volume, and volume rewards the most prolific commenters. The analyst wants the second list beside the first: the handles whose LLM items readers actually scored. The two lists together are the editorial judgment, and the point is the gap between them.
technique topKWeighted(k)(value, weight) is topK where each row votes with a weight instead of one; greatest(upvotes, 0) floors negative scores and toUInt64 gives the sketch the integer it requires. Comments carry NULL upvotes in this export, and aggregates skip NULL arguments, so the weighted list is drawn from the 11,438 scored items while the plain list covers all 131,838. That asymmetry is the finding: the loudest voices are commenters (simonw, Aurornis, tptacek); the most rewarded are submitters (SwoopsFromAbove, gpjt, vuciv), and only embedding-shape appears on both lists.
why fast The same 6.2 M rows as the parent, with upvotes decompressed beside original_author, 2.3 GB against 2.3 GB; the difference is inside run-to-run variance. Both sketches run in the one streaming pass, and the weighted one costs a multiply and the same fixed counter table per row.
- shape How are rows grouped, expanded, ordered, selected, or windowed?
- 3.9 M rows
+ row-retrieveThe twenty LLM items Hacker News scored highestSELECT title, upvotes FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') ORDER BY upvotes DESC LIMIT 200.76 s (median of three) over 20 months of hackernews.items, 3.9 M rows read — as measured
The count says 131,838 items mention LLMs. That is a size, not a reading list. The analyst's next question is what the community rewarded most inside that set: the actual titles, in score order, on one page. A count cannot show a single title; retrieval is the first step that puts text in front of a reader.
technique This is the count-to-rows transition, the one place in the tree where the projection is replaced rather than extended: count() gives way to the raw text column and its score, and ORDER BY upvotes DESC LIMIT 20 chooses which twenty of the 131,838 to show. The control lines are byte-identical to the probe. Comments carry NULL upvotes in this export and sort last, so the page is stories; the schema warns that scores are first-fetch snapshots, so treat the order as a strong signal, not a leaderboard. A text column named at the top level of the outermost SELECT is what marks an entry as retrieval; any(title) inside an aggregate does not.
why fast 3.9 M rows and 1.1 GB read, against the probe's 6.2 M and 2.1 GB. The anchor still runs in PREWHERE over search_text_lc, and title is decompressed only for granules that hold survivors. ORDER BY with a LIMIT keeps a bounded top-20 while blocks stream past it instead of sorting the 131,838-row match set, so the ordering costs a comparison per row and twenty rows of state. The page is twenty titles; the work is the anchored scan plus that heap.
- 6.2 M rows
+ limit-byThe top two LLM items of every kindSELECT hn_type, title, upvotes FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') ORDER BY upvotes DESC LIMIT 2 BY hn_type0.60 s (median of three) over 20 months of hackernews.items, 6.2 M rows read, 9 rows — as measured
The global top twenty is all stories, because stories are where scores live. The analyst wants the best of each kind on one page: the two highest-scored stories, the two polls, the two job posts that mention LLMs. That is a different operation from a global cutoff, and most engines make you build window machinery for it.
technique LIMIT k BY key is ClickHouse's native group-wise top-k over raw rows: order first, then keep k per key. It replaces row_number() OVER (PARTITION BY hn_type) plus a subquery with one clause. The projection adds hn_type so the reader can see which key each row belongs to; the parent's trailing LIMIT 20 is dropped because the API's parser refuses a second LIMIT after LIMIT BY, and with five hn_type values the per-key cap already bounds the page at ten rows. The result is nine: two stories, two polls, two jobs, one pollopt and two comments, whose NULL upvotes and empty titles show on the page exactly as the export holds them.
why fast 6.2 M rows and 2.3 GB read against the parent's 3.9 M and 1.1 GB. A global LIMIT 20 can keep a bounded heap; a per-key cut needs the ordered match set before it knows which two rows each hn_type keeps, so the anchored granules are read in full, hn_type is decompressed beside title and upvotes, and the 131,838 survivors are sorted once before the cut streams past them. State beyond the sort is two rows per key.
- 6.2 M rows
+ group-byOne count becomes a count per kindSELECT hn_type, count() AS n FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY hn_type ORDER BY n DESC LIMIT 100.89 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
The probe says 131,838 documents mention LLMs in twenty months. It cannot say whether that is a corpus of submissions or of arguments under them, and the two read differently: a story is a claim, a comment is a reaction. The first pivot any analyst makes is from a total to a total per record kind.
technique GROUP BY turns the probe's single count into one count per distinct key; ORDER BY n DESC LIMIT 10 ranks the groups. The answer is five rows: 120,400 comments, 11,424 stories, 11 jobs, 2 polls, 1 poll option. Nine in ten LLM documents on Hacker News are reactions, not submissions. Every child under this node keeps the count and the ranking idiom and swaps only the key.
why fast The same 6.2 M rows as the probe, one more column: 2.25 GB against 2.15 GB, the difference being hn_type. Per row the engine hashes a low-cardinality key into an aggregate state that holds five groups; sorting five rows is nothing. The 0.89 s against the probe's 0.47 s is inside the spread this table shows between runs of identical text.
- qualifier Which candidates, or which groups, pass a further condition?
- 6.2 M rows
+ havingFilter the groups, not the rowsSELECT original_author, count() AS n FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY original_author HAVING n >= 30 ORDER BY n DESC LIMIT 101.21 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
WHERE sees rows; a regular is defined on the aggregate: an author with at least thirty LLM posts in the window. That predicate exists only after grouping, and it is the cohort every subsequent regulars analysis starts from.
technique HAVING runs after aggregation and sees aggregates by alias. The gate turns 35,079 authors into 553 regulars, and those 553 wrote 32,396 of the 131,838 matching items, a quarter of the LLM conversation from 1.6 percent of its voices. This is the doorway to the quantifier family: at-least-k, never, and present-in-all are all HAVING gates over grouped state, and the two children of this entry add exactly one each.
why fast The same 6,235,069 rows and 2.26 GB as a per-author group-by; the gate runs over the 35,079 grouped rows after the scan, one integer comparison each, and shrinks the sort to 553 rows. The three runs spread from 1.16 to 2.08 s under production load; rows read is the stable cost and it equals the parent's.
- qualifier Which candidates, or which groups, pass a further condition?
- 6.2 M rows
+ anti-quantifierAbsence as a result setSELECT original_author, count() AS n FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY original_author HAVING n >= 30 AND max(hasToken(search_text_lc, 'agent')) = 0 ORDER BY n DESC LIMIT 100.73 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
Who talks about LLMs at volume and never once says agent? Never is a claim about every row an author wrote, universally quantified absence, and no row-level filter can state it. Typed as an aggregate over the group it is one more term in the gate.
technique max(P) = 0 in HAVING types never-P: over a group of 0/1 predicate values the maximum is zero exactly when no row satisfies P. The floor of thirty posts keeps the cohort meaningful; without it the never-set fills with one-post drive-bys. 166 of the 553 regulars never say agent, led by latexr (139 LLM posts) and crazygringo (136). Never is downward-monotone as ingest continues, so state it over a closed window, as here. max alone reads as this construct; adding min beside it would read as span-derive.
why fast The same 6,235,069 rows and 2.26 GB as the parent. The addition is one token test per matched row, folded into a one-byte max per group, and one more comparison at gate time over 35,079 groups; nothing is rescanned to establish absence. The three runs spread from 0.43 to 2.22 s under production load; rows read is the stable cost and it equals the parent's.
- 6.2 M rows
+ divisionPresent in every venue: relational divisionSELECT original_author, count() AS n FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY original_author HAVING n >= 30 AND uniqIf(hn_type, hn_type IN ('story', 'comment')) = 2 ORDER BY n DESC LIMIT 102.59 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
Which regulars both submit LLM stories and argue in the comments? Present-in-all is relational division, the query textbooks solve with a double NOT EXISTS. Over grouped rows it is a distinct count under a condition, compared to the size of the required set.
technique uniqIf(value, condition) counts distinct values among the rows that pass the condition; requiring it to equal the size of the required set is the universal quantifier. Two is the full set here (story and comment), so the gate keeps authors present in both. 121 of the 553 regulars qualify; simonw leads at 719, and the ranking shifts visibly against the plain regulars list (fragmede and embedding-shape rise, Aurornis and TeMPOraL drop out as comment-only voices). Loosen = k to >= k for present-in-at-least-k. count(DISTINCT if(cond, value, NULL)) is the exact spelling of the same test.
why fast The same 6,235,069 rows and 2.36 GB as a per-author group-by that reads hn_type. The state is one small distinct sketch per group over at most four values, and the gate is one equality over 35,079 groups; there is no second relation and no per-author subquery. The three runs (2.34 to 2.72 s) landed in a loaded interval; rows read is the stable cost and it equals the parent's.
- derivation What value is computed from a document, an array, or a row?
- 15.1 M rows
+ case-classifyGroup by your own periodizationSELECT multiIf(original_timestamp < '2022-11-30', 'before-chatgpt', original_timestamp < '2023-11-30', 'chatgpt-year', original_timestamp < '2025-01-01', 'model-race', 'control-window') AS era, count() AS n FROM hackernews.items WHERE original_timestamp >= '2020-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY era ORDER BY n DESC LIMIT 100.37 s (median of three) over hackernews.items from 2020-01 to 2026-08, the window widened from the control's 2025 start so that a pre-ChatGPT era exists; 15.1 M rows read — as measured
Calendar months are arbitrary where history is not. The boundaries an LLM analyst cares about are events: before ChatGPT, the year after it, the model race of 2024, and the twenty months this tree measures. A GROUP BY over eras you define needs the dimension built inline from the timestamp.
technique multiIf is the inline classifier: conditions read top-down, the first true one names the row, the last argument is the default. The eras compare original_timestamp against date literals, never a calendar function, so the derived key is the classification alone. This is the one node in the slice that widens the window: the control starts in 2025 and has no pre-ChatGPT era to classify, so the start moves to 2020-01-01 with the end and the term unchanged. Four groups over 191,965 documents: 170 before ChatGPT, 23,958 in the ChatGPT year, 35,999 in the model race, 131,838 in the control window. The last row reproduces the trunk's count exactly, which is the check that the classifier and the control agree.
why fast 15.1 M rows and 5.27 GB, 2.4x the parent's 6.2 M rows, because the window is 80 months instead of 20; the wider anchor also reads 1.46x the documents. Per row a short chain of date comparisons and a hash into a state holding four groups. The 0.37 s median against a 2.55 s cold first run of the same text says the cost is I/O warmth, not the classifier.
- 6.2 M rows
+ value-bucketsBin the score, then count the binsSELECT intDiv(upvotes, 100) * 100 AS bucket, count() AS n FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') AND hn_type = 'story' GROUP BY bucket ORDER BY n DESC LIMIT 100.58 s (median of three) over 20 months of hackernews.items stories, 6.2 M rows read — as measured
How high do LLM stories climb? GROUP BY upvotes gives one group per distinct number, a list nobody can read. The shape of attention appears only when the score is folded into bins of a hundred first. Comments carry no score on this table, so the question is asked of stories.
technique intDiv(x, 100) * 100 floors each score to its bin; the bin is the group key. The added predicate hn_type = 'story' is a plain filter, not a construct: without it the top group is NULL, because all 120,400 comments have no score. Of 11,424 LLM stories, 11,169 sit at 0-99 points, 149 at 100-199, 48 at 200-299, 21 at 300-399, then 16, 10, 5, 3, 2 and a single story above 1,700. Only 255 stories cross 100 points. Ordering by n reads the attention curve top-down; ordering by bucket would read it left-to-right.
why fast 6.20 M rows against the parent's 6.24 M; the story predicate trims 0.5 % and the rest is the same scan. Bytes rise to 2.27 GB because upvotes is now read alongside hn_type. Per row one integer division and a hash into a state holding ten groups, the whole histogram in one pass.
- 6.2 M rows
+ extract-allCount every match, then group by the countSELECT length(arrayDistinct(extractAll(search_text_lc, '\\b(?:gpt|claude|gemini|llama|mistral|deepseek|qwen|grok)\\b'))) AS families, count() AS n FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY families ORDER BY n DESC LIMIT 100.62 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
The first match answers which model a document names. Some questions are about multiplicity: how many LLM discussions put two or more vendors side by side, and how many mention one in passing? That needs every match per document, deduplicated and counted, before the GROUP BY.
technique extractAll returns every match of the pattern as an array; arrayDistinct drops repeats, length turns the array into a breadth measure, and that integer is the group key. Eight model families, word-bounded so llama does not fire on llamas-of-text noise. 117,562 documents name none of them, 11,658 name exactly one, 1,937 name two, 490 three, 151 four, then 29, 8, 2 and a single document that names all eight. Cross-vendor comparison is 2,618 of 131,838 LLM documents, one in fifty.
why fast The same 6.2 M rows and the same 2.15 GB as regex-extract: the key is again derived from the anchored text column alone. Per row the regex now runs to the end of the document instead of stopping at the first hit, then a small array is deduplicated and measured. Nine groups held. At 0.62 s against regex-extract's 1.33 s, the difference is inside the run-to-run spread of this table, not evidence that scanning to the end is free.
- 6.2 M rows
+ sim-hashFold near-identical stories by fingerprintSELECT ngramSimHash(search_text_lc) AS fingerprint, count() AS copies, any(title) AS specimen FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') AND hn_type = 'story' GROUP BY fingerprint ORDER BY copies DESC LIMIT 100.84 s (median of three) over 20 months of hackernews.items stories, 6.2 M rows read — as measured
Reposts and mirrors inflate a topic's apparent size. Exact-string GROUP BY misses them, one edited character breaks the group, and pairwise comparison across 11,424 stories is not a query anyone runs. A locality-sensitive fingerprint as the group key finds the clusters in one pass.
technique ngramSimHash folds a text into a 64-bit fingerprint on which near-identical texts collide, so a plain GROUP BY becomes a near-duplicate detector. count() is named copies; any(title) picks one specimen per cluster and stays inside an aggregate, so the projection is not row retrieval. The story predicate keeps the specimens readable. 11,424 LLM stories fold to 11,036 fingerprints: 304 clusters hold more than one copy, 388 stories are surplus. The largest cluster is six submissions of 'Stealing Reasoning Traces from Proprietary LLM APIs'; three clusters of five follow.
why fast 6.20 M rows against the parent's 6.24 M, the story predicate trimming 0.5 %; 2.27 GB with title read for the specimen. Per row the engine hashes the document's n-grams into one 64-bit value and hashes that into a state holding 11,036 groups, plus one title kept per group. No pair of stories is ever compared; that is why this runs in 0.84 s on the same scan as the parent.
- 6.2 M rows
+ domain-extractFollow the outbound links to their hostsSELECT domain(outbound_url) AS site, count() AS n FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') AND outbound_url != '' GROUP BY site ORDER BY n DESC LIMIT 101.00 s (median of three) over 20 months of hackernews.items with a link, 6.2 M rows read — as measured
What a community discusses and what it links are different maps. Submission URLs carry the second one: which sources feed the LLM conversation. Raw URLs are too granular to count, so the host has to be derived from each one before the GROUP BY.
technique domain() parses the host out of a URL; the host is the group key. The plain predicate outbound_url != '' restricts the count to the 9,390 linked items, 9,382 of them stories. Top of the evidence diet: github.com 3,014, arxiv.org 442, twitter.com 109, www.youtube.com 101, medium.com 91. A third of every LLM link on Hacker News points at a repository. The www prefix survives because domain() keeps it; cutToFirstSignificantSubdomain would fold it.
why fast 6.22 M rows against the parent's 6.24 M; the link predicate trims 0.2 % and the rest is the same scan. 2.23 GB, outbound_url read in place of hn_type. Per row one URL parse, and the aggregate state holds 3,967 distinct hosts, the first node in the slice where the group count is in the thousands; ten of them are sorted out at the end.
- 6.2 M rows
+ regex-extractThe version number is inside the proseSELECT regexpExtract(search_text_lc, 'gpt[- ]?([0-9]+(?:\\.[0-9]+)?o?)', 1) AS version, count() AS n FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY version ORDER BY n DESC LIMIT 101.33 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
Nobody tagged which GPT generation a comment argues about; the version lives in the text as gpt-4o, gpt 5, gpt3.5. A capture group lifts it out and makes it a group key, so the LLM discourse can be counted by the model it names.
technique regexpExtract(text, pattern, 1) returns the first capture group of the first match, or the empty string. The pattern accepts a hyphen, a space or nothing between gpt and the number, a dotted minor version, and a trailing o. The empty match is kept as its own group rather than filtered: it is the denominator. 129,751 of 131,838 LLM documents never name a GPT version; among the 2,087 that do, 5 leads with 445, then 4o at 367, 4 at 336, 3 at 156, 2 at 150, 3.5 at 108, 5.2 at 100, 5.5 at 92, 4.1 at 72. Forty-four distinct strings in all. The backslash before the dot is doubled in the SQL text because ClickHouse unescapes string literals once.
why fast The same 6.2 M rows as the parent and fewer bytes, 2.15 GB against 2.25 GB: the key comes from search_text_lc, which the anchor already reads, so hn_type stays on disk. The work moved into the row: one regular-expression scan of every matched document, up to the first hit, then a hash into a state holding 44 groups. That per-row scan is why this is the slowest node in the slice at 1.33 s with all three runs between 1.18 and 1.34 s.
- 6.2 M rows
+ top-level-domainThe suffix is a register signalSELECT topLevelDomain(outbound_url) AS tld, count() AS n FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') AND outbound_url != '' GROUP BY tld ORDER BY n DESC LIMIT 101.46 s (median of three) over 20 months of hackernews.items with a link, 6.2 M rows read — as measured
The host answers which sites; the suffix answers what kind of source a conversation cites. An .ai-heavy link profile and an .org-heavy one are different registers of evidence, and the suffix is a far smaller key than the host.
technique topLevelDomain() strips a URL to its suffix, and the suffix is the group key over the same 9,390 linked items. com 5,865, org 763, ai 571, io 474, dev 309. Six in ten LLM links are .com; .ai alone outnumbers .io, and the .org share is largely arxiv. This node sits beside domain-extract, not under it: both derive a key from outbound_url and the detector keys on the function name, so the SQL contains topLevelDomain and never the bare lowercase domain call.
why fast Identical rows and bytes to domain-extract, 6.22 M rows and 2.23 GB: the same predicate, the same URL column. The aggregate state shrinks from 3,967 hosts to 184 suffixes. The 1.46 s median with runs of 0.66, 1.81 and 1.46 s is the box's spread; the smaller state is not visible at this scale.
- 6.2 M rows
+ array-expandOne row per token, then count the tokensSELECT arrayJoin(tokens(search_text_lc)) AS t, count() AS n FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') AND length(t) > 3 GROUP BY t ORDER BY n DESC LIMIT 100.45 s (median of three) over 20 months of hackernews.items, 6.2 M rows read and 8.3 M token rows grouped — as measured
Every key so far was one value per document. The vocabulary of the LLM discourse is many values per document, and counting it means expanding each row into one row per token before the GROUP BY. This is the most expensive shape in the slice, and the top of the list is stopwords, which is the lesson.
technique tokens() splits the text into an array; arrayJoin expands the array so each element becomes its own row, and the element is the group key. The plain WHERE length(t) > 3 refers to the expanded alias and drops short tokens after the expansion. 14.4 M tokens in 131,838 documents, 8.3 M of them longer than three characters, land in 141,072 groups. The ranking is that, with, this, have, they: the corpus's connective tissue outranks its subject. Reading the vocabulary needs a filter before the expansion, which is the child.
why fast The same 6.2 M rows and 2.15 GB as the anchored text scan; the key is derived from search_text_lc alone. The work is in the expansion: 8.3 M token rows hashed into a state holding 141,072 groups, the largest state in the slice by three orders of magnitude, then ten sorted out. 0.45 s median with runs of 0.80, 0.36 and 0.45 s: the table's spread swallows the expansion at this document count.
- 6.2 M rows
+ array-filterFilter the array before expanding itSELECT arrayJoin(arrayFilter(x -> length(x) >= 12, tokens(search_text_lc))) AS t, count() AS n FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') AND length(t) > 3 GROUP BY t ORDER BY n DESC LIMIT 100.45 s (median of three) over 20 months of hackernews.items, 6.2 M rows read and 222 K token rows grouped — as measured
Expanding every token buries the vocabulary under stopwords and hands the aggregate 8.3 M rows. The jargon an analyst wants is the long words. A lambda over the array keeps only those before arrayJoin fans them out, so the expansion produces 37 times fewer rows.
technique arrayFilter(lambda, array) keeps the elements for which the lambda is true and composes inside the same expression as tokens() and arrayJoin. The lambda variable is x so it does not shadow the outer alias t. The parent's WHERE length(t) > 3 is kept for the additive edge and is now redundant: every survivor is twelve characters or longer. The top of the LLM discourse by long words: understanding 5,486, architecture 4,124, intelligence 3,972, deterministic 3,492, conversation 3,027. Any predicate works in the lambda, a prefix test, a membership check, a length gate.
why fast The same 6.2 M rows and 2.15 GB as the parent. Per row the lambda runs over every token before expansion, so the expansion emits 222,307 rows instead of 8.3 M, and the aggregate state holds 27,793 groups instead of 141,072. That is where the work went, and it is exactly the work the measurement cannot show: 0.45 s here against 0.45 s for the parent, three runs each inside the same 0.3 to 0.8 s band. At this document count the expansion was never the bottleneck; the filter is the correct shape for the corpus ten times larger.
- 6.2 M rows
+ time-bucketThe same count, laid along the calendarSELECT toStartOfMonth(original_timestamp) AS m, count() AS n FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY m ORDER BY m ASC LIMIT 200.50 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
Grouping by kind says what the LLM discourse is made of; it says nothing about when. An analyst wants the curve: did interest climb, plateau, or spike with a release? That needs a group key derived from the timestamp, one bucket per month.
technique toStartOfMonth folds every timestamp to the first of its month, and that derived value is the GROUP BY key; ORDER BY m ASC reads as a series. Twenty closed months: 3,942 in January 2025, 9,907 at the March 2026 peak, 8,546 in August 2026. The window is closed on both ends on purpose; a partial month reads as a collapse next to finished ones. Any calendar function (toStartOfWeek, toYear) is the same move at a different grain.
why fast The same 6.2 M rows and, to the byte, the same 2.15 GB as the probe: the key is computed from original_timestamp, a column the WHERE already reads, so the month costs no extra I/O. Per row one date truncation and one hash into a state holding twenty groups. The 0.50 s is the probe's time; a second look-run of the identical text took 2.0 s on the same rows, which is the variance of a shared box, not the work.
- derivation What value is computed from a document, an array, or a row?
- 6.2 M rows
+ lexicon-scoreNet tone of LLM threads, month by monthSELECT toStartOfMonth(original_timestamp) AS m, count() AS n, round(avg(arraySum(t -> transform(t, ['impressive', 'useful', 'helpful', 'excellent', 'amazing', 'love', 'great', 'brilliant', 'hype', 'garbage', 'useless', 'terrible', 'slop', 'hallucinations', 'worthless', 'disappointing'], [1, 1, 1, 1, 1, 1, 1, 1, -1, -1, -1, -1, -1, -1, -1, -1], 0), tokens(search_text_lc))), 3) AS tone FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY m ORDER BY m ASC LIMIT 200.78 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
Volume climbed from 3,942 to 8,546 items a month. Did the mood move with it? You want a per-month tone score computed from the text of every matching document with a small fixed lexicon, not the count of one word.
technique tokens(search_text_lc) splits the indexed text into words; transform(t, words, scores, 0) maps each word to +1, -1 or 0; arraySum with a lambda totals one document; avg per month gives the mean net score. Sixteen words: eight approving (impressive, useful, helpful, excellent, amazing, love, great, brilliant) and eight dismissive (hype, garbage, useless, terrible, slop, hallucinations, worthless, disappointing). Use arraySum or arrayMap, never arrayJoin, which expands rows and fires array-expand. Tone stays net-positive across the window and halves: 0.137 in January 2025, 0.063 in July 2026, while volume doubles.
why fast The same 6.2 M rows as the parent and the same 2.1 GB: search_text_lc is already read to evaluate hasToken, so tokenising it is CPU over bytes already in memory, not I/O. Per matching document, one tokenisation and one transform lookup per token, across 131,838 documents into twenty groups. Elapsed 0.78 s against the parent's 0.48 s and 0.94 s in adjacent triples is inside variance.
- shape How are rows grouped, expanded, ordered, selected, or windowed?
- 6.2 M rows
+ gap-fillA calendar axis that cannot skip a monthSELECT toStartOfMonth(original_timestamp) AS m, count() AS n FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY m ORDER BY m ASC WITH FILL FROM toDate('2025-01-01') TO toDate('2026-09-01') STEP INTERVAL 1 MONTH LIMIT 202.34 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
GROUP BY emits only the months that have rows. When a term goes quiet for a month, or the archive has a hole, the series silently shortens and every downstream chart or month-over-month delta reports the wrong neighbours. You want the twenty-month axis guaranteed, with an explicit zero wherever nothing matched.
technique ORDER BY m ASC WITH FILL FROM a TO b STEP INTERVAL 1 MONTH inserts a row for every step between the bounds that the aggregation did not produce, with n defaulted to 0. The bare WITH FILL form only spans between the first and last observed rows, so a silent first or last month would stay absent; FROM and TO pin the axis to the control window, and TO is exclusive, matching the < '2026-09-01' bound. The trunk series is dense, so the fill inserts nothing here: what the query buys is the guarantee. The detector's named-CTE scan skips the WITH in WITH FILL.
why fast The same 6.2 M rows and 2.1 GB as the parent. Filling walks the twenty sorted rows after aggregation and compares each neighbour pair with the step; on a dense series that is twenty comparisons and no inserts. This triple measured 2.34 s where the parent measured 0.48 s minutes earlier on identical scan work: production load, not the fill.
- composition How are query blocks or relations combined?
- 6.2 M rows
+ subqueryAverage month and peak month, one rowSELECT round(avg(n), 1) AS avg_per_month, max(n) AS peak_month FROM ( SELECT toStartOfMonth(original_timestamp) AS m, count() AS n FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY m ORDER BY m ASC LIMIT 20 ) LIMIT 11.17 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
Twenty rows is a series; sometimes you want two numbers. What is the typical monthly volume of LLM talk on HN, and what is its ceiling? Summarising a grouped result is an aggregate over aggregates, which SQL only allows through a nested query.
technique FROM ( ) wraps the parent verbatim, ORDER BY and LIMIT included, and the outer SELECT aggregates its twenty rows: avg(n) for the typical month, max(n) for the peak. The detector fires subquery on FROM (. max( on its own stays clear of span-derive, which needs min( beside it, and argMax( would fire group-first. One row: 6,591.9 items in the average month, 9,907 at the March 2026 peak.
why fast The same 6.2 M rows and 2.1 GB as the parent. The outer aggregate consumes twenty rows: one average, one maximum, one row out. Elapsed 1.17 s against the parent's 0.48 s and 0.94 s in adjacent triples is inside variance for identical scan work.
- statistic What summary is computed across documents or events?
- 6.2 M rows
+ cohort-retentionAuthors from 2025 still talking in 2026SELECT sum(r[1]) AS active_2025, sum(r[2]) AS still_active_2026, round(100 * sum(r[2]) / sum(r[1]), 1) AS retained_pct FROM ( SELECT original_author, retention(toYear(original_timestamp) = 2025, toYear(original_timestamp) = 2026) AS r FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY original_author ) LIMIT 10.26 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
Volume doubled. Is it the same people talking more, or new people arriving? Take everyone who wrote an LLM-matching item in 2025 and ask how many wrote another in the first eight months of 2026. Monthly counts cannot see identity; a per-author pass can.
technique retention(cond1, cond2) aggregates each author's rows into flags: r[1] is 1 if any row met cond1, r[2] is 1 only if cond1 and cond2 both held. The conditions are calendar tests, toYear(original_timestamp) = 2025 and = 2026, which is where time-bucket fires. The inner query keeps the control lines and groups by original_author; the outer sums the flags across authors. The 2026 leg covers January through August, so the 39.0 % is an eight-month return rate against a twelve-month base: 20,542 authors in 2025, 8,007 of them back in 2026.
why fast The same 6.2 M rows as the parent, plus the author column: 2.26 GB against 2.1 GB. Aggregation holds one two-flag state per author, about 35 K states instead of twenty month buckets, and the outer pass sums them into three numbers. Elapsed 0.26 s against the subquery parent's 1.17 s is variance on the same scan.
- 6.2 M rows
+ trend-fitThe linear growth rate of LLM talkSELECT round(avg(n), 1) AS avg_per_month, max(n) AS peak_month, simpleLinearRegression(toFloat64(dateDiff('month', toDate('2025-01-01'), m)), toFloat64(n)) AS fit, round(tupleElement(fit, 1), 1) AS slope_per_month, round(tupleElement(fit, 2), 1) AS intercept FROM ( SELECT toStartOfMonth(original_timestamp) AS m, count() AS n FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY m ORDER BY m ASC LIMIT 20 ) LIMIT 10.27 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
Average and peak say where the series sits, not how fast it climbs. You want a slope in items per month per month, fitted to all twenty points, with its intercept, so the trend is one number you can state and extrapolate.
technique simpleLinearRegression(x, y) fits y = k * x + b across the group and returns the (k, b) tuple. x is the month index, dateDiff('month', toDate('2025-01-01'), m), so k reads as items per month per month; tupleElement(fit, 1) and tupleElement(fit, 2) surface k and b as rounded columns. Write tupleElement, not fit.1: the API's SQL parser refuses the dotted tuple syntax. avg_per_month and peak_month stay in the outer SELECT. Slope 261.5, intercept 4,107.9: the fit puts August 2026 at 9,076 against an observed 8,546.
why fast The same 6.2 M rows and 2.1 GB as the parent. The regression is a running-sums state over twenty (x, y) pairs: five accumulators and one solve. Elapsed 0.27 s against the subquery parent's 1.17 s in this session is variance on identical scan work.
- 6.2 M rows
+ sequence-matchCopilot before Cursor, any gap, same yearSELECT y, matched, count() AS authors FROM ( SELECT toYear(original_timestamp) AS y, original_author, sequenceMatch('(?1).*(?2)')(toDateTime(original_timestamp), hasToken(search_text_lc, 'copilot'), hasToken(search_text_lc, 'cursor')) AS matched FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY y, original_author ) GROUP BY y, matched ORDER BY y ASC, matched ASC LIMIT 200.55 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
The ninety-day funnel is strict about time. Sometimes the question is only order: did an author mention Copilot and, at any later point in the year, mention Cursor? That drops the window and keeps the sequence.
technique sequenceMatch(pattern)(time, cond1, cond2) tests each author's time-ordered events against a pattern: (?1).*(?2) is a cond1 event followed, after any number of other events, by a cond2 event, two distinct items with no time bound. It returns 1 or 0, so the outer counts authors per year and outcome. The inner shape is the funnel's, bucketed by toYear(original_timestamp) and grouped by author, with sequenceMatch in place of windowFunnel. Unbounded order finds 35 authors in 2025 and 10 in 2026, a superset of the 90-day funnel's 22 and 7.
why fast The same 6.2 M rows and 2.26 GB as the funnel: the author column read once more, the text already in memory for the two extra hasToken tests. About 43 K author-year event lists are checked against a two-step pattern, then rolled up into four rows. Elapsed 0.55 s against the funnel's 0.95 s is inside variance.
- 6.2 M rows
+ event-funnelCopilot first, Cursor within ninety daysSELECT y, lvl, count() AS authors FROM ( SELECT toYear(original_timestamp) AS y, original_author, windowFunnel(7776000, 'strict_increase')(toDateTime(original_timestamp), hasToken(search_text_lc, 'copilot'), hasToken(search_text_lc, 'cursor')) AS lvl FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY y, original_author ) GROUP BY y, lvl ORDER BY y ASC, lvl ASC LIMIT 200.95 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
Did HN's LLM commenters move from Copilot to Cursor? Not mentioned both: mentioned Copilot, then in a later item within ninety days mentioned Cursor. That is an ordered, time-bounded chain per author, which no count of tokens can express.
technique windowFunnel(window, 'strict_increase')(time, cond1, cond2) walks each author's items in time order and returns the deepest step reached with every step inside window seconds of the first: 0 never mentioned Copilot, 1 Copilot only, 2 Copilot then Cursor within 90 days (7,776,000 s). strict_increase demands a strictly later timestamp between steps, so one item naming both tools cannot complete the chain; the default mode counts those and reports 102 authors at level 2 for 2025 instead of 22. The inner query is bucketed by toYear(original_timestamp), so the funnel runs per author-year and 2025 sits beside 2026 (a chain across New Year is not counted). The added tokens copilot and cursor join the control's llm rather than replacing it. 2025: 507 authors reached Copilot, 22 went on to Cursor; 2026: 353 and 7.
why fast The same 6.2 M rows as the parent, plus the author column: 2.26 GB. Per row, two more hasToken tests on text already in memory; per author-year, a small time-sorted event list, about 43 K groups against the parent's twenty; then a rollup of 43 K rows into six. Elapsed 0.95 s against the subquery parent's 1.17 s is inside variance.
- composition How are query blocks or relations combined?
- 277.6 M rows
+ union-allHacker News and Reddit in one querySELECT venue, round(avg(n), 1) AS avg_per_month, max(n) AS peak_month FROM ( (SELECT 'hackernews' AS venue, toStartOfMonth(original_timestamp) AS m, count() AS n FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY m ORDER BY m ASC LIMIT 20) UNION ALL (SELECT 'reddit' AS venue, toStartOfMonth(created_utc) AS m, count() AS n FROM reddit.posts WHERE created_utc >= '2025-01-01' AND created_utc < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY m ORDER BY m ASC LIMIT 20) ) GROUP BY venue ORDER BY venue ASC LIMIT 220.6 s (median of three) over 20 months of hackernews.items and reddit.posts, 277.6 M rows read — as measured
Is the LLM curve a Hacker News story or the internet's? The average and peak over HN alone have nothing to stand against. You want the same two numbers for a second venue, computed in one statement, each row tagged with where it came from.
technique UNION ALL stacks two SELECTs with matching columns into one input, and a venue literal in each branch labels its rows. The Reddit branch mirrors the HN branch column for column: the same twenty months on created_utc, the same term. The outer rollup keeps the parent's avg_per_month and peak_month and adds GROUP BY venue. Every set-operation branch on this API carries its own literal LIMIT and the complete set query needs an outer LIMIT, which ClickHouse accepts only through a FROM ( ) wrapper, so a union always sits under subquery. The branch reads reddit.posts; the comments archive reads 2.6 B rows for the same window. Reddit's August 2026 is partial in the mirror (2,063 posts against 20,650 in July), which pulls its average down.
why fast 277.6 M rows and 101 GB against the parent's 6.2 M rows and 2.1 GB. The Reddit branch is the cost: 271 M rows of reddit.posts, where llm is a common token and the token index prunes little, so the scan covers most of two years of posts. The HN branch is unchanged. Two branch aggregations of twenty groups each, then a rollup of forty rows into two.
- presentation How is a result encoded for a reader?
- 6.2 M rows
+ inline-barsTwenty months of LLM talk, drawn inlineSELECT toStartOfMonth(original_timestamp) AS m, count() AS n, bar(n, 0, 10000, 20) AS trend FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY m ORDER BY m ASC LIMIT 200.65 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
You want the shape of the series at a glance, in the result grid, without exporting to a chart. The parent returns the twenty counts as digits; seeing the climb from 3,942 to 9,907 means reading every number. A bar column makes the peak and the dips visible in the row itself.
technique bar(x, min, max, width) renders x on a min-to-max scale as a run of block glyphs width cells long, with eighth-block glyphs for the remainder. It composes onto the parent's SELECT as one more column over the alias n. The ceiling 10,000 comes from the measured peak (9,907), so the tallest month fills 19.8 of 20 cells; choose the ceiling from the data, because a ceiling below the peak clips silently to a full bar.
why fast The same 6.2 M rows and 2.1 GB as the parent. The bars are twenty short strings built from the aggregated values after the scan. Elapsed 0.65 s against the parent's 0.48 s and 0.94 s in adjacent triples is inside run-to-run variance.
- statistic What summary is computed across documents or events?
- 6.2 M rows
+ quantileThe length distribution, in three numbersSELECT hn_type, count() AS n, quantiles(0.5, 0.9, 0.99)(word_count) AS words_p50_p90_p99 FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY hn_type ORDER BY n DESC LIMIT 100.56 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
Averages hide the shape: a mean word count says nothing about whether LLM comments are short quips with a long essay tail or uniformly mid-length. The median, the 90th and the 99th percentile per item type are the shape, and a count cannot produce them.
technique quantiles(levels...)(column) returns one array of all requested levels from a single sample per group; three separate quantile() calls would hold three samples. The result is the shape: LLM comments run 71 words at the median, 210 at p90, 474 at p99, while stories sit at 14 words (a title) with a p90 of 320 (the Ask HN posts that carry text). Jobs return NULLs because word_count is not populated for them. quantiles is reservoir-sampled and approximate; quantilesExact holds every value and is the choice when the group is small.
why fast The same 6,235,069 rows as the parent, one Int32 column added (2.28 GB). Each group holds one bounded reservoir sample (8,192 values) rather than the group's full value list, so the state is constant in group size and the three levels are read from the same sample at the end. The elapsed difference is inside run-to-run variance.
- 6.2 M rows
+ entropyOne number for how many people really talkSELECT hn_type, count() AS n, round(entropy(original_author), 2) AS author_entropy_bits FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY hn_type ORDER BY n DESC LIMIT 100.66 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
A distinct count says 29,887 people commented; it cannot say whether the conversation is spread across them or dominated by a few hundred. Shannon entropy over the author column is the concentration in one number, comparable across item types.
technique entropy(column) is the Shannon entropy of the column's value distribution within the group, in bits. Read it through its exponent: 13.51 bits for comments means the conversation behaves like about 11,600 equally active voices, out of 29,887 actual; stories at 12.62 bits behave like about 6,300 submitters out of 8,217. Comments are the more concentrated room relative to their headcount. Pair it with uniq to see both the headcount and the effective headcount; entropy alone is a ratio without a base.
why fast The same 6,235,069 rows and the same 2.36 GB as the distinct-count sibling (the author column is the one addition). entropy holds a hash map of value counts per group and reduces it at the end, so its state grows with the group's distinct authors (tens of thousands of entries for comments), which is the one aggregate in this fan whose memory is not constant. The elapsed difference against the parent is inside run-to-run variance.
- 6.2 M rows
+ group-firstThe first voice in every threadSELECT parent_hn_id, count() AS n, argMin(original_author, original_timestamp) AS first_voice FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY parent_hn_id ORDER BY n DESC LIMIT 100.69 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
Grouped by parent, the count ranks the threads where LLM talk landed. It cannot say who opened each of them: the first reply is the row with the minimal timestamp inside its group, and a count has no row. Which threads drew the most LLM replies, and who fired the first shot in each?
technique argMin(x, y) returns the x of the row where y is minimal, per group: group-wise first as a plain aggregate, no window, no self-join. The key is the parent pointer, so a group is a reply set under one item. The NULL group is the 11,438 items with no parent (11,424 stories plus jobs and polls), and it leads the ranking; the threads below it are the monthly hiring posts, where LLM turns up in 55 to 121 direct replies each. argMax with the same shape gives the last voice; the min+max pair of timestamps is the span-derive construct, so argMin stands alone here.
why fast The same 6,235,069 rows as the parent, one extra column read (2.31 GB). Per group the state is one (author, timestamp) pair, replaced only when a smaller timestamp arrives; the group count is higher than the parent's (one group per parent item, tens of thousands, against five types) and that larger group count is the only added cost. The elapsed difference is inside run-to-run variance.
- 6.2 M rows
+ span-deriveWho has been in this conversation the whole timeSELECT original_author, count() AS n, min(original_timestamp) AS first_post, max(original_timestamp) AS last_post, dateDiff('day', min(original_timestamp), max(original_timestamp)) AS tenure_days FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY original_author ORDER BY n DESC LIMIT 100.79 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
A post count ranks the regulars but cannot separate a voice present across the whole window from one that arrived last quarter and posted in bursts. First post, last post and the days between are per-author spans, and every veteran-versus-newcomer question is built on them.
technique min and max of the timestamp per group derive the interval; dateDiff names its length in days. The ten most prolific LLM voices span nearly the whole 608-day window: simonw's first in-window LLM post lands at 00:04 on 2025-01-01 and his last on 2026-08-31, 607 days apart; ACCount37 is the newcomer at 370 days from an August 2025 start. With two spans in hand, before/after and overlap questions become endpoint comparisons. The min+max pair is what the tree reads as this construct; argMin alone belongs to group-first.
why fast The same 6,235,069 rows and 2.26 GB as a per-author group-by. Span state is two timestamps per group, each updated by one comparison per row, and dateDiff runs once per group at output; the 35,079 author groups are the same groups the count already held. The elapsed difference is inside run-to-run variance.
- 6.2 M rows
+ distinct-countVoices, not volume, per item typeSELECT hn_type, count() AS n, uniq(original_author) AS voices FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY hn_type ORDER BY n DESC LIMIT 100.91 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
120,400 LLM comments could be forty people or thirty thousand. The group-by count cannot tell breadth from persistence: one regular posting daily reads exactly like a crowd posting once. How many distinct people stand behind each type's count?
technique uniq() is the approximate distinct count, one column beside the exact count in the same grouped pass. The n/voices ratio is the free second finding: 120,400 comments from 29,887 voices is four per voice; 11,424 stories from 8,217 submitters is 1.4. uniq is a HyperLogLog-class sketch (error under 1 percent at these sizes); write uniqExact when the number must be exact and the group's distinct set fits in memory.
why fast The same 6,235,069 rows as the parent, plus one column read (original_author, 2.36 GB against 2.25 GB). The distinct count never materializes the distinct set: each group holds a fixed-size sketch, updated per row with a hash, so five groups cost five sketches however many authors appear. The elapsed difference against the parent is inside run-to-run variance.
- 6.2 M rows
+ correlationWhat upvotes move with, and what they ignoreSELECT hn_type, count() AS n, round(corr(upvotes, comment_count), 3) AS votes_vs_comments, round(corr(upvotes, word_count), 3) AS votes_vs_words FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY hn_type ORDER BY n DESC LIMIT 101.15 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
Do LLM stories that draw more comments also draw more votes, and does a longer post earn more votes? Both are questions about how two columns co-vary across a group's rows, which no count and no single-column statistic can answer.
technique corr(x, y) is the Pearson coefficient per group, accumulated in one pass from running sums. Two coefficients in one query make the finding: for 11,424 LLM stories, upvotes and comment counts move together at 0.848, while upvotes and word count are uncorrelated at 0.005. The length of a submission buys nothing; the conversation it starts is what the vote count tracks. Rows for comments return NULL because comments carry no upvote snapshot; corr skips NULL pairs and returns NULL when a group has none. Both columns are first-fetch snapshots, so read this over a closed window, never over the live tail.
why fast The same 6,235,069 rows as the parent, three Int32 columns added (2.34 GB). Each coefficient is five running sums per group (sum x, sum y, sum xy, sum x squared, sum y squared) and a count, so two coefficients across five groups are sixty numbers of state. The three runs spread from 0.89 to 1.58 s under production load; rows read is the stable cost and it equals the parent's.
- 6.2 M rows
+ group-collectThree sample lines from every top voiceSELECT original_author, count() AS n, groupArray(3)(left(payload, 80)) AS samples FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY original_author ORDER BY n DESC LIMIT 101.05 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
The ranking says who talks about LLMs most. It does not say what they sound like. Three opening fragments per author, carried alongside the count, turn a leaderboard into something a reader can judge, and no scalar aggregate can carry text out of a group.
technique groupArray(k)(expr) collects up to k values per group into an array, in whatever order rows arrive; left(payload, 80) keeps each element to one line. The result reads as a voice sample: simonw's three lines are tooling and benchmarks, ACCount37's are architecture arguments. The order is not chronological and not by merit; the sorted-collect child adds the order. The text column is read inside a function, so this stays an aggregate over groups, not row retrieval.
why fast The same 6,235,069 rows as the parent, but the payload column is now read: 3.80 GB against 2.26 GB, the largest byte step in this fan. Per group the state is a bounded array of three 80-character strings; groupArray with a limit stops appending once it holds k, so a 719-post author costs the same as a 3-post one. Elapsed tracks the extra 1.5 GB of text decompression.
- 6.2 M rows
+ sorted-collectHow each voice entered the conversationSELECT original_author, count() AS n, groupArray(3)(left(payload, 80)) AS samples, groupArraySorted(2)((toDate(original_timestamp), left(payload, 80))) AS opening_lines FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY original_author ORDER BY n DESC LIMIT 101.15 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
Three arbitrary samples show a voice; its first two posts of the window show its starting position, the stance it held before the year unfolded. Ordering inside a group is what groupArray lacks: you want the top k by a key, per group, without sorting every row of every group.
technique groupArraySorted(k)(value) keeps the k smallest values per group; make the value a tuple and the first element is the sort key, the rest the payload. (date, line) with k = 2 yields each author's two earliest LLM posts of the window, date first. simonw opens on 2025-01-01 with a Karpathy reference; the unsorted samples column stays beside it for contrast. Negate a numeric key for top-k by size: (-upvotes, title) returns the two most upvoted. The key here is toDate, not toStartOfMonth, so the query stays in this fan and does not become a time-bucket.
why fast The same 6,235,069 rows and the same 3.80 GB as the parent: no new column is read, since the timestamp is already loaded for the window predicate. The addition is a two-element bounded heap per group, one comparison per row against the current second-smallest and an insertion only when the row wins, so a 719-post group sorts nothing. The elapsed difference is inside run-to-run variance.
- composition How are query blocks or relations combined?
- 12.4 M rows
+ in-subqueryOne cohort's share of the whole conversationSELECT hn_type, count() AS n FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') AND original_author IN ( SELECT DISTINCT original_author FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') AND hasToken(search_text_lc, 'agent') ) GROUP BY hn_type ORDER BY n DESC LIMIT 101.00 s (median of three) over 20 months of hackernews.items, 12.4 M rows read across both blocks — as measured
The people who say agent inside the LLM conversation are a cohort you cannot type out: 5,496 handles, defined by a predicate. What share of all LLM items, by type, do those people produce? Membership in a computed set is the filter, and it takes a subquery in the WHERE clause.
technique column IN (SELECT ...) is the semijoin: the inner query defines the cohort by any predicate the corpus can express, the outer histogram counts what that cohort does. Both blocks carry the control lines, and the inner adds one token. The agent crowd is 5,496 of 35,079 LLM voices and writes 47,086 of the 120,400 LLM comments (39 percent) and 3,420 of 11,424 stories (30 percent): a sixth of the people, two fifths of the talk. Compare each row to the parent's count for the share.
why fast Twice the parent's scan: 12,429,738 rows and 4.60 GB, because the inner query is a second pass over the same index-pruned window (the agent token narrows its output, not its read). The cohort materializes once as a hash set of 5,496 strings; the outer pass tests each matched row against it, one hash probe per row, so the join between the two blocks costs nothing measurable beyond the second scan. The three runs spread from 0.7 to 1.5 s under production load; the 12.4 M rows read is the stable cost.
- 6.2 M rows
+ named-cteName the per-author table, then histogram itWITH per_author AS ( SELECT original_author, count() AS n FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') GROUP BY original_author ) SELECT n AS posts, count() AS authors FROM per_author GROUP BY posts ORDER BY posts ASC LIMIT 101.02 s (median of three) over 20 months of hackernews.items, 6.2 M rows read — as measured
How many people said LLM once, twice, ten times? That is a distribution over the per-author counts, an aggregate of an aggregate. Naming the first aggregation makes the second legible: the reader sees per_author defined once, then a plain GROUP BY over it.
technique WITH name AS (query) declares a block up front and the statement below reads FROM it by name; the parent's grouped query is the block, unchanged except that ORDER BY and LIMIT move to the consumer. The histogram is the long tail in numbers: 19,057 of 35,079 authors said LLM exactly once, 5,702 twice, 277 exactly ten times. A CTE referenced by name is not a parenthesized derived table, so the statement stays in this fan; write FROM (SELECT ...) and it becomes the subquery construct.
why fast The same 6,235,069 rows and 2.26 GB as the parent: the name costs nothing, the pruned scan happens once inside the block, and the outer GROUP BY runs over the 35,079 rows the block emits. The three runs spread from 0.59 to 1.98 s under production load; rows read is the stable cost and it equals the parent's.
- 12.6 M rows
+ joinComments carry the term; the join finds the roomSELECT s.hn_id AS story_id, any(s.title) AS story, count() AS n FROM hackernews.items AS c INNER JOIN hackernews.items AS s ON c.parent_hn_id = s.hn_id AND s.hn_type = 'story' AND s.hn_id >= 42500000 WHERE c.original_timestamp >= '2025-01-01' AND c.original_timestamp < '2026-09-01' AND hasToken(c.search_text_lc, 'llm') GROUP BY s.hn_id ORDER BY n DESC LIMIT 101.33 s (median of three) over 20 months of hackernews.items, 12.6 M rows read across both sides — as measured
Grouping matching comments by parent id ranks the threads but shows only numbers. The title lives on a different row, the story, and reaching it means stitching each matching comment to the item it answers. Which stories hosted the most direct LLM replies?
technique A self-join over the thread key: the control lines stay on the comment alias c, each hit joins to its parent through parent_hn_id, and s.hn_type = 'story' keeps the parents that are stories, so the count is direct replies to the story. parent_hn_id is the link because story_hn_id is NULL on most archived comments (92 percent of 2026's) and would drop them. The result names the rooms: the monthly Who wants to be hired threads lead (121 LLM replies in August 2026), with My AI skeptic friends are all nuts at 94 as the one non-recurring story in the top ten. any(s.title) reads the title as an aggregate; naming s.title bare in the projection would make this row retrieval.
why fast 12,591,971 rows and 2.40 GB: the parent's 6.2 M matched-side scan plus 6.4 M rows for the build side. The build side is bounded in the ON clause by the primary key (s.hn_id >= 42500000, the ids from late December 2024 on), which prunes the story lookup to the window's own items; without that bound the same join reads 51.5 M rows, the whole relation as build side. The probe side then resolves each matching comment by one hash lookup on the parent id.
- composition How are query blocks or relations combined?
- 12.6 M rows
+ anti-joinAnti-join finds what never happenedSELECT s.original_author AS author, count() AS n FROM hackernews.items AS s LEFT ANTI JOIN hackernews.items AS c ON c.parent_hn_id = s.hn_id AND c.hn_id >= 42500000 WHERE s.original_timestamp >= '2025-01-01' AND s.original_timestamp < '2026-09-01' AND hasToken(s.search_text_lc, 'llm') AND s.hn_type = 'story' GROUP BY author ORDER BY n DESC LIMIT 100.47 s (median of three) over 20 months of hackernews.items, 12.6 M rows read across both sides — as measured
Which LLM stories drew no reply at all, and who keeps submitting them? Absence is invisible to WHERE: there is no comment row to filter. LEFT ANTI JOIN keeps exactly the left rows that found no match on the right.
technique The parent's join is turned around: stories are the anchored side under the control lines, comments are the build side, and LEFT ANTI keeps stories with no comment pointing at them. Grouped by submitter it ranks shouting into the void: PaulHoule leads with 66 unanswered LLM stories, matt_d with 44. The denominator is stark: 6,849 of the 11,424 LLM stories in the window drew no direct reply, 60 percent, mostly duplicates of whichever submission won the thread. The same shape answers any absence question over two row sets.
why fast 12,579,539 rows and 2.51 GB, the same order as the join parent: the build side is every comment from the window, bounded by primary key in the ON clause (c.hn_id >= 42500000), hashed on its parent id once; each of the 11,424 anchored stories then costs one hash probe, and the probe's miss is the result. No NOT IN scan, no per-story subquery. Elapsed came in under the parent's: the anchored side hands 11,424 stories to the probe, not 131,838 comments, and the build side is read for its parent id alone.
- 3.1 M rows
+ recursiveRecursion walks what a join cannot reachWITH RECURSIVE thread AS ( SELECT hn_id, parent_hn_id, 0 AS depth FROM hackernews.items WHERE original_timestamp >= '2025-01-01' AND original_timestamp < '2026-09-01' AND hasToken(search_text_lc, 'llm') AND hn_id = 44567857 UNION ALL SELECT c.hn_id, c.parent_hn_id, t.depth + 1 AS depth FROM hackernews.items AS c INNER JOIN thread AS t ON c.parent_hn_id = t.hn_id WHERE t.depth < 20 AND c.original_timestamp >= '2025-07-15' AND c.original_timestamp < '2025-08-01' ) SELECT depth, count() AS n FROM thread GROUP BY depth ORDER BY depth ASC LIMIT 203.25 s (median of three) for a 1,598-comment, depth-16 thread in hackernews.items, 3.1 M rows read — as measured
A join reaches one hop; a comment tree is as deep as the argument went. The shape of the window's largest LLM thread, how many replies at each depth and how deep the deepest chain goes, is transitive closure over the parent pointer, and no fixed number of joins expresses it.
technique WITH RECURSIVE iterates the join until the frontier is empty: the seed row in, the children of the current frontier each round, depth carried as a counter. The seed is the window's most-commented LLM story, LLM Inevitabilism (hn_id 44567857, 2025-07-15, 1,628 comments). The load-bearing lines are inside the recursive arm: a depth cap and a date window around the story's own fortnight bound every round, because story_hn_id is NULL on most archived replies and cannot scope the walk. The closure holds 1,598 comments: 124 top-level replies swelling to 290 at depth 3 and thinning to a single comment at depth 16. The UNION ALL inside WITH RECURSIVE is part of this construct, not the union-all entry.
why fast 3,132,860 rows and 62 MB, a quarter of the parent join's rows and a fortieth of its bytes: every round is a key join over the seventeen-day slice the recursive arm names, only two Int64 columns and a timestamp are read, and the seed itself is a primary-key lookup. Sixteen rounds; the three runs spread from 2.4 to 3.3 s under production load, and the 3.1 M rows read is the stable cost. Drop the date bound and each round rescans the relation; the bound is the difference between a thread walk and seventeen full passes.
- 51.5 M rows
+ asof-joinThe nearest earlier row, per rowSELECT a.hn_type AS kind, count() AS n, round(avg(dateDiff('day', b.original_timestamp, a.original_timestamp)), 1) AS days_since_previous FROM hackernews.items AS a ASOF JOIN hackernews.items AS b MATCH_CONDITION (a.original_timestamp > b.original_timestamp) ON a.original_author = b.original_author AND hasToken(b.search_text_lc, 'llm') AND b.original_timestamp >= '2025-01-01' AND b.original_timestamp < '2026-09-01' WHERE a.original_timestamp >= '2025-01-01' AND a.original_timestamp < '2026-09-01' AND hasToken(a.search_text_lc, 'llm') GROUP BY a.hn_type ORDER BY n DESC LIMIT 107.87 s (median of three) over 20 months of hackernews.items, 51.5 M rows read across both sides — as measured
How long does a voice go quiet between LLM posts? For every item you want exactly one partner, the same author's most recent previous LLM item, and an equality join returns all of them. ASOF JOIN picks the one nearest under an inequality, per row.
technique ASOF JOIN matches each left row to the single right row that is nearest under MATCH_CONDITION within the ON equality group: here, the same author's latest earlier item. The served spelling is MATCH_CONDITION plus ON (translated server-side); GROUP BY needs the qualified column. The build side's predicates belong in the ON clause: written in WHERE they apply after the match, so each item pairs with its author's previous post of any kind and survives only if that happened to be an LLM post, which returned 12,570 comments at 4.3 days apart, wrong on both counts. Filtered in ON, 91,839 comments align to a previous LLM comment 35.7 days earlier on average, and 4,652 stories to one 54.1 days earlier. Items with no predecessor in the window drop out, as an inner join should.
why fast 51,532,931 rows and 19.2 GB: the build side reads the whole relation, because ON-clause predicates on the ASOF build side filter after the read rather than pruning it (a primary-key bound in the same clause changed nothing). The build then holds, per author, a timestamp-sorted list of that author's in-window LLM items, and each of the 131,838 probe rows costs one binary search in its author's list. The three runs spread from 7.6 to 10.4 s under production load; the 51.5 M rows read is the stable cost, four times the parent's.