Skip to content

List phrase signatures and kind words in one pass instead of rescanning the base once per group - #1108

Merged
WaylandYang merged 1 commit into
deeplethe:devfrom
evolv3ai:fix/signature-scans-are-linear
Oct 9, 2026
Merged

WaylandYang merged 1 commit into
deeplethe:devfrom
evolv3ai:fix/signature-scans-are-linear

Conversation

@evolv3ai

@evolv3ai evolv3ai commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Why

#1096. phrase_bindings::signatures and type_bindings::signatures build each group's sample arrays with correlated ARRAY(SELECT …) subqueries, and each one rescans the whole CTE once per group. Cost grows with groups × rows. align_phrases runs the signature query twice per run and the kind-word query during its typed recompute, so on a large base these statements dominate a run (measurements in the issue: 195 s → 0.8 s and 80 s → 0.4 s).

What changes

  • Signature query: rank each group's rows once (row_number()), then take the count and the first three examples and quotes in one GROUP BY with array_agg … FILTER (WHERE rn <= 3). Chunk text is read only for the rows kept. The live CTE (including the per-statement evidence pick) is unchanged.
  • The final ORDER BY gains g.subject_type_id, g.object_type_id, g.object_is_value, so rows that tie on (count, phrase) have a fixed order.
  • Kind-word query: top three spellings, examples and phrases each come from one aggregate pass (grouped, spellings/words, phrase_counts/phrases), joined back by kind_word. Its output is unchanged, including order.
  • No schema change, no change to callers.

How it was checked

  • New store test tied_signatures_come_in_a_fixed_order: a group of five statements (examples are the first three by recorded_at, each quote stays with its example, a statement without evidence gives ""), three groups tied on (count, phrase) that differ only in type ids / object_is_value (fixed order on repeated calls), and kind words with four spellings (top three by frequency, examples in creation order, phrases by frequency). Against the old query it fails on the tied rows' order.
  • The existing phrase_signature_evidence and a_kind_word_binds_to_a_class tests pass unchanged. So does the whole utopia-store suite with UTOPIA_DATABASE_URL and UTOPIA_TEST_REQUIRE_DB=1 against a scratch pgvector:pg16, plus utopia-server -- phrase_alignment type_alignment implication. cargo fmt --all --check and cargo clippy --workspace --all-targets -- -D warnings are clean.
  • Equivalence on the fixture below: the kind-word output is byte-identical including order. The signature output is identical as a multiset with identical arrays, and rows move only within (count, phrase) ties, which now follow the new tiebreak.

EXPLAIN ANALYZE, before and after

Synthetic base on pgvector/pg16 with default settings, 1 CPU: 15,000 open statements in 12,100 signature groups (100 of them with more than three rows), 6,000 entities, 1,500 kind words. EXPLAIN (ANALYZE, BUFFERS, COSTS OFF).

Query Before After
phrase_bindings::signatures 27,358 ms, 2 SubPlans (CTE Scan on picked, loops=12100 each) 199 ms, no SubPlan
type_bindings::signatures 2,400 ms, 3 SubPlans (loops=1500 each) 91 ms, no SubPlan
Signature query, before
 Sort (actual time=27336.322..27336.994 rows=12100 loops=1)
   Sort Key: g.count DESC, g.phrase
   Sort Method: quicksort  Memory: 2623kB
   Buffers: shared hit=62168
   CTE live
     ->  Nested Loop Left Join (actual time=40.811..113.600 rows=15000 loops=1)
           Buffers: shared hit=41360
           ->  Hash Left Join (actual time=40.658..56.278 rows=15000 loops=1)
                 Hash Cond: (f.object_id = o.id)
                 Buffers: shared hit=957
                 ->  Hash Join (actual time=39.300..50.952 rows=15000 loops=1)
                       Hash Cond: (f.subject_id = s.id)
                       Buffers: shared hit=728
                       ->  Seq Scan on facts f (actual time=37.693..44.840 rows=15000 loops=1)
                             Filter: ((invalidated_at IS NULL) AND (phrase IS NOT NULL) AND (kb_id = '0e000000-0000-7000-8000-000000000001'::uuid) AND (layer = 'open'::text) AND (btrim(phrase) <> ''::text))
                             Buffers: shared hit=499
                       ->  Hash (actual time=1.572..1.578 rows=6000 loops=1)
                             Buckets: 8192  Batches: 1  Memory Usage: 509kB
                             Buffers: shared hit=229
                             ->  Seq Scan on entities s (actual time=0.012..0.832 rows=6000 loops=1)
                                   Buffers: shared hit=229
                 ->  Hash (actual time=1.328..1.329 rows=6000 loops=1)
                       Buckets: 8192  Batches: 1  Memory Usage: 509kB
                       Buffers: shared hit=229
                       ->  Seq Scan on entities o (actual time=0.011..0.594 rows=6000 loops=1)
                             Buffers: shared hit=229
           ->  Limit (actual time=0.002..0.002 rows=1 loops=15000)
                 Buffers: shared hit=40403
                 ->  Sort (actual time=0.002..0.002 rows=1 loops=15000)
                       Sort Key: (((fact_evidence.quote_start IS NULL) OR (fact_evidence.quote_end IS NULL))), fact_evidence.chunk_id
                       Sort Method: quicksort  Memory: 25kB
                       Buffers: shared hit=40403
                       ->  Index Scan using fact_evidence_pkey on fact_evidence (actual time=0.001..0.001 rows=1 loops=15000)
                             Index Cond: (fact_id = f.id)
                             Buffers: shared hit=40400
   CTE grouped
     ->  HashAggregate (actual time=129.749..131.527 rows=12100 loops=1)
           Group Key: live.phrase, live.subject_type_id, live.object_type_id, live.object_is_value
           Batches: 1  Memory Usage: 1681kB
           Buffers: shared hit=41360
           ->  CTE Scan on live (actual time=40.813..122.512 rows=15000 loops=1)
                 Buffers: shared hit=41360
   CTE picked
     ->  WindowAgg (actual time=66.683..116.042 rows=15000 loops=1)
           Buffers: shared hit=20803
           ->  Incremental Sort (actual time=66.674..106.801 rows=15000 loops=1)
                 Sort Key: g_1.phrase, g_1.subject_type_id, g_1.object_type_id, g_1.object_is_value, l.recorded_at, l.id
                 Presorted Key: g_1.phrase
                 Full-sort Groups: 398  Sort Method: quicksort  Average Memory: 34kB  Peak Memory: 34kB
                 Buffers: shared hit=20803
                 ->  Nested Loop Left Join (actual time=66.477..94.297 rows=15000 loops=1)
                       Buffers: shared hit=20800
                       ->  Merge Join (actual time=66.443..83.763 rows=15000 loops=1)
                             Merge Cond: ((g_1.phrase = l.phrase) AND (g_1.object_is_value = l.object_is_value))
                             Join Filter: ((NOT (l.subject_type_id IS DISTINCT FROM g_1.subject_type_id)) AND (NOT (l.object_type_id IS DISTINCT FROM g_1.object_type_id)))
                             Rows Removed by Join Filter: 49000
                             ->  Sort (actual time=31.436..32.649 rows=12100 loops=1)
                                   Sort Key: g_1.phrase, g_1.object_is_value
                                   Sort Method: quicksort  Memory: 1198kB
                                   ->  CTE Scan on grouped g_1 (actual time=0.003..5.286 rows=12100 loops=1)
                             ->  Sort (actual time=34.950..37.241 rows=63996 loops=1)
                                   Sort Key: l.phrase, l.object_is_value
                                   Sort Method: quicksort  Memory: 2614kB
                                   ->  CTE Scan on live l (actual time=0.005..1.973 rows=15000 loops=1)
                       ->  Index Scan using chunks_pkey on chunks c (actual time=0.000..0.000 rows=1 loops=15000)
                             Index Cond: (id = l.chunk_id)
                             Buffers: shared hit=20800
   ->  Hash Left Join (actual time=253.543..27299.471 rows=12100 loops=1)
         Hash Cond: (g.object_type_id = ot.id)
         Buffers: shared hit=62165
         ->  Hash Left Join (actual time=129.782..143.650 rows=12100 loops=1)
               Hash Cond: (g.subject_type_id = st.id)
               Buffers: shared hit=41361
               ->  CTE Scan on grouped g (actual time=129.750..133.396 rows=12100 loops=1)
                     Buffers: shared hit=41360
               ->  Hash (actual time=0.021..0.021 rows=20 loops=1)
                     Buckets: 1024  Batches: 1  Memory Usage: 10kB
                     Buffers: shared hit=1
                     ->  Seq Scan on entity_types st (actual time=0.012..0.014 rows=20 loops=1)
                           Buffers: shared hit=1
         ->  Hash (actual time=0.009..0.009 rows=20 loops=1)
               Buckets: 1024  Batches: 1  Memory Usage: 10kB
               Buffers: shared hit=1
               ->  Seq Scan on entity_types ot (actual time=0.004..0.005 rows=20 loops=1)
                     Buffers: shared hit=1
         SubPlan 4
           ->  Sort (actual time=1.143..1.143 rows=1 loops=12100)
                 Sort Key: p.rn
                 Sort Method: quicksort  Memory: 25kB
                 Buffers: shared hit=20803
                 ->  CTE Scan on picked p (actual time=0.681..1.142 rows=1 loops=12100)
                       Filter: ((NOT (subject_type_id IS DISTINCT FROM g.subject_type_id)) AND (NOT (object_type_id IS DISTINCT FROM g.object_type_id)) AND (rn <= 3) AND (phrase = g.phrase) AND (object_is_value = g.object_is_value))
                       Rows Removed by Filter: 14999
                       Buffers: shared hit=20803
         SubPlan 5
           ->  Sort (actual time=1.096..1.096 rows=1 loops=12100)
                 Sort Key: p_1.rn
                 Sort Method: quicksort  Memory: 25kB
                 ->  CTE Scan on picked p_1 (actual time=0.650..1.094 rows=1 loops=12100)
                       Filter: ((NOT (subject_type_id IS DISTINCT FROM g.subject_type_id)) AND (NOT (object_type_id IS DISTINCT FROM g.object_type_id)) AND (rn <= 3) AND (phrase = g.phrase) AND (object_is_value = g.object_is_value))
                       Rows Removed by Filter: 14999
 Planning:
   Buffers: shared hit=776
 Planning Time: 1.822 ms
 Execution Time: 27357.959 ms
Signature query, after
 Sort (actual time=181.217..181.722 rows=12100 loops=1)
   Sort Key: (count(*)) DESC, (lower(btrim(regexp_replace(f.phrase, '\s+'::text, ' '::text, 'g'::text)))), s.type_id, o.type_id, ((f.object_id IS NULL))
   Sort Method: quicksort  Memory: 2623kB
   Buffers: shared hit=41390
   ->  Hash Left Join (actual time=146.284..176.417 rows=12100 loops=1)
         Hash Cond: (o.type_id = ot.id)
         Buffers: shared hit=41376
         ->  Hash Left Join (actual time=146.269..174.627 rows=12100 loops=1)
               Hash Cond: (s.type_id = st.id)
               Buffers: shared hit=41375
               ->  GroupAggregate (actual time=146.239..172.650 rows=12100 loops=1)
                     Group Key: (lower(btrim(regexp_replace(f.phrase, '\s+'::text, ' '::text, 'g'::text)))), s.type_id, o.type_id, ((f.object_id IS NULL))
                     Buffers: shared hit=41374
                     ->  Sort (actual time=146.173..146.781 rows=15000 loops=1)
                           Sort Key: (lower(btrim(regexp_replace(f.phrase, '\s+'::text, ' '::text, 'g'::text)))), s.type_id, o.type_id, ((f.object_id IS NULL))
                           Sort Method: quicksort  Memory: 2501kB
                           Buffers: shared hit=41369
                           ->  Hash Left Join (actual time=131.334..140.694 rows=15000 loops=1)
                                 Hash Cond: (fact_evidence.chunk_id = c.id)
                                 Join Filter: ((row_number() OVER (?)) <= 3)
                                 Rows Removed by Join Filter: 1800
                                 Buffers: shared hit=41369
                                 ->  WindowAgg (actual time=131.265..138.610 rows=15000 loops=1)
                                       Buffers: shared hit=41360
                                       ->  Sort (actual time=131.241..131.856 rows=15000 loops=1)
                                             Sort Key: (lower(btrim(regexp_replace(f.phrase, '\s+'::text, ' '::text, 'g'::text)))), s.type_id, o.type_id, ((f.object_id IS NULL)), f.recorded_at, f.id
                                             Sort Method: quicksort  Memory: 2714kB
                                             Buffers: shared hit=41360
                                             ->  Nested Loop Left Join (actual time=25.154..91.264 rows=15000 loops=1)
                                                   Buffers: shared hit=41357
                                                   ->  Hash Left Join (actual time=25.039..39.411 rows=15000 loops=1)
                                                         Hash Cond: (f.object_id = o.id)
                                                         Buffers: shared hit=957
                                                         ->  Hash Join (actual time=23.507..34.386 rows=15000 loops=1)
                                                               Hash Cond: (f.subject_id = s.id)
                                                               Buffers: shared hit=728
                                                               ->  Seq Scan on facts f (actual time=21.977..28.854 rows=15000 loops=1)
                                                                     Filter: ((invalidated_at IS NULL) AND (phrase IS NOT NULL) AND (kb_id = '0e000000-0000-7000-8000-000000000001'::uuid) AND (layer = 'open'::text) AND (btrim(phrase) <> ''::text))
                                                                     Buffers: shared hit=499
                                                               ->  Hash (actual time=1.515..1.516 rows=6000 loops=1)
                                                                     Buckets: 8192  Batches: 1  Memory Usage: 509kB
                                                                     Buffers: shared hit=229
                                                                     ->  Seq Scan on entities s (actual time=0.011..0.764 rows=6000 loops=1)
                                                                           Buffers: shared hit=229
                                                         ->  Hash (actual time=1.521..1.521 rows=6000 loops=1)
                                                               Buckets: 8192  Batches: 1  Memory Usage: 509kB
                                                               Buffers: shared hit=229
                                                               ->  Seq Scan on entities o (actual time=0.005..0.586 rows=6000 loops=1)
                                                                     Buffers: shared hit=229
                                                   ->  Limit (actual time=0.002..0.002 rows=1 loops=15000)
                                                         Buffers: shared hit=40400
                                                         ->  Sort (actual time=0.002..0.002 rows=1 loops=15000)
                                                               Sort Key: (((fact_evidence.quote_start IS NULL) OR (fact_evidence.quote_end IS NULL))), fact_evidence.chunk_id
                                                               Sort Method: quicksort  Memory: 25kB
                                                               Buffers: shared hit=40400
                                                               ->  Index Scan using fact_evidence_pkey on fact_evidence (actual time=0.001..0.001 rows=1 loops=15000)
                                                                     Index Cond: (fact_id = f.id)
                                                                     Buffers: shared hit=40400
                                 ->  Hash (actual time=0.057..0.058 rows=200 loops=1)
                                       Buckets: 1024  Batches: 1  Memory Usage: 25kB
                                       Buffers: shared hit=9
                                       ->  Seq Scan on chunks c (actual time=0.012..0.033 rows=200 loops=1)
                                             Buffers: shared hit=9
               ->  Hash (actual time=0.021..0.021 rows=20 loops=1)
                     Buckets: 1024  Batches: 1  Memory Usage: 10kB
                     Buffers: shared hit=1
                     ->  Seq Scan on entity_types st (actual time=0.015..0.016 rows=20 loops=1)
                           Buffers: shared hit=1
         ->  Hash (actual time=0.009..0.009 rows=20 loops=1)
               Buckets: 1024  Batches: 1  Memory Usage: 10kB
               Buffers: shared hit=1
               ->  Seq Scan on entity_types ot (actual time=0.004..0.005 rows=20 loops=1)
                     Buffers: shared hit=1
 Planning:
   Buffers: shared hit=857
 Planning Time: 2.149 ms
 Execution Time: 198.765 ms
Kind-word query, before
 Sort (actual time=2384.161..2384.220 rows=1500 loops=1)
   Sort Key: g.count DESC, g.kind_word
   Sort Method: quicksort  Memory: 374kB
   Buffers: shared hit=45241
   CTE live
     ->  Seq Scan on entities e (actual time=18.572..31.697 rows=6000 loops=1)
           Filter: ((merged_into IS NULL) AND (specific_type IS NOT NULL) AND (kb_id = '0e000000-0000-7000-8000-000000000001'::uuid) AND (btrim(specific_type) <> ''::text))
           Buffers: shared hit=229
   ->  Subquery Scan on g (actual time=37.057..2380.680 rows=1500 loops=1)
         Buffers: shared hit=45235
         ->  HashAggregate (actual time=35.338..35.929 rows=1500 loops=1)
               Group Key: live.kind_word
               Batches: 1  Memory Usage: 209kB
               Buffers: shared hit=229
               ->  CTE Scan on live (actual time=18.575..33.904 rows=6000 loops=1)
                     Buffers: shared hit=229
         SubPlan 2
           ->  Subquery Scan on s (actual time=0.511..0.511 rows=1 loops=1500)
                 ->  Limit (actual time=0.510..0.510 rows=1 loops=1500)
                       ->  Sort (actual time=0.510..0.510 rows=1 loops=1500)
                             Sort Key: (count(*)) DESC, l.spelling
                             Sort Method: quicksort  Memory: 25kB
                             ->  GroupAggregate (actual time=0.509..0.509 rows=1 loops=1500)
                                   Group Key: l.spelling
                                   ->  Sort (actual time=0.507..0.507 rows=4 loops=1500)
                                         Sort Key: l.spelling
                                         Sort Method: quicksort  Memory: 25kB
                                         ->  CTE Scan on live l (actual time=0.064..0.506 rows=4 loops=1500)
                                               Filter: (kind_word = g.kind_word)
                                               Rows Removed by Filter: 5996
         SubPlan 3
           ->  Limit (actual time=0.488..0.488 rows=3 loops=1500)
                 Buffers: shared hit=11
                 ->  Sort (actual time=0.487..0.487 rows=3 loops=1500)
                       Sort Key: ((l_1.description IS NOT NULL)), l_1.created_at, l_1.id
                       Sort Method: quicksort  Memory: 25kB
                       Buffers: shared hit=11
                       ->  CTE Scan on live l_1 (actual time=0.061..0.485 rows=4 loops=1500)
                             Filter: (kind_word = g.kind_word)
                             Rows Removed by Filter: 5996
         SubPlan 4
           ->  Subquery Scan on s_1 (actual time=0.561..0.561 rows=3 loops=1500)
                 Buffers: shared hit=44995
                 ->  Limit (actual time=0.560..0.560 rows=3 loops=1500)
                       Buffers: shared hit=44995
                       ->  Sort (actual time=0.560..0.560 rows=3 loops=1500)
                             Sort Key: (count(*)) DESC, f.phrase
                             Sort Method: top-N heapsort  Memory: 25kB
                             Buffers: shared hit=44995
                             ->  GroupAggregate (actual time=0.553..0.556 rows=9 loops=1500)
                                   Group Key: f.phrase
                                   Buffers: shared hit=44995
                                   ->  Sort (actual time=0.552..0.552 rows=10 loops=1500)
                                         Sort Key: f.phrase
                                         Sort Method: quicksort  Memory: 25kB
                                         Buffers: shared hit=44995
                                         ->  Nested Loop (actual time=0.070..0.546 rows=10 loops=1500)
                                               Buffers: shared hit=44995
                                               ->  CTE Scan on live l_2 (actual time=0.066..0.521 rows=4 loops=1500)
                                                     Filter: (kind_word = g.kind_word)
                                                     Rows Removed by Filter: 5996
                                               ->  Index Scan using facts_kb_subject_fk_idx on facts f (actual time=0.003..0.006 rows=2 loops=6000)
                                                     Index Cond: ((kb_id = '0e000000-0000-7000-8000-000000000001'::uuid) AND (subject_id = l_2.id))
                                                     Filter: ((invalidated_at IS NULL) AND (phrase IS NOT NULL) AND (layer = 'open'::text))
                                                     Buffers: shared hit=44995
 Planning:
   Buffers: shared hit=515
 Planning Time: 1.078 ms
 Execution Time: 2399.651 ms
Kind-word query, after
 Sort (actual time=90.136..90.187 rows=1500 loops=1)
   Sort Key: (count(*)) DESC, live.kind_word
   Sort Method: quicksort  Memory: 374kB
   Buffers: shared hit=742
   CTE live
     ->  Seq Scan on entities e (actual time=0.177..13.635 rows=6000 loops=1)
           Filter: ((merged_into IS NULL) AND (specific_type IS NOT NULL) AND (kb_id = '0e000000-0000-7000-8000-000000000001'::uuid) AND (btrim(specific_type) <> ''::text))
           Buffers: shared hit=229
   ->  Merge Left Join (actual time=79.061..89.529 rows=1500 loops=1)
         Merge Cond: (live.kind_word = phrase_counts.kind_word)
         Buffers: shared hit=739
         ->  Merge Left Join (actual time=33.602..38.158 rows=1500 loops=1)
               Merge Cond: (live.kind_word = spellings.kind_word)
               Buffers: shared hit=240
               ->  GroupAggregate (actual time=28.909..30.957 rows=1500 loops=1)
                     Group Key: live.kind_word
                     Buffers: shared hit=240
                     ->  Sort (actual time=28.895..29.143 rows=6000 loops=1)
                           Sort Key: live.kind_word, ((live.description IS NOT NULL)), live.created_at, live.id
                           Sort Method: quicksort  Memory: 685kB
                           Buffers: shared hit=240
                           ->  CTE Scan on live (actual time=0.180..16.247 rows=6000 loops=1)
                                 Buffers: shared hit=229
               ->  GroupAggregate (actual time=4.690..6.604 rows=1500 loops=1)
                     Group Key: spellings.kind_word
                     ->  Incremental Sort (actual time=4.684..5.879 rows=1500 loops=1)
                           Sort Key: spellings.kind_word, spellings.rn
                           Presorted Key: spellings.kind_word
                           Full-sort Groups: 47  Sort Method: quicksort  Average Memory: 26kB  Peak Memory: 26kB
                           ->  Subquery Scan on spellings (actual time=4.634..5.381 rows=1500 loops=1)
                                 ->  WindowAgg (actual time=4.633..5.280 rows=1500 loops=1)
                                       Run Condition: (row_number() OVER (?) <= 3)
                                       ->  Sort (actual time=4.627..4.717 rows=1500 loops=1)
                                             Sort Key: live_1.kind_word, (count(*)) DESC, live_1.spelling
                                             Sort Method: quicksort  Memory: 130kB
                                             ->  HashAggregate (actual time=1.775..1.911 rows=1500 loops=1)
                                                   Group Key: live_1.kind_word, live_1.spelling
                                                   Batches: 1  Memory Usage: 321kB
                                                   ->  CTE Scan on live live_1 (actual time=0.000..0.339 rows=6000 loops=1)
         ->  GroupAggregate (actual time=45.456..50.768 rows=1500 loops=1)
               Group Key: phrase_counts.kind_word
               Buffers: shared hit=499
               ->  Incremental Sort (actual time=45.449..49.628 rows=4500 loops=1)
                     Sort Key: phrase_counts.kind_word, phrase_counts.rn
                     Presorted Key: phrase_counts.kind_word
                     Full-sort Groups: 137  Sort Method: quicksort  Average Memory: 26kB  Peak Memory: 26kB
                     Buffers: shared hit=499
                     ->  Subquery Scan on phrase_counts (actual time=45.394..48.478 rows=4500 loops=1)
                           Buffers: shared hit=499
                           ->  WindowAgg (actual time=45.393..48.196 rows=4500 loops=1)
                                 Run Condition: (row_number() OVER (?) <= 3)
                                 Buffers: shared hit=499
                                 ->  Sort (actual time=45.379..46.102 rows=13500 loops=1)
                                       Sort Key: l.kind_word, (count(*)) DESC, f.phrase
                                       Sort Method: quicksort  Memory: 1123kB
                                       Buffers: shared hit=499
                                       ->  HashAggregate (actual time=12.469..14.053 rows=13500 loops=1)
                                             Group Key: l.kind_word, f.phrase
                                             Batches: 1  Memory Usage: 2065kB
                                             Buffers: shared hit=499
                                             ->  Hash Join (actual time=5.521..8.069 rows=15000 loops=1)
                                                   Hash Cond: (l.id = f.subject_id)
                                                   Buffers: shared hit=499
                                                   ->  CTE Scan on live l (actual time=0.000..0.405 rows=6000 loops=1)
                                                   ->  Hash (actual time=5.456..5.456 rows=15000 loops=1)
                                                         Buckets: 16384  Batches: 1  Memory Usage: 996kB
                                                         Buffers: shared hit=499
                                                         ->  Seq Scan on facts f (actual time=0.020..3.370 rows=15000 loops=1)
                                                               Filter: ((invalidated_at IS NULL) AND (phrase IS NOT NULL) AND (kb_id = '0e000000-0000-7000-8000-000000000001'::uuid) AND (layer = 'open'::text))
                                                               Buffers: shared hit=499
 Planning:
   Buffers: shared hit=536
 Planning Time: 1.290 ms
 Execution Time: 91.056 ms
Fixture
-- Synthetic base for EXPLAIN ANALYZE: 6,000 entities, 15,000 open statements, deterministic.
-- 12,000 statements spread one per signature, plus 3,000 over 50 common phrases (groups of ~30).
\set kb '\'0e000000-0000-7000-8000-000000000001\''
INSERT INTO organizations(id,name) VALUES ('0e000000-0000-7000-8000-0000000000aa','explain-fixture');
INSERT INTO workspaces(id,org_id,name) VALUES ('0e000000-0000-7000-8000-0000000000bb','0e000000-0000-7000-8000-0000000000aa','explain-fixture');
INSERT INTO knowledge_bases(id,workspace_id,name) VALUES (:kb,'0e000000-0000-7000-8000-0000000000bb','explain-fixture');
INSERT INTO entity_types(id,kb_id,key,label,color,shape)
  SELECT ('0e000000-0000-7000-8001-' || lpad(to_hex(i),12,'0'))::uuid, :kb, 't'||i, 'T'||i, '#000', 'circle'
  FROM generate_series(0,19) i;
INSERT INTO entities(id,kb_id,canonical_name,type_id,specific_type,description)
  SELECT ('0e000000-0000-7000-8002-' || lpad(to_hex(i),12,'0'))::uuid, :kb, 'entity '||i,
         ('0e000000-0000-7000-8001-' || lpad(to_hex(i % 20),12,'0'))::uuid,
         CASE WHEN i % 3 = 0 THEN 'Kind ' ELSE 'kind ' END || (i % 1500),
         CASE WHEN i % 2 = 0 THEN 'described' END
  FROM generate_series(0,5999) i;
INSERT INTO documents(id,kb_id,filename,sha256) VALUES ('0e000000-0000-7000-8003-000000000000',:kb,'fixture','fixture');
INSERT INTO chunks(id,kb_id,document_id,seq,text)
  SELECT ('0e000000-0000-7000-8004-' || lpad(to_hex(i),12,'0'))::uuid, :kb, '0e000000-0000-7000-8003-000000000000', i,
         'chunk ' || i || ' says something quotable here'
  FROM generate_series(0,199) i;
INSERT INTO facts(id,kb_id,subject_id,object_id,object_value,layer,phrase)
  SELECT ('0e000000-0000-7000-8005-' || lpad(to_hex(n),12,'0'))::uuid, :kb,
         ('0e000000-0000-7000-8002-' || lpad(to_hex((n*13) % 5999),12,'0'))::uuid,
         CASE WHEN n % 5 = 0 THEN NULL ELSE ('0e000000-0000-7000-8002-' || lpad(to_hex((n*7) % 5987),12,'0'))::uuid END,
         CASE WHEN n % 5 = 0 THEN jsonb_build_object('value', 'v'||n) END,
         'open', 'phrase ' || (n % 2500)
  FROM generate_series(0,11999) n;
INSERT INTO fact_evidence(fact_id,chunk_id,document_id,quote_start,quote_end)
  SELECT ('0e000000-0000-7000-8005-' || lpad(to_hex(n),12,'0'))::uuid,
         ('0e000000-0000-7000-8004-' || lpad(to_hex(n % 200),12,'0'))::uuid,
         '0e000000-0000-7000-8003-000000000000', 0, 12
  FROM generate_series(0,11999) n WHERE n % 10 < 7;
INSERT INTO facts(id,kb_id,subject_id,object_id,layer,phrase)
  SELECT ('0e000000-0000-7000-8006-' || lpad(to_hex(n),12,'0'))::uuid, :kb,
         ('0e000000-0000-7000-8002-' || lpad(to_hex(n % 6000),12,'0'))::uuid,
         ('0e000000-0000-7000-8002-' || lpad(to_hex((n*7) % 6000),12,'0'))::uuid,
         'open', 'common ' || (n % 50)
  FROM generate_series(0,2999) n;
INSERT INTO fact_evidence(fact_id,chunk_id,document_id,quote_start,quote_end)
  SELECT ('0e000000-0000-7000-8006-' || lpad(to_hex(n),12,'0'))::uuid,
         ('0e000000-0000-7000-8004-' || lpad(to_hex(n % 200),12,'0'))::uuid,
         '0e000000-0000-7000-8003-000000000000', 0, 12
  FROM generate_series(0,2999) n WHERE n % 3 > 0;
ANALYZE;

This change was drafted with Claude Code (Anthropic) and reviewed by me before submitting.

Before review

  • Every commit is signed off (git commit -s)
  • cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings and cargo test --workspace pass
  • SQL under crates/utopia-store/ was tested with UTOPIA_DATABASE_URL set (those tests skip without it)

…ng the base once per group

Signed-off-by: evolv3ai <hello@evolv3.ai>

@WaylandYang WaylandYang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you. Both rewrites are what #1096 asked for, with the tiebreak and the test.

Checked at 3b61214:

  • On three real local bases the output of both queries is the same before and after, in order and as a multiset (31, 5 and 3 phrase signatures; 23, 6 and 2 kind words).
  • The store suite and the alignment tests pass with the database required, including tied_signatures_come_in_a_fixed_order.
  • The local bases are small, so the speed-up is yours to vouch for; the plans in the description show both SubPlans gone.

Merging. Thanks again for the measurements and for carrying the fix upstream.

@WaylandYang
WaylandYang merged commit 2d73d5f into deeplethe:dev Oct 9, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants