From 93f2b245e1f82a39fd99395ccdf7519a275a1fe0 Mon Sep 17 00:00:00 2001
From: Denis Rodionov <denis.rodionov@tantorlabs.com>
Date: Mon, 24 Aug 2026 09:32:35 +0300
Subject: [PATCH] PoC: rework hashjoin Bloom filter selectivity (semijoin +
 false positives)

bloom_build_side_join_ratio() currently estimates a Bloom filter's
surviving fraction as the ratio of an ordinary join's estimated
cardinality to the owner's row count. That's the wrong quantity: it's
inflated by however many build-side rows share a key, which has nothing
to do with whether a given probe tuple survives.

Rework it to estimate

    bloom_sel = s + (1 - s) * p

where s is the JOIN_SEMI selectivity of the clauses linking the owner to
the build side (FK-aware, same machinery ordinary join size estimation
already uses), and p is the filter's false-positive probability, sized
with the same bitset/hash-count policy the executor uses to build the
real runtime filter (bloom_create_probe() / bloom_probe_work_mem(),
factored out into lib/bloomfilter.c so planner and executor can't drift
apart).

Two follow-on issues surfaced by testing this:

* enumerate_bloom_filter_build_relids() enumerates build-side relid
  combinations combinatorially, without checking whether the combination
  could ever be a legal join given the query's outer/anti-join ordering.
  The previous (join-cardinality-ratio) estimate never noticed, since it
  never touches real join infrastructure; a genuine JOIN_SEMI clause
  selectivity estimate does, and errors out ("could not find RelOptInfo
  for given relids"). Added bloom_relids_are_joinable() to filter these
  out before they reach selectivity estimation.

* Separately, even a build_relids combination that is an otherwise legal
  join cannot be estimated with JOIN_SEMI clauselist_selectivity() at
  this point in planning: eqjoinsel()'s semi/anti-join handling needs an
  already-built joinrel to check RHS uniqueness, and none exists yet when
  find_interesting_bloom_filters() runs (scan-path generation happens
  before the bottom-up join search builds any joinrels). Multi-relation
  build sides fall back to the product of the per-relation semijoin
  selectivities instead. For a build relation with no direct join clause
  to the owner (reached only through another build relation - the
  classic snowflake-schema case, see the dim_2 example in the updated
  bloom_build_side_join_ratio() comment) this contributes a neutral 1.0
  to the product, so the fallback doesn't approximate that restriction,
  it misses it entirely. Confirmed both with the updated
  hashjoin_bloom_snowflake.out and with a minimal 3-table repro (t1-t2-t3
  chain, filter on t3 only, no direct t1/t3 clause): the Bloom filter
  candidate on t1 built from {t2,t3} never captures the t3 restriction.
  Flagged as a known limitation of this PoC rather than solved.

Also verified (by hand, with join_collapse_limit=1 vs. the default)
that find_interesting_bloom_filters() seeing every base relation's
RelOptInfo up front doesn't depend on join_collapse_limit-driven
restructuring of the join order search: same plan either way.

This is a proof of concept meant to make the selectivity discussion on
pgsql-hackers concrete, not a finished patch: it doesn't attempt the
multi-relation predicate-transfer case, and hasn't been benchmarked.

Includes regenerated expected/*.out for tests whose plans changed as a
result: Bloom filters now genuinely pushed down in cases the old
estimate never allowed, or (stats_ext) correctly no longer proposed
where extended statistics show it wouldn't help.
---
 src/backend/executor/nodeHash.c               |  41 +--
 src/backend/lib/bloomfilter.c                 | 159 +++++++--
 src/backend/optimizer/path/allpaths.c         | 308 ++++++++++++++----
 src/include/lib/bloomfilter.h                 |  17 +
 src/test/regress/expected/eager_aggregate.out |  80 +++--
 src/test/regress/expected/graph_table.out     |   4 +-
 src/test/regress/expected/hashjoin_bloom.out  |   6 +-
 .../expected/hashjoin_bloom_snowflake.out     | 304 +++++++++--------
 .../regress/expected/hashjoin_bloom_star.out  |  80 ++---
 src/test/regress/expected/join.out            | 146 +++++----
 src/test/regress/expected/merge.out           | 126 ++++---
 src/test/regress/expected/returning.out       |   8 +-
 src/test/regress/expected/stats_ext.out       |  10 +-
 src/test/regress/expected/subselect.out       |   4 +-
 src/test/regress/expected/updatable_views.out |  19 +-
 src/test/regress/expected/window.out          |  15 +-
 src/test/regress/expected/with.out            |  96 +++---
 17 files changed, 852 insertions(+), 571 deletions(-)

diff --git a/src/backend/executor/nodeHash.c b/src/backend/executor/nodeHash.c
index 41a6a3431bb..15470523d74 100644
--- a/src/backend/executor/nodeHash.c
+++ b/src/backend/executor/nodeHash.c
@@ -82,17 +82,6 @@ static bool ExecParallelHashTuplePrealloc(HashJoinTable hashtable,
 static void ExecParallelHashMergeCounters(HashJoinTable hashtable);
 static void ExecParallelHashCloseBatchAccessors(HashJoinTable hashtable);
 
-static bloom_filter *bloom_create_probe(int64 total_elems, int bloom_work_mem, uint64 seed);
-
-
-/*
- * Sizing for probe-oriented Bloom filters. A small bitset floor, so small
- * build sides stay resident in CPU cache, and a low cap on the number of hash
- * functions, since each is an independent (potentially cache-missing) bitset
- * access on every probe.
- */
-#define BLOOM_PROBE_MIN_BITSET_BYTES	1024
-#define BLOOM_PROBE_MAX_HASH_FUNCS		4
 
 /* ----------------------------------------------------------------
  *		ExecHash
@@ -749,7 +738,7 @@ ExecHashTableCreate(HashState *state)
 		state->bloomFilterRejected = 0;
 
 		/* Cap bloom filter at ~1/8 of work_mem, but not less than 1MB. */
-		bloom_work_mem = Max(1024, work_mem / 8);
+		bloom_work_mem = bloom_probe_work_mem(work_mem);
 
 		oldctx = MemoryContextSwitchTo(hashtable->hashCxt);
 		state->bloom_filter = bloom_create_probe((int64) Max(rows, 1.0),
@@ -3797,31 +3786,3 @@ get_hash_memory_limit(void)
 
 	return (size_t) mem_limit;
 }
-
-/*
- * Create a Bloom filter tuned for repeated per-tuple probing within a query,
- * rather than one built once and checked in bulk.
- *
- * bloom_create's sizing is a poor fit for that use in two ways, both of which
- * make each probe expensive:
- *
- * - Size.  bloom_create enforces a 1MB minimum bitset, which for a typical
- *   build side does not fit in the CPU's L2 cache, so every probe of a large
- *   scan incurs cache misses.  Here we drop that floor and let the bitset be
- *   sized to the build side, so small build sides yield cache-resident filters.
- *
- * - Hash functions.  Each of the k hash functions is an independent random
- *   access into the bitset, so a high k means many (cache-missing) accesses per
- *   probe.  We cap k well below MAX_HASH_FUNCS, trading a slightly higher
- *   false-positive rate for much cheaper probes.
- *
- * This mirrors the "small fixed number of hash functions" and "keep the filter
- * within the L2 cache" choices in the bottom-up Bloom filter paper.
- */
-bloom_filter *
-bloom_create_probe(int64 total_elems, int bloom_work_mem, uint64 seed)
-{
-	return bloom_create_custom(total_elems, bloom_work_mem,
-							   BLOOM_PROBE_MIN_BITSET_BYTES,
-							   BLOOM_PROBE_MAX_HASH_FUNCS, seed);
-}
diff --git a/src/backend/lib/bloomfilter.c b/src/backend/lib/bloomfilter.c
index 9f2f9006ab7..b1f577c3cdf 100644
--- a/src/backend/lib/bloomfilter.c
+++ b/src/backend/lib/bloomfilter.c
@@ -53,6 +53,10 @@ struct bloom_filter
 
 static int	my_bloom_power(uint64 target_bitset_bits);
 static int	optimal_k(uint64 bitset_bits, int64 total_elems);
+static void bloom_choose_parameters(int64 total_elems, int bloom_work_mem,
+					 uint64 min_bitset_bytes, int max_hash_funcs,
+					 uint64 *bitset_bytes, uint64 *bitset_bits,
+					 int *k_hash_funcs);
 static void k_hashes(bloom_filter *filter, uint32 *hashes, unsigned char *elem,
 					 size_t len);
 static inline uint32 mod_m(uint32 val, uint64 m);
@@ -88,33 +92,18 @@ bloom_create_custom(int64 total_elems, int bloom_work_mem,
 					  uint64 min_bitset_bytes, int max_hash_funcs, uint64 seed)
 {
 	bloom_filter *filter;
-	int			bloom_power;
 	uint64		bitset_bytes;
 	uint64		bitset_bits;
+	int			k_hash_funcs;
 
-	/*
-	 * Aim for two bytes per element; this is sufficient to get a false
-	 * positive rate below 1%, independent of the size of the bitset or total
-	 * number of elements.  Also, if rounding down the size of the bitset to
-	 * the next lowest power of two turns out to be a significant drop, the
-	 * false positive rate still won't exceed 2% in almost all cases.
-	 */
-	bitset_bytes = Min(bloom_work_mem * UINT64CONST(1024), total_elems * 2);
-	bitset_bytes = Max(min_bitset_bytes, bitset_bytes);
-
-	/*
-	 * Size in bits should be the highest power of two <= target.  bitset_bits
-	 * is uint64 because PG_UINT32_MAX is 2^32 - 1, not 2^32
-	 */
-	bloom_power = my_bloom_power(bitset_bytes * BITS_PER_BYTE);
-	bitset_bits = UINT64CONST(1) << bloom_power;
-	bitset_bytes = bitset_bits / BITS_PER_BYTE;
+	bloom_choose_parameters(total_elems, bloom_work_mem,
+						min_bitset_bytes, max_hash_funcs,
+						&bitset_bytes, &bitset_bits, &k_hash_funcs);
 
 	/* Allocate bloom filter with unset bitset */
 	filter = palloc0(offsetof(bloom_filter, bitset) +
 					 sizeof(unsigned char) * bitset_bytes);
-	filter->k_hash_funcs = Min(optimal_k(bitset_bits, total_elems),
-							   max_hash_funcs);
+	filter->k_hash_funcs = k_hash_funcs;
 	filter->seed = seed;
 	filter->m = bitset_bits;
 
@@ -129,6 +118,136 @@ bloom_create(int64 total_elems, int bloom_work_mem, uint64 seed)
 								 seed);
 }
 
+/*
+ * bloom_choose_parameters
+ *	Work out the bitset size (in bytes and bits) and the number of hash
+ *	functions for a Bloom filter holding total_elems elements within a
+ *	bloom_work_mem (KB) budget, subject to min_bitset_bytes and
+ *	max_hash_funcs.
+ *
+ * This is the sizing logic bloom_create_custom() used to have inline;
+ * factored out here so that bloom_estimate_probe_fpr() can compute a
+ * false-positive estimate using exactly the (m, k) the executor will
+ * actually build the filter with, rather than a second, possibly-drifting
+ * copy of the same math.
+ */
+static void
+bloom_choose_parameters(int64 total_elems, int bloom_work_mem,
+				uint64 min_bitset_bytes, int max_hash_funcs,
+				uint64 *bitset_bytes, uint64 *bitset_bits,
+				int *k_hash_funcs)
+{
+	uint64		bytes;
+	uint64		bits;
+	int			bloom_power;
+
+	/*
+	 * Aim for two bytes per element; this is sufficient to get a false
+	 * positive rate below 1%, independent of the size of the bitset or total
+	 * number of elements.  Also, if rounding down the size of the bitset to
+	 * the next lowest power of two turns out to be a significant drop, the
+	 * false positive rate still won't exceed 2% in almost all cases.
+	 */
+	bytes = Min(bloom_work_mem * UINT64CONST(1024), total_elems * 2);
+	bytes = Max(min_bitset_bytes, bytes);
+
+	/*
+	 * Size in bits should be the highest power of two <= target.  bits is
+	 * uint64 because PG_UINT32_MAX is 2^32 - 1, not 2^32.
+	 */
+	bloom_power = my_bloom_power(bytes * BITS_PER_BYTE);
+	bits = UINT64CONST(1) << bloom_power;
+
+	*bitset_bits = bits;
+	*bitset_bytes = bits / BITS_PER_BYTE;
+	*k_hash_funcs = Min(optimal_k(bits, total_elems), max_hash_funcs);
+}
+
+/*
+ * Create a Bloom filter tuned for repeated per-tuple probing within a
+ * query, rather than one built once and then checked in bulk.
+ *
+ * bloom_create()'s sizing is a poor fit for that use in two ways, both of
+ * which make each probe expensive: it enforces a 1MB minimum bitset, which
+ * for a typical build side does not fit in the CPU's L2 cache, so every
+ * probe incurs cache misses; and each of its (up to MAX_HASH_FUNCS) hash
+ * functions is an independent random bitset access, so a high k means many
+ * such accesses per probe. Here we drop the 1MB floor (letting small build
+ * sides yield cache-resident filters) and cap k well below MAX_HASH_FUNCS,
+ * trading a slightly higher false-positive rate for much cheaper probes.
+ *
+ * (Moved here from nodeHash.c: the planner's false-positive estimate, in
+ * bloom_estimate_probe_fpr() below, needs to size a filter exactly the way
+ * this function does, so both now live next to bloom_choose_parameters().)
+ */
+bloom_filter *
+bloom_create_probe(int64 total_elems, int bloom_work_mem, uint64 seed)
+{
+	return bloom_create_custom(total_elems, bloom_work_mem,
+							   BLOOM_PROBE_MIN_BITSET_BYTES,
+							   BLOOM_PROBE_MAX_HASH_FUNCS, seed);
+}
+
+/*
+ * bloom_probe_work_mem
+ *	Memory budget (bloom_work_mem, i.e. KB) for a probe-oriented Bloom
+ *	filter built from a hash join whose overall memory budget is
+ *	total_work_mem KB.
+ *
+ * Shared by the executor, which builds the filter (see ExecHashTableCreate()
+ * in nodeHash.c), and the planner, which must estimate that same filter's
+ * false-positive rate (see bloom_estimate_probe_fpr() and its caller in
+ * allpaths.c): both need to agree on the same number, or the estimate
+ * stops corresponding to what actually gets built.
+ */
+int
+bloom_probe_work_mem(int total_work_mem)
+{
+	return Max(1024, total_work_mem / 8);
+}
+
+/*
+ * bloom_estimate_probe_fpr
+ *	Estimate the false-positive probability of a probe-oriented Bloom
+ *	filter (see bloom_create_probe()) built from total_elems elements
+ *	within a bloom_work_mem (KB) budget.
+ *
+ * Uses bloom_choose_parameters() with the same arguments bloom_create_probe()
+ * uses, so this corresponds to the filter the executor will actually build,
+ * not a separately-tuned approximation of it.
+ *
+ * Under the usual uniform-hashing approximation, after inserting n elements
+ * with k hash functions into an m-bit filter, the probability that a given
+ * bit is still zero is approximately exp(-k*n/m), so the probability that
+ * it is set is 1 - exp(-k*n/m).  A false positive requires all k probed
+ * bits to be set, hence the k-th power below.
+ */
+double
+bloom_estimate_probe_fpr(int64 total_elems, int bloom_work_mem)
+{
+	uint64		bitset_bytes;
+	uint64		bitset_bits;
+	int			k_hash_funcs;
+	double		bit_set_prob;
+	double		fpr;
+
+	Assert(total_elems > 0);
+	Assert(bloom_work_mem > 0);
+
+	bloom_choose_parameters(total_elems, bloom_work_mem,
+						BLOOM_PROBE_MIN_BITSET_BYTES,
+						BLOOM_PROBE_MAX_HASH_FUNCS,
+						&bitset_bytes, &bitset_bits, &k_hash_funcs);
+
+	/* use expm1() rather than 1-exp() for accuracy when k*n/m is small */
+	bit_set_prob = -expm1(-((double) k_hash_funcs * (double) total_elems) /
+						   (double) bitset_bits);
+	fpr = pow(bit_set_prob, k_hash_funcs);
+
+	/* guard against floating-point roundoff pushing this outside [0,1] */
+	return Min(1.0, Max(0.0, fpr));
+}
+
 /*
  * Free Bloom filter
  */
diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index 2a6755569f1..bd6ff0a7081 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -25,6 +25,7 @@
 #include "catalog/pg_operator.h"
 #include "catalog/pg_proc.h"
 #include "foreign/fdwapi.h"
+#include "lib/bloomfilter.h"
 #include "miscadmin.h"
 #include "nodes/extensible.h"
 #include "nodes/makefuncs.h"
@@ -114,7 +115,12 @@ static List *find_interesting_bloom_filters(PlannerInfo *root,
 static Selectivity bloom_build_side_join_ratio(PlannerInfo *root,
 											   List *build_sides,
 											   RelOptInfo *rel,
-											   Relids build_relids);
+											   Relids build_relids,
+											   List **clauses_by_rel);
+static Selectivity estimate_bloom_semijoin_selectivity(PlannerInfo *root,
+											   RelOptInfo *rel,
+											   Relids build_relids,
+											   List **clauses_by_rel);
 static void generate_expected_filter_paths(PlannerInfo *root, RelOptInfo *rel);
 static void set_tablesample_rel_size(PlannerInfo *root, RelOptInfo *rel,
 									 RangeTblEntry *rte);
@@ -181,6 +187,7 @@ static void recurse_push_qual(Node *setOp, Query *topquery,
 							  RangeTblEntry *rte, Index rti, Node *qual);
 static void remove_unused_subquery_outputs(Query *subquery, RelOptInfo *rel,
 										   Bitmapset *extra_used_attrs);
+static bool bloom_relids_are_joinable(PlannerInfo *root, Relids relids);
 
 
 /*
@@ -1014,74 +1021,256 @@ bloom_filter_recipient_reachable(PlannerInfo *root, Index owner_relid,
 
 /*
  * bloom_build_side_join_ratio
- *	  Estimate the fraction of owner (probe) rows that survive a Bloom filter
- *	  built over the join relation 'build_relids'.
+ *		Estimate the fraction of owner (probe) rows that survive a Bloom
+ *		filter built over the join relation 'build_relids'.
  *
- * The per-owner-clause semijoin estimate used elsewhere only looks at the
- * clauses that directly join the owner to a build relation, using each build
- * relation's base statistics.  It therefore ignores selectivity contributed by
- * the rest of the build side: restrictions on build relations (possibly ones
- * that have no direct join clause to the owner at all) and the join clauses
- * among the build relations.
+ * A Bloom filter behaves like an approximate semijoin: ignoring false
+ * positives, an owner-side tuple survives exactly when its join key has a
+ * real match somewhere in build_relids.  That's the semijoin selectivity of
+ * the clauses linking rel to build_relids - not the selectivity of an
+ * ordinary join between them, which is inflated by however many build-side
+ * rows share a key and has nothing to do with whether a given probe tuple
+ * survives (a probe key that matches one build row survives exactly as
+ * often as one that matches a thousand).
  *
- * Consider a join on a snowflake schema with a fact table and two dimensions.
+ * This matters most once build_relids spans more than one relation. Only
+ * looking at the clauses that directly join the owner to a single build
+ * relation ignores selectivity contributed by the rest of the build side:
+ * restrictions on build relations that have no direct join clause to the
+ * owner at all, and join clauses among the build relations themselves.
+ * Consider a snowflake-schema join:
  *
- * SELECT * FROM fact_table
- *          JOIN dim_1 ON (fact_table.id1 = dim_1.id)
- *          JOIN dim_2 ON (dim_1.id2 = dim_2.id)
- *          WHERE dim_2.x = 10;
+ *		SELECT * FROM fact_table
+ *				 JOIN dim_1 ON (fact_table.id1 = dim_1.id)
+ *				 JOIN dim_2 ON (dim_1.id2 = dim_2.id)
+ *				 WHERE dim_2.x = 10;
  *
- * The dimension table dim_2 is filtered by a WHERE clause and only reaches the
- * fact table through another dimension table. The direct estimate is 1.0 (for
- * the join between fact_table and dim_1), even though the join may eliminate
- * almost every fact row (depending on how many tuples survive the WHERE).
+ * dim_2 is filtered by a WHERE clause and only reaches fact_table through
+ * dim_1; a filter built from {dim_1} alone can't see that restriction, even
+ * though it may eliminate almost every fact row.
  *
- * We approximate the surviving fraction as the estimated cardinality of the
- * join of {owner} + build_relids divided by the owner's cardinality.  Since a
- * join emits at least one row per surviving owner row, this ratio is an upper
- * bound on the true surviving fraction and so never makes the filter look more
- * selective than it is.
+ * We'd like to estimate that properly - the JOIN_SEMI selectivity of the
+ * clauses against the whole build_relids set at once - but we can only do
+ * that safely when build_relids is a single relation: eqjoinsel()'s
+ * semi/anti-join handling calls find_join_input_rel() on the RHS relids to
+ * check uniqueness, which requires an already-built joinrel, and no such
+ * joinrel exists yet at this point in planning (scan-path generation runs
+ * before the bottom-up join search ever builds one). So for a
+ * multi-relation build side we fall back to the product of the
+ * per-relation semijoin selectivities instead (see
+ * estimate_bloom_semijoin_selectivity()). For a build relation with no
+ * direct clause to the owner - dim_2 above, reached only through dim_1
+ * - that relation contributes a neutral 1.0 to the product, so the
+ * fallback doesn't approximate the dim_2 restriction, it misses it
+ * entirely (confirmed with a minimal t1-t2-t3 repro: no direct t1/t3
+ * clause, and the {t2,t3} candidate on t1 never captures the t3 filter).
+ * It is still no worse than an ordinary-join-cardinality ratio would be.
+ * A real fix needs either a way to estimate JOIN_SEMI selectivity without
+ * an existing joinrel, or restructuring so this runs after one exists;
+ * left as future work.
  *
- * The join cardinality is the product of the build relations' (restriction
- * reduced) row estimates times the selectivity of a non-redundant set of
- * hashjoinable equality join clauses spanning the relation set.
+ * We additionally account for the filter's false-positive rate, mirroring
+ * the sizing policy ExecHashTableCreate() applies to the actual runtime
+ * filter (bloom_create_probe(), bloom_probe_work_mem(), both in
+ * lib/bloomfilter.c). Letting s be the semijoin selectivity and p the
+ * false-positive probability, a probe tuple survives with probability
  *
- * XXX We can't call generate_join_implied_equalities(), because all of this
- * happens before we get to create the RelOptInfos for joins. So we assemble
- * that clause set on our own.  Only JOIN_INNER selectivity is used, so no
- * join-relation lookup is needed either.
+ *		bloom_sel = s + (1 - s) * p
  *
- * XXX The RelOptInfo for baserels however already exist, and so we can use
- * those without any issue.
+ * i.e. it either has a real match (probability s), or it doesn't but still
+ * passes due to a hash collision (probability (1 - s) * p). p needs n, the
+ * number of distinct build-side keys; we approximate it with the build
+ * side's own estimated row count, which overstates n (and so understates
+ * p) whenever the build key has duplicates - the same simplification
+ * Zeyl et al. use for the equivalent quantity in "Including Bloom Filters
+ * in Bottom-up Optimization" (SIGMOD-Companion'25, Section 3.5).
  *
- * XXX What about RelOptInfos representing joins from previous join planning
- * cycle (for queries above join_collapse_limit)? Those should also exist,
- * no? We should still be able to access the baserels one by one.
+ * clauses_by_rel is find_interesting_bloom_filters()'s per-relation clause
+ * array; the caller still needs it afterwards (it ends up in the
+ * resulting ExpectedFilter), so this function and
+ * estimate_bloom_semijoin_selectivity() must not consume or modify it.
  */
 static Selectivity
 bloom_build_side_join_ratio(PlannerInfo *root, List *build_sides,
-							RelOptInfo *rel, Relids build_relids)
+							RelOptInfo *rel, Relids build_relids,
+							List **clauses_by_rel)
 {
-	double	nrows = DBL_MAX;
-	Relids	relids;
-
+	double	build_rows = -1.0;
+	Selectivity semi_sel;
+	Selectivity fpr;
 	ListCell *lc;
 
-	relids = bms_copy(build_relids);
-	relids = bms_add_member(relids, rel->relid);
-
-	foreach (lc, build_sides)
+	/*
+	 * Look up the build side's own row estimate: build_relids alone, not
+	 * joined with the owner. This is exactly the relids
+	 * enumerate_bloom_filter_build_relids() produces, so, unlike the
+	 * {owner} UNION build_relids lookup this replaces, it never needs the
+	 * enumeration to reach one level higher than the build side itself.
+	 */
+	foreach(lc, build_sides)
 	{
-		SimpleRelOptInfo *rel = (SimpleRelOptInfo *) lfirst(lc);
+		SimpleRelOptInfo *bside = (SimpleRelOptInfo *) lfirst(lc);
 
-		if (bms_equal(rel->relids, relids))
+		if (bms_equal(bside->relids, build_relids))
 		{
-			nrows = rel->rows;
+			build_rows = bside->rows;
 			break;
 		}
 	}
 
-	return Min(1.0, Max(0.0, nrows / rel->rows));
+	/* XXX paranoia: build_relids is always one of build_sides by construction */
+	Assert(build_rows >= 0.0);
+	build_rows = Max(build_rows, 1.0);
+
+	semi_sel = estimate_bloom_semijoin_selectivity(root, rel, build_relids,
+												clauses_by_rel);
+
+	fpr = bloom_estimate_probe_fpr((int64) ceil(build_rows),
+								bloom_probe_work_mem(work_mem));
+
+	return Min(1.0, Max(0.0, semi_sel + (1.0 - semi_sel) * fpr));
+}
+
+/*
+ * estimate_bloom_semijoin_selectivity
+ *	Estimate the fraction of rel's rows that have at least one matching
+ *	row among build_relids, i.e. the selectivity of the relevant clauses
+ *	as a JOIN_SEMI between rel and build_relids.
+ *
+ * For a single build relation this is a real JOIN_SEMI clauselist_selectivity
+ * estimate (see the "single relation" branch below); that is always safe,
+ * because a single relid always has a real base-rel RelOptInfo, no matter
+ * when in planning this runs.
+ *
+ * For a build side spanning several relations, we CANNOT do the same thing
+ * against the combined build_relids: eqjoinsel()'s JOIN_SEMI/JOIN_ANTI
+ * handling calls find_join_input_rel() on the RHS relids to check
+ * uniqueness, and that requires an already-built joinrel. No such joinrel
+ * exists yet when find_interesting_bloom_filters() runs (scan-path
+ * generation happens before the bottom-up join search ever builds one)
+ * regardless of whether the combination is otherwise a perfectly ordinary,
+ * legal join (bloom_relids_are_joinable() only rules out combinations that
+ * could never be legal at all; it does not help here). So for a
+ * multi-relation build side we fall back to the product of the
+ * per-relation semijoin selectivities, each computed safely against a
+ * single base relation. A build relation with no direct clause to the
+ * owner contributes a neutral 1.0 to that product rather than any real
+ * reduction, so a restriction reachable only through another build
+ * relation is missed entirely, not just approximated (see the dim_2
+ * example in bloom_build_side_join_ratio()). A multi-column FK spanning
+ * more than one build relation is also no longer recognized as a unit.
+ * Both are the same kind of simplification already made for the
+ * false-positive NDV estimate in bloom_build_side_join_ratio(); a
+ * tighter joint estimate is future work.
+ *
+ * clauses_by_rel is find_interesting_bloom_filters()'s per-relation clause
+ * array (indexed by relid, same convention as elsewhere in that function).
+ * We only ever read from it, never modify or free its contents.
+ */
+static Selectivity
+estimate_bloom_semijoin_selectivity(PlannerInfo *root, RelOptInfo *rel,
+								Relids build_relids, List **clauses_by_rel)
+{
+	if (bms_membership(build_relids) == BMS_MULTIPLE)
+	{
+		Selectivity sel = 1.0;
+		int			r = -1;
+
+		while ((r = bms_next_member(build_relids, r)) >= 0)
+		{
+			Relids		single = bms_make_singleton(r);
+
+			sel *= estimate_bloom_semijoin_selectivity(root, rel, single,
+													clauses_by_rel);
+			bms_free(single);
+		}
+
+		CLAMP_PROBABILITY(sel);
+		return sel;
+	}
+	else
+	{
+		SpecialJoinInfo sjinfo;
+		List	*worklist;
+		List	*bare_clauses = NIL;
+		Selectivity fkselec;
+		Selectivity selec;
+		ListCell	*lc;
+		int			relid = bms_singleton_member(build_relids);
+
+		init_dummy_sjinfo(&sjinfo, rel->relids, build_relids);
+		sjinfo.jointype = JOIN_SEMI;
+
+		/* our own copy: the caller's per-relation lists must stay untouched */
+		worklist = list_copy(clauses_by_rel[relid]);
+
+		fkselec = get_foreign_key_join_selectivity(root, rel->relids,
+									build_relids, &sjinfo,
+									&worklist);
+
+		/* bare clauses, to avoid caching this JOIN_SEMI estimate on the RestrictInfos */
+		foreach(lc, worklist)
+			bare_clauses = lappend(bare_clauses,
+							((RestrictInfo *) lfirst(lc))->clause);
+
+		selec = fkselec * clauselist_selectivity(root, bare_clauses, 0,
+									JOIN_SEMI, &sjinfo);
+		CLAMP_PROBABILITY(selec);
+
+		list_free(bare_clauses);
+		list_free(worklist);
+
+		return selec;
+	}
+}
+
+/*
+ * bloom_relids_are_joinable
+ *      Would forming a join over exactly "relids" ever be legal, given the
+ *      outer/semi/anti-join ordering constraints already present in this
+ *      query (root->join_info_list)?
+ *
+ * enumerate_bloom_filter_build_relids() enumerates build-side relid
+ * combinations purely combinatorially, without checking this, so it can
+ * (and, with NOT EXISTS / anti-joins in the query, does) produce a
+ * build_relids set that can never become a real joinrel. Feeding such a set
+ * into ordinary selectivity estimators (eqjoinsel, via
+ * clauselist_selectivity) is unsafe: they assume they're being asked about
+ * a combination the planner could legitimately form, and error out (via
+ * find_join_input_rel()) when that assumption doesn't hold.
+ *
+ * This mirrors the ordering check join_is_legal() applies to a real
+ * two-relation join attempt, generalized to an arbitrary relid set.
+ */
+static bool
+bloom_relids_are_joinable(PlannerInfo *root, Relids relids)
+{
+	ListCell   *lc;
+
+	foreach(lc, root->join_info_list)
+	{
+		SpecialJoinInfo *sjinfo = (SpecialJoinInfo *) lfirst(lc);
+
+		/* Ignore special joins unrelated to this relid set. */
+		if (!bms_overlap(relids, sjinfo->min_lefthand) &&
+			!bms_overlap(relids, sjinfo->min_righthand))
+			continue;
+
+		/*
+		 * If relids contains only part of one side of this special join,
+		 * the rest of that side is still required to be present before the
+		 * special join's ordering constraint is satisfied, so relids
+		 * could never be a legal join by itself.
+		 */
+		if (bms_overlap(relids, sjinfo->min_lefthand) &&
+			!bms_is_subset(sjinfo->min_lefthand, relids))
+			return false;
+		if (bms_overlap(relids, sjinfo->min_righthand) &&
+			!bms_is_subset(sjinfo->min_righthand, relids))
+			return false;
+	}
+
+	return true;
 }
 
 /*
@@ -1314,6 +1503,9 @@ find_interesting_bloom_filters(PlannerInfo *root, RelOptInfo *rel)
 		if (bms_is_member(rel->relid, brel->relids))
 			continue;
 
+		if (!bloom_relids_are_joinable(root, brel->relids))
+			continue;
+
 		/*
 		 * Collect the owner-side clauses (the filter keys) and the product of
 		 * the per-relation semijoin selectivities.
@@ -1333,21 +1525,15 @@ find_interesting_bloom_filters(PlannerInfo *root, RelOptInfo *rel)
 			continue;
 
 		/*
-		 * For a build side that spans several relations, refine the estimate
-		 * with the build-side join-cardinality ratio and keep whichever bound
-		 * is tighter.  This is what accounts for restrictions on build
-		 * relations and for join clauses among them -- including build
-		 * relations that have no direct join clause to the owner.  A
-		 * single-relation build side has no such internal structure, so the
-		 * per-relation semijoin estimate already covers it.
-		 *
-		 * XXX I think we could just do this for all build sides, and not
-		 * calculate the clause selectivity at all. One effect is that we
-		 * would automatically consider only filters pushed down from the
-		 * smaller to the larger relation (which is one of the heuristics
-		 * suggested by the paper anyway).
+		 * Estimate the surviving fraction (semijoin selectivity plus Bloom
+		 * false positives). For a single-relation build side this is a
+		 * real JOIN_SEMI estimate; for a multi-relation build side it is
+		 * instead the product of the per-relation estimates, because a
+		 * real joint estimate would need a joinrel that does not exist yet
+		 * at this point in planning (see estimate_bloom_semijoin_selectivity).
 		 */
-		sel = bloom_build_side_join_ratio(root, build_sides, rel, brel->relids);
+		sel = bloom_build_side_join_ratio(root, build_sides, rel,
+										brel->relids, clauses_by_rel);
 
 		Assert((sel >= 0.0) && (sel <= 1.0));
 
diff --git a/src/include/lib/bloomfilter.h b/src/include/lib/bloomfilter.h
index 9a79e83e7d9..fd928161b5d 100644
--- a/src/include/lib/bloomfilter.h
+++ b/src/include/lib/bloomfilter.h
@@ -21,6 +21,23 @@ extern bloom_filter *bloom_create_custom(int64 total_elems, int bloom_work_mem,
 										 uint64 min_bitset_bytes,
 										 int max_hash_funcs,
 										 uint64 seed);
+extern bloom_filter *bloom_create_probe(int64 total_elems, int bloom_work_mem,
+										 uint64 seed);
+extern int bloom_probe_work_mem(int total_work_mem);
+extern double bloom_estimate_probe_fpr(int64 total_elems, int bloom_work_mem);
+
+/*
+ * Sizing policy for probe-oriented Bloom filters (see bloom_create_probe()):
+ * a small bitset floor, so small build sides stay CPU-cache-resident, and a
+ * low cap on the number of hash functions, since each is an independent
+ * (potentially cache-missing) bitset access per probe.
+ *
+ * (Moved here from nodeHash.c: the planner side needs these constants too,
+ * to size its bloom_estimate_probe_fpr() estimate the same way.)
+ */
+#define BLOOM_PROBE_MIN_BITSET_BYTES	1024
+#define BLOOM_PROBE_MAX_HASH_FUNCS		4
+
 extern void bloom_free(bloom_filter *filter);
 extern void bloom_add_element(bloom_filter *filter, unsigned char *elem,
 							  size_t len);
diff --git a/src/test/regress/expected/eager_aggregate.out b/src/test/regress/expected/eager_aggregate.out
index 091ae48a92b..bcb8978b6b0 100644
--- a/src/test/regress/expected/eager_aggregate.out
+++ b/src/test/regress/expected/eager_aggregate.out
@@ -34,14 +34,16 @@ GROUP BY t1.a ORDER BY t1.a;
                Hash Cond: (t1.b = t2.b)
                ->  Seq Scan on public.eager_agg_t1 t1
                      Output: t1.a, t1.b, t1.c
+                     Bloom Filter 1: keys=(t1.b) expected=3.2%
                ->  Hash
                      Output: t2.b, (PARTIAL avg(t2.c))
+                     Bloom Filter 1
                      ->  Partial HashAggregate
                            Output: t2.b, PARTIAL avg(t2.c)
                            Group Key: t2.b
                            ->  Seq Scan on public.eager_agg_t2 t2
                                  Output: t2.a, t2.b, t2.c
-(18 rows)
+(20 rows)
 
 SELECT t1.a, avg(t2.c)
   FROM eager_agg_t1 t1
@@ -80,8 +82,10 @@ GROUP BY t1.a ORDER BY t1.a;
                Hash Cond: (t1.b = t2.b)
                ->  Seq Scan on public.eager_agg_t1 t1
                      Output: t1.a, t1.b, t1.c
+                     Bloom Filter 1: keys=(t1.b) expected=3.2%
                ->  Hash
                      Output: t2.b, (PARTIAL avg(t2.c))
+                     Bloom Filter 1
                      ->  Partial GroupAggregate
                            Output: t2.b, PARTIAL avg(t2.c)
                            Group Key: t2.b
@@ -90,7 +94,7 @@ GROUP BY t1.a ORDER BY t1.a;
                                  Sort Key: t2.b
                                  ->  Seq Scan on public.eager_agg_t2 t2
                                        Output: t2.c, t2.b
-(21 rows)
+(23 rows)
 
 SELECT t1.a, avg(t2.c)
   FROM eager_agg_t1 t1
@@ -121,8 +125,8 @@ SELECT t1.a, avg(t2.c + t3.c)
   JOIN eager_agg_t2 t2 ON t1.b = t2.b
   JOIN eager_agg_t3 t3 ON t2.a = t3.a
 GROUP BY t1.a ORDER BY t1.a;
-                                  QUERY PLAN                                  
-------------------------------------------------------------------------------
+                                   QUERY PLAN                                    
+---------------------------------------------------------------------------------
  Finalize GroupAggregate
    Output: t1.a, avg((t2.c + t3.c))
    Group Key: t1.a
@@ -134,21 +138,25 @@ GROUP BY t1.a ORDER BY t1.a;
                Hash Cond: (t1.b = t2.b)
                ->  Seq Scan on public.eager_agg_t1 t1
                      Output: t1.a, t1.b, t1.c
+                     Bloom Filter 2: keys=(t1.b) expected=3.2%
                ->  Hash
                      Output: t2.b, (PARTIAL avg((t2.c + t3.c)))
+                     Bloom Filter 2
                      ->  Partial HashAggregate
                            Output: t2.b, PARTIAL avg((t2.c + t3.c))
                            Group Key: t2.b
                            ->  Hash Join
                                  Output: t2.c, t2.b, t3.c
-                                 Hash Cond: (t3.a = t2.a)
-                                 ->  Seq Scan on public.eager_agg_t3 t3
-                                       Output: t3.a, t3.b, t3.c
+                                 Hash Cond: (t2.a = t3.a)
+                                 ->  Seq Scan on public.eager_agg_t2 t2
+                                       Output: t2.a, t2.b, t2.c
+                                       Bloom Filter 1: keys=(t2.a) expected=3.2%
                                  ->  Hash
-                                       Output: t2.c, t2.b, t2.a
-                                       ->  Seq Scan on public.eager_agg_t2 t2
-                                             Output: t2.c, t2.b, t2.a
-(25 rows)
+                                       Output: t3.c, t3.a
+                                       Bloom Filter 1
+                                       ->  Seq Scan on public.eager_agg_t3 t3
+                                             Output: t3.c, t3.a
+(29 rows)
 
 SELECT t1.a, avg(t2.c + t3.c)
   FROM eager_agg_t1 t1
@@ -176,8 +184,8 @@ SELECT t1.a, avg(t2.c + t3.c)
   JOIN eager_agg_t2 t2 ON t1.b = t2.b
   JOIN eager_agg_t3 t3 ON t2.a = t3.a
 GROUP BY t1.a ORDER BY t1.a;
-                                     QUERY PLAN                                     
-------------------------------------------------------------------------------------
+                                      QUERY PLAN                                       
+---------------------------------------------------------------------------------------
  Finalize GroupAggregate
    Output: t1.a, avg((t2.c + t3.c))
    Group Key: t1.a
@@ -189,8 +197,10 @@ GROUP BY t1.a ORDER BY t1.a;
                Hash Cond: (t1.b = t2.b)
                ->  Seq Scan on public.eager_agg_t1 t1
                      Output: t1.a, t1.b, t1.c
+                     Bloom Filter 2: keys=(t1.b) expected=3.2%
                ->  Hash
                      Output: t2.b, (PARTIAL avg((t2.c + t3.c)))
+                     Bloom Filter 2
                      ->  Partial GroupAggregate
                            Output: t2.b, PARTIAL avg((t2.c + t3.c))
                            Group Key: t2.b
@@ -199,14 +209,16 @@ GROUP BY t1.a ORDER BY t1.a;
                                  Sort Key: t2.b
                                  ->  Hash Join
                                        Output: t2.c, t2.b, t3.c
-                                       Hash Cond: (t3.a = t2.a)
-                                       ->  Seq Scan on public.eager_agg_t3 t3
-                                             Output: t3.a, t3.b, t3.c
+                                       Hash Cond: (t2.a = t3.a)
+                                       ->  Seq Scan on public.eager_agg_t2 t2
+                                             Output: t2.a, t2.b, t2.c
+                                             Bloom Filter 1: keys=(t2.a) expected=3.2%
                                        ->  Hash
-                                             Output: t2.c, t2.b, t2.a
-                                             ->  Seq Scan on public.eager_agg_t2 t2
-                                                   Output: t2.c, t2.b, t2.a
-(28 rows)
+                                             Output: t3.c, t3.a
+                                             Bloom Filter 1
+                                             ->  Seq Scan on public.eager_agg_t3 t3
+                                                   Output: t3.c, t3.a
+(32 rows)
 
 SELECT t1.a, avg(t2.c + t3.c)
   FROM eager_agg_t1 t1
@@ -400,14 +412,16 @@ GROUP BY t1.a ORDER BY t1.a;
                Hash Cond: (t1.b = t2.b)
                ->  Seq Scan on public.eager_agg_t1 t1
                      Output: t1.a, t1.b, t1.c
+                     Bloom Filter 1: keys=(t1.b) expected=3.2%
                ->  Hash
                      Output: t2.b, (PARTIAL avg(t2.c))
+                     Bloom Filter 1
                      ->  Partial HashAggregate
                            Output: t2.b, PARTIAL avg(t2.c)
                            Group Key: t2.b
                            ->  Seq Scan on public.eager_agg_t2 t2
                                  Output: t2.a, t2.b, t2.c
-(18 rows)
+(20 rows)
 
 SELECT t1.a, avg(t2.c)
   FROM eager_agg_t1 t1
@@ -442,11 +456,13 @@ GROUP BY t1.a ORDER BY t1.a;
    ->  Sort
          Sort Key: t1.a
          ->  Hash Join
-               Hash Cond: (t2.b = t1.b)
-               ->  Seq Scan on eager_agg_t2 t2
+               Hash Cond: (t1.b = t2.b)
+               ->  Seq Scan on eager_agg_t1 t1
+                     Bloom Filter 1: keys=(b)
                ->  Hash
-                     ->  Seq Scan on eager_agg_t1 t1
-(9 rows)
+                     Bloom Filter 1
+                     ->  Seq Scan on eager_agg_t2 t2
+(11 rows)
 
 EXPLAIN (COSTS OFF)
 SELECT t1.a, avg(t2.c) FILTER (WHERE random() > 0.5)
@@ -460,11 +476,13 @@ GROUP BY t1.a ORDER BY t1.a;
    ->  Sort
          Sort Key: t1.a
          ->  Hash Join
-               Hash Cond: (t2.b = t1.b)
-               ->  Seq Scan on eager_agg_t2 t2
+               Hash Cond: (t1.b = t2.b)
+               ->  Seq Scan on eager_agg_t1 t1
+                     Bloom Filter 1: keys=(b)
                ->  Hash
-                     ->  Seq Scan on eager_agg_t1 t1
-(9 rows)
+                     Bloom Filter 1
+                     ->  Seq Scan on eager_agg_t2 t2
+(11 rows)
 
 -- Eager aggregation must not push a partial aggregate onto the inner side of a
 -- SEMI or ANTI join
@@ -530,14 +548,16 @@ GROUP BY t2.b ORDER BY t2.b;
                Hash Cond: (t1.b = t2.b)
                ->  Seq Scan on public.eager_agg_t1 t1
                      Output: t1.a, t1.b, t1.c
+                     Bloom Filter 1: keys=(t1.b) expected=3.2%
                ->  Hash
                      Output: t2.b, (PARTIAL count(*))
+                     Bloom Filter 1
                      ->  Partial HashAggregate
                            Output: t2.b, PARTIAL count(*)
                            Group Key: t2.b
                            ->  Seq Scan on public.eager_agg_t2 t2
                                  Output: t2.a, t2.b, t2.c
-(18 rows)
+(20 rows)
 
 SELECT t2.b, count(*)
   FROM eager_agg_t2 t2
diff --git a/src/test/regress/expected/graph_table.out b/src/test/regress/expected/graph_table.out
index 5465c785fe7..9100cba0a64 100644
--- a/src/test/regress/expected/graph_table.out
+++ b/src/test/regress/expected/graph_table.out
@@ -433,16 +433,16 @@ SELECT src, conn, dest, lprop1, vprop2, vprop1 FROM GRAPH_TABLE (g1 MATCH (a IS
 SELECT * FROM GRAPH_TABLE (g1 MATCH (v1 IS vl2)-[conn]-(v2) COLUMNS (v1.vname AS v1name, conn.ename AS cname, v2.vname AS v2name));
  v1name | cname | v2name 
 --------+-------+--------
- v21    | e122  | v12
  v22    | e121  | v11
+ v21    | e122  | v12
  v22    | e231  | v32
 (3 rows)
 
 SELECT * FROM GRAPH_TABLE (g1 MATCH (v1 IS vl2)-(v2) COLUMNS (v1.vname AS v1name, v2.vname AS v2name));
  v1name | v2name 
 --------+--------
- v21    | v12
  v22    | v11
+ v21    | v12
  v22    | v32
 (3 rows)
 
diff --git a/src/test/regress/expected/hashjoin_bloom.out b/src/test/regress/expected/hashjoin_bloom.out
index 3ca58f850a1..86456073ab9 100644
--- a/src/test/regress/expected/hashjoin_bloom.out
+++ b/src/test/regress/expected/hashjoin_bloom.out
@@ -113,10 +113,10 @@ JOIN bloom_multi_dim d ON (f.id1 = d.id1 AND f.id2 = d.id2)
 WHERE d.r < 0.5;
                                                   QUERY PLAN                                                  
 --------------------------------------------------------------------------------------------------------------
- Hash Join  (cost=25.98..723.08 rows=14970 width=53) (actual rows=14970.00 loops=1)
+ Hash Join  (cost=25.98..723.25 rows=14970 width=53) (actual rows=14970.00 loops=1)
    Hash Cond: ((f.id1 = d.id1) AND (f.id2 = d.id2))
-   ->  Seq Scan on bloom_multi_fact f  (cost=0.00..618.50 rows=14970 width=41) (actual rows=15000.00 loops=1)
-         Bloom Filter 1: keys=(id1, id2) expected=49.9% checked=29999 rejected=15000 (50.0%)
+   ->  Seq Scan on bloom_multi_fact f  (cost=0.00..618.50 rows=15003 width=41) (actual rows=15000.00 loops=1)
+         Bloom Filter 1: keys=(id1, id2) expected=50.0% checked=29999 rejected=15000 (50.0%)
    ->  Hash  (cost=18.50..18.50 rows=499 width=12) (actual rows=499.00 loops=1)
          Buckets: 1024  Batches: 1  Memory Usage: 30kB
          Bloom Filter 1: bits=8192 hashes=4 memory=1kB checked=29999 rejected=15000
diff --git a/src/test/regress/expected/hashjoin_bloom_snowflake.out b/src/test/regress/expected/hashjoin_bloom_snowflake.out
index 03aa81ca1a7..169bf572c86 100644
--- a/src/test/regress/expected/hashjoin_bloom_snowflake.out
+++ b/src/test/regress/expected/hashjoin_bloom_snowflake.out
@@ -153,40 +153,40 @@ JOIN bloom_snowflake_dim_1_2 d12 ON (d1.id12 = d12.id)
 JOIN bloom_snowflake_dim_2_1 d21 ON (d2.id21 = d21.id)
 JOIN bloom_snowflake_dim_2_2 d22 ON (d2.id22 = d22.id)
 WHERE d1.r < 0.5;
-                                 QUERY PLAN                                  
------------------------------------------------------------------------------
+                                    QUERY PLAN                                     
+-----------------------------------------------------------------------------------
  Hash Join
-   Hash Cond: (f.id1 = d1.id)
+   Hash Cond: (f.id2 = d2.id)
    ->  Hash Join
-         Hash Cond: (f.id2 = d2.id)
+         Hash Cond: (f.id1 = d1.id)
          ->  Seq Scan on bloom_snowflake_fact f
                Bloom Filter 3: keys=(id1)
          ->  Hash
+               Bloom Filter 3
                ->  Hash Join
-                     Hash Cond: (d2.id22 = d22.id)
-                     ->  Hash Join
-                           Hash Cond: (d2.id21 = d21.id)
-                           ->  Seq Scan on bloom_snowflake_dim_2 d2
-                           ->  Hash
-                                 ->  Seq Scan on bloom_snowflake_dim_2_1 d21
+                     Hash Cond: (d12.id = d1.id12)
+                     ->  Seq Scan on bloom_snowflake_dim_1_2 d12
+                           Bloom Filter 2: keys=(id)
                      ->  Hash
-                           ->  Seq Scan on bloom_snowflake_dim_2_2 d22
+                           Bloom Filter 2
+                           ->  Hash Join
+                                 Hash Cond: (d11.id = d1.id11)
+                                 ->  Seq Scan on bloom_snowflake_dim_1_1 d11
+                                       Bloom Filter 1: keys=(id)
+                                 ->  Hash
+                                       Bloom Filter 1
+                                       ->  Seq Scan on bloom_snowflake_dim_1 d1
+                                             Filter: (r < '0.5'::double precision)
    ->  Hash
-         Bloom Filter 3
          ->  Hash Join
-               Hash Cond: (d12.id = d1.id12)
-               ->  Seq Scan on bloom_snowflake_dim_1_2 d12
-                     Bloom Filter 2: keys=(id)
+               Hash Cond: (d2.id22 = d22.id)
+               ->  Hash Join
+                     Hash Cond: (d2.id21 = d21.id)
+                     ->  Seq Scan on bloom_snowflake_dim_2 d2
+                     ->  Hash
+                           ->  Seq Scan on bloom_snowflake_dim_2_1 d21
                ->  Hash
-                     Bloom Filter 2
-                     ->  Hash Join
-                           Hash Cond: (d11.id = d1.id11)
-                           ->  Seq Scan on bloom_snowflake_dim_1_1 d11
-                                 Bloom Filter 1: keys=(id)
-                           ->  Hash
-                                 Bloom Filter 1
-                                 ->  Seq Scan on bloom_snowflake_dim_1 d1
-                                       Filter: (r < '0.5'::double precision)
+                     ->  Seq Scan on bloom_snowflake_dim_2_2 d22
 (32 rows)
 
 EXPLAIN (COSTS OFF)
@@ -199,41 +199,37 @@ JOIN bloom_snowflake_dim_1_2 d12 ON (d1.id12 = d12.id)
 JOIN bloom_snowflake_dim_2_1 d21 ON (d2.id21 = d21.id)
 JOIN bloom_snowflake_dim_2_2 d22 ON (d2.id22 = d22.id)
 WHERE d11.r < 0.5;
-                                 QUERY PLAN                                  
------------------------------------------------------------------------------
+                                    QUERY PLAN                                     
+-----------------------------------------------------------------------------------
  Hash Join
-   Hash Cond: (f.id1 = d1.id)
+   Hash Cond: (f.id2 = d2.id)
    ->  Hash Join
-         Hash Cond: (f.id2 = d2.id)
+         Hash Cond: (f.id1 = d1.id)
          ->  Seq Scan on bloom_snowflake_fact f
-               Bloom Filter 3: keys=(id1)
          ->  Hash
                ->  Hash Join
-                     Hash Cond: (d2.id22 = d22.id)
-                     ->  Hash Join
-                           Hash Cond: (d2.id21 = d21.id)
-                           ->  Seq Scan on bloom_snowflake_dim_2 d2
-                           ->  Hash
-                                 ->  Seq Scan on bloom_snowflake_dim_2_1 d21
+                     Hash Cond: (d12.id = d1.id12)
+                     ->  Seq Scan on bloom_snowflake_dim_1_2 d12
                      ->  Hash
-                           ->  Seq Scan on bloom_snowflake_dim_2_2 d22
+                           ->  Hash Join
+                                 Hash Cond: (d1.id11 = d11.id)
+                                 ->  Seq Scan on bloom_snowflake_dim_1 d1
+                                       Bloom Filter 1: keys=(id11)
+                                 ->  Hash
+                                       Bloom Filter 1
+                                       ->  Seq Scan on bloom_snowflake_dim_1_1 d11
+                                             Filter: (r < '0.5'::double precision)
    ->  Hash
-         Bloom Filter 3
          ->  Hash Join
-               Hash Cond: (d12.id = d1.id12)
-               ->  Seq Scan on bloom_snowflake_dim_1_2 d12
-                     Bloom Filter 2: keys=(id)
+               Hash Cond: (d2.id22 = d22.id)
+               ->  Hash Join
+                     Hash Cond: (d2.id21 = d21.id)
+                     ->  Seq Scan on bloom_snowflake_dim_2 d2
+                     ->  Hash
+                           ->  Seq Scan on bloom_snowflake_dim_2_1 d21
                ->  Hash
-                     Bloom Filter 2
-                     ->  Hash Join
-                           Hash Cond: (d1.id11 = d11.id)
-                           ->  Seq Scan on bloom_snowflake_dim_1 d1
-                                 Bloom Filter 1: keys=(id11)
-                           ->  Hash
-                                 Bloom Filter 1
-                                 ->  Seq Scan on bloom_snowflake_dim_1_1 d11
-                                       Filter: (r < '0.5'::double precision)
-(32 rows)
+                     ->  Seq Scan on bloom_snowflake_dim_2_2 d22
+(28 rows)
 
 -- increase the accepted build size (includes the fact)
 SET bloom_filter_pushdown_max_build_relids = 4;
@@ -248,40 +244,40 @@ JOIN bloom_snowflake_dim_1_2 d12 ON (d1.id12 = d12.id)
 JOIN bloom_snowflake_dim_2_1 d21 ON (d2.id21 = d21.id)
 JOIN bloom_snowflake_dim_2_2 d22 ON (d2.id22 = d22.id)
 WHERE d1.r < 0.5;
-                                 QUERY PLAN                                  
------------------------------------------------------------------------------
+                                    QUERY PLAN                                     
+-----------------------------------------------------------------------------------
  Hash Join
-   Hash Cond: (f.id1 = d1.id)
+   Hash Cond: (f.id2 = d2.id)
    ->  Hash Join
-         Hash Cond: (f.id2 = d2.id)
+         Hash Cond: (f.id1 = d1.id)
          ->  Seq Scan on bloom_snowflake_fact f
                Bloom Filter 3: keys=(id1)
          ->  Hash
+               Bloom Filter 3
                ->  Hash Join
-                     Hash Cond: (d2.id22 = d22.id)
-                     ->  Hash Join
-                           Hash Cond: (d2.id21 = d21.id)
-                           ->  Seq Scan on bloom_snowflake_dim_2 d2
-                           ->  Hash
-                                 ->  Seq Scan on bloom_snowflake_dim_2_1 d21
+                     Hash Cond: (d12.id = d1.id12)
+                     ->  Seq Scan on bloom_snowflake_dim_1_2 d12
+                           Bloom Filter 2: keys=(id)
                      ->  Hash
-                           ->  Seq Scan on bloom_snowflake_dim_2_2 d22
+                           Bloom Filter 2
+                           ->  Hash Join
+                                 Hash Cond: (d11.id = d1.id11)
+                                 ->  Seq Scan on bloom_snowflake_dim_1_1 d11
+                                       Bloom Filter 1: keys=(id)
+                                 ->  Hash
+                                       Bloom Filter 1
+                                       ->  Seq Scan on bloom_snowflake_dim_1 d1
+                                             Filter: (r < '0.5'::double precision)
    ->  Hash
-         Bloom Filter 3
          ->  Hash Join
-               Hash Cond: (d12.id = d1.id12)
-               ->  Seq Scan on bloom_snowflake_dim_1_2 d12
-                     Bloom Filter 2: keys=(id)
+               Hash Cond: (d2.id22 = d22.id)
+               ->  Hash Join
+                     Hash Cond: (d2.id21 = d21.id)
+                     ->  Seq Scan on bloom_snowflake_dim_2 d2
+                     ->  Hash
+                           ->  Seq Scan on bloom_snowflake_dim_2_1 d21
                ->  Hash
-                     Bloom Filter 2
-                     ->  Hash Join
-                           Hash Cond: (d11.id = d1.id11)
-                           ->  Seq Scan on bloom_snowflake_dim_1_1 d11
-                                 Bloom Filter 1: keys=(id)
-                           ->  Hash
-                                 Bloom Filter 1
-                                 ->  Seq Scan on bloom_snowflake_dim_1 d1
-                                       Filter: (r < '0.5'::double precision)
+                     ->  Seq Scan on bloom_snowflake_dim_2_2 d22
 (32 rows)
 
 EXPLAIN (COSTS OFF)
@@ -294,41 +290,37 @@ JOIN bloom_snowflake_dim_1_2 d12 ON (d1.id12 = d12.id)
 JOIN bloom_snowflake_dim_2_1 d21 ON (d2.id21 = d21.id)
 JOIN bloom_snowflake_dim_2_2 d22 ON (d2.id22 = d22.id)
 WHERE d11.r < 0.5;
-                                 QUERY PLAN                                  
------------------------------------------------------------------------------
+                                    QUERY PLAN                                     
+-----------------------------------------------------------------------------------
  Hash Join
-   Hash Cond: (f.id1 = d1.id)
+   Hash Cond: (f.id2 = d2.id)
    ->  Hash Join
-         Hash Cond: (f.id2 = d2.id)
+         Hash Cond: (f.id1 = d1.id)
          ->  Seq Scan on bloom_snowflake_fact f
-               Bloom Filter 3: keys=(id1)
          ->  Hash
                ->  Hash Join
-                     Hash Cond: (d2.id22 = d22.id)
-                     ->  Hash Join
-                           Hash Cond: (d2.id21 = d21.id)
-                           ->  Seq Scan on bloom_snowflake_dim_2 d2
-                           ->  Hash
-                                 ->  Seq Scan on bloom_snowflake_dim_2_1 d21
+                     Hash Cond: (d12.id = d1.id12)
+                     ->  Seq Scan on bloom_snowflake_dim_1_2 d12
                      ->  Hash
-                           ->  Seq Scan on bloom_snowflake_dim_2_2 d22
+                           ->  Hash Join
+                                 Hash Cond: (d1.id11 = d11.id)
+                                 ->  Seq Scan on bloom_snowflake_dim_1 d1
+                                       Bloom Filter 1: keys=(id11)
+                                 ->  Hash
+                                       Bloom Filter 1
+                                       ->  Seq Scan on bloom_snowflake_dim_1_1 d11
+                                             Filter: (r < '0.5'::double precision)
    ->  Hash
-         Bloom Filter 3
          ->  Hash Join
-               Hash Cond: (d12.id = d1.id12)
-               ->  Seq Scan on bloom_snowflake_dim_1_2 d12
-                     Bloom Filter 2: keys=(id)
+               Hash Cond: (d2.id22 = d22.id)
+               ->  Hash Join
+                     Hash Cond: (d2.id21 = d21.id)
+                     ->  Seq Scan on bloom_snowflake_dim_2 d2
+                     ->  Hash
+                           ->  Seq Scan on bloom_snowflake_dim_2_1 d21
                ->  Hash
-                     Bloom Filter 2
-                     ->  Hash Join
-                           Hash Cond: (d1.id11 = d11.id)
-                           ->  Seq Scan on bloom_snowflake_dim_1 d1
-                                 Bloom Filter 1: keys=(id11)
-                           ->  Hash
-                                 Bloom Filter 1
-                                 ->  Seq Scan on bloom_snowflake_dim_1_1 d11
-                                       Filter: (r < '0.5'::double precision)
-(32 rows)
+                     ->  Seq Scan on bloom_snowflake_dim_2_2 d22
+(28 rows)
 
 -- needed to stabilize the join order
 SET join_collapse_limit = 1;
@@ -443,20 +435,18 @@ JOIN bloom_snowflake_multi_dim_1_2 d12 ON (d1.id12a = d12.a AND d1.id12b = d12.b
 WHERE d11.r < 0.45 AND d12.r < 0.55;
                                                                    QUERY PLAN                                                                   
 ------------------------------------------------------------------------------------------------------------------------------------------------
- Hash Join  (cost=78.07..2418.84 rows=24628 width=101) (actual rows=23530.00 loops=1)
+ Hash Join  (cost=78.08..2859.33 rows=24628 width=101) (actual rows=23530.00 loops=1)
    Hash Cond: ((f.id1a = d1.a) AND (f.id1b = d1.b))
-   ->  Seq Scan on bloom_snowflake_multi_fact f  (cost=0.00..2156.00 rows=24628 width=49) (actual rows=23531.00 loops=1)
-         Bloom Filter 3: keys=(id1a, id1b) expected=24.6% checked=99999 rejected=76469 (76.5%)
-   ->  Hash  (cost=74.38..74.38 rows=246 width=52) (actual rows=234.00 loops=1)
+   ->  Seq Scan on bloom_snowflake_multi_fact f  (cost=0.00..2031.00 rows=100000 width=49) (actual rows=100000.00 loops=1)
+   ->  Hash  (cost=74.39..74.39 rows=246 width=52) (actual rows=234.00 loops=1)
          Buckets: 1024  Batches: 1  Memory Usage: 28kB
-         Bloom Filter 3: bits=8192 hashes=4 memory=1kB checked=99999 rejected=76469
-         ->  Hash Join  (cost=52.00..74.38 rows=246 width=52) (actual rows=234.00 loops=1)
+         ->  Hash Join  (cost=52.00..74.39 rows=246 width=52) (actual rows=234.00 loops=1)
                Hash Cond: ((d1.id12a = d12.a) AND (d1.id12b = d12.b))
-               ->  Hash Join  (cost=25.09..46.18 rows=246 width=40) (actual rows=235.00 loops=1)
+               ->  Hash Join  (cost=25.09..46.18 rows=247 width=40) (actual rows=235.00 loops=1)
                      Hash Cond: ((d1.id11a = d11.a) AND (d1.id11b = d11.b))
-                     ->  Seq Scan on bloom_snowflake_multi_dim_1 d1  (cost=0.00..19.80 rows=246 width=28) (actual rows=236.00 loops=1)
-                           Bloom Filter 1: keys=(id11a, id11b) expected=43.9% checked=996 rejected=569 (57.1%)
-                           Bloom Filter 2: keys=(id12a, id12b) expected=56.1% checked=431 rejected=195 (45.2%)
+                     ->  Seq Scan on bloom_snowflake_multi_dim_1 d1  (cost=0.00..19.80 rows=247 width=28) (actual rows=236.00 loops=1)
+                           Bloom Filter 1: keys=(id11a, id11b) expected=44.0% checked=996 rejected=569 (57.1%)
+                           Bloom Filter 2: keys=(id12a, id12b) expected=56.2% checked=431 rejected=195 (45.2%)
                      ->  Hash  (cost=18.50..18.50 rows=439 width=12) (actual rows=440.00 loops=1)
                            Buckets: 1024  Batches: 1  Memory Usage: 27kB
                            Bloom Filter 1: bits=8192 hashes=4 memory=1kB checked=996 rejected=569
@@ -469,7 +459,7 @@ WHERE d11.r < 0.45 AND d12.r < 0.55;
                      ->  Seq Scan on bloom_snowflake_multi_dim_1_2 d12  (cost=0.00..18.50 rows=561 width=12) (actual rows=562.00 loops=1)
                            Filter: (r < '0.55'::double precision)
                            Rows Removed by Filter: 438
-(26 rows)
+(24 rows)
 
 EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF, BUFFERS OFF)
 SELECT *
@@ -480,13 +470,11 @@ JOIN bloom_snowflake_multi_dim_1_2 d12 ON (d1.id12a = d12.a AND d1.id12b = d12.b
 WHERE d11.r < 0.75 AND d12.r < 0.75;
                                                                    QUERY PLAN                                                                   
 ------------------------------------------------------------------------------------------------------------------------------------------------
- Hash Join  (cost=94.81..2667.85 rows=55564 width=101) (actual rows=55598.00 loops=1)
+ Hash Join  (cost=94.81..2876.37 rows=55564 width=101) (actual rows=55598.00 loops=1)
    Hash Cond: ((f.id1a = d1.a) AND (f.id1b = d1.b))
-   ->  Seq Scan on bloom_snowflake_multi_fact f  (cost=0.00..2156.00 rows=55564 width=49) (actual rows=55694.00 loops=1)
-         Bloom Filter 1: keys=(id1a, id1b) expected=55.6% checked=99999 rejected=44306 (44.3%)
+   ->  Seq Scan on bloom_snowflake_multi_fact f  (cost=0.00..2031.00 rows=100000 width=49) (actual rows=100000.00 loops=1)
    ->  Hash  (cost=86.47..86.47 rows=556 width=52) (actual rows=555.00 loops=1)
          Buckets: 1024  Batches: 1  Memory Usage: 54kB
-         Bloom Filter 1: bits=8192 hashes=4 memory=1kB checked=99999 rejected=44306
          ->  Hash Join  (cost=59.36..86.47 rows=556 width=52) (actual rows=555.00 loops=1)
                Hash Cond: ((d1.id12a = d12.a) AND (d1.id12b = d12.b))
                ->  Hash Join  (cost=29.51..52.76 rows=734 width=40) (actual rows=737.00 loops=1)
@@ -502,7 +490,7 @@ WHERE d11.r < 0.75 AND d12.r < 0.75;
                      ->  Seq Scan on bloom_snowflake_multi_dim_1_2 d12  (cost=0.00..18.50 rows=757 width=12) (actual rows=758.00 loops=1)
                            Filter: (r < '0.75'::double precision)
                            Rows Removed by Filter: 242
-(22 rows)
+(20 rows)
 
 EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF, BUFFERS OFF)
 SELECT *
@@ -514,50 +502,48 @@ JOIN bloom_snowflake_multi_dim_2 d2 ON (f.id2a = d2.a AND f.id2b = d2.b)
 JOIN bloom_snowflake_multi_dim_2_1 d21 ON (d2.id21a = d21.a AND d2.id21b = d21.b)
 JOIN bloom_snowflake_multi_dim_2_2 d22 ON (d2.id22a = d22.a AND d2.id22b = d22.b)
 WHERE d11.r < 0.75 AND d12.r < 0.75;
-                                                                                             QUERY PLAN                                                                                              
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
- Hash Join  (cost=331.99..3322.32 rows=55564 width=153) (actual rows=55598.00 loops=1)
-   Hash Cond: ((f.id1a = d1.a) AND (f.id1b = d1.b))
-   ->  Hash Join  (cost=237.18..2810.47 rows=55564 width=101) (actual rows=55694.00 loops=1)
-         Hash Cond: ((f.id2a = d2.a) AND (f.id2b = d2.b))
-         ->  Seq Scan on bloom_snowflake_multi_fact f  (cost=0.00..2156.00 rows=55564 width=49) (actual rows=55694.00 loops=1)
-               Bloom Filter 1: keys=(id1a, id1b) expected=55.6% checked=100000 rejected=44306 (44.3%)
-         ->  Hash  (cost=222.18..222.18 rows=1000 width=52) (actual rows=1000.00 loops=1)
-               Buckets: 1024  Batches: 1  Memory Usage: 91kB
-               ->  Merge Join  (cost=170.18..222.18 rows=1000 width=52) (actual rows=1000.00 loops=1)
-                     Merge Cond: ((d22.a = d2.id22a) AND (d22.b = d2.id22b))
-                     ->  Index Scan using bloom_snowflake_multi_dim_2_2_pkey on bloom_snowflake_multi_dim_2_2 d22  (cost=0.29..395.16 rows=10000 width=12) (actual rows=1001.00 loops=1)
-                           Index Searches: 1
-                     ->  Sort  (cost=169.90..172.40 rows=1000 width=40) (actual rows=1000.00 loops=1)
-                           Sort Key: d2.id22a, d2.id22b
-                           Sort Method: quicksort  Memory: 79kB
-                           ->  Merge Join  (cost=68.16..120.07 rows=1000 width=40) (actual rows=1000.00 loops=1)
-                                 Merge Cond: ((d21.a = d2.id21a) AND (d21.b = d2.id21b))
-                                 ->  Index Scan using bloom_snowflake_multi_dim_2_1_pkey on bloom_snowflake_multi_dim_2_1 d21  (cost=0.29..395.16 rows=10000 width=12) (actual rows=1000.00 loops=1)
-                                       Index Searches: 1
-                                 ->  Sort  (cost=67.83..70.33 rows=1000 width=28) (actual rows=1000.00 loops=1)
-                                       Sort Key: d2.id21a, d2.id21b
-                                       Sort Method: quicksort  Memory: 71kB
-                                       ->  Seq Scan on bloom_snowflake_multi_dim_2 d2  (cost=0.00..18.00 rows=1000 width=28) (actual rows=1000.00 loops=1)
-   ->  Hash  (cost=86.47..86.47 rows=556 width=52) (actual rows=555.00 loops=1)
-         Buckets: 1024  Batches: 1  Memory Usage: 54kB
-         Bloom Filter 1: bits=8192 hashes=4 memory=1kB checked=100000 rejected=44306
-         ->  Hash Join  (cost=59.36..86.47 rows=556 width=52) (actual rows=555.00 loops=1)
-               Hash Cond: ((d1.id12a = d12.a) AND (d1.id12b = d12.b))
-               ->  Hash Join  (cost=29.51..52.76 rows=734 width=40) (actual rows=737.00 loops=1)
-                     Hash Cond: ((d1.id11a = d11.a) AND (d1.id11b = d11.b))
-                     ->  Seq Scan on bloom_snowflake_multi_dim_1 d1  (cost=0.00..18.00 rows=1000 width=28) (actual rows=1000.00 loops=1)
-                     ->  Hash  (cost=18.50..18.50 rows=734 width=12) (actual rows=735.00 loops=1)
-                           Buckets: 1024  Batches: 1  Memory Usage: 40kB
-                           ->  Seq Scan on bloom_snowflake_multi_dim_1_1 d11  (cost=0.00..18.50 rows=734 width=12) (actual rows=735.00 loops=1)
+                                                                                          QUERY PLAN                                                                                           
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+ Hash Join  (cost=331.99..3530.84 rows=55564 width=153) (actual rows=55598.00 loops=1)
+   Hash Cond: ((f.id2a = d2.a) AND (f.id2b = d2.b))
+   ->  Hash Join  (cost=94.81..2876.37 rows=55564 width=101) (actual rows=55598.00 loops=1)
+         Hash Cond: ((f.id1a = d1.a) AND (f.id1b = d1.b))
+         ->  Seq Scan on bloom_snowflake_multi_fact f  (cost=0.00..2031.00 rows=100000 width=49) (actual rows=100000.00 loops=1)
+         ->  Hash  (cost=86.47..86.47 rows=556 width=52) (actual rows=555.00 loops=1)
+               Buckets: 1024  Batches: 1  Memory Usage: 54kB
+               ->  Hash Join  (cost=59.36..86.47 rows=556 width=52) (actual rows=555.00 loops=1)
+                     Hash Cond: ((d1.id12a = d12.a) AND (d1.id12b = d12.b))
+                     ->  Hash Join  (cost=29.51..52.76 rows=734 width=40) (actual rows=737.00 loops=1)
+                           Hash Cond: ((d1.id11a = d11.a) AND (d1.id11b = d11.b))
+                           ->  Seq Scan on bloom_snowflake_multi_dim_1 d1  (cost=0.00..18.00 rows=1000 width=28) (actual rows=1000.00 loops=1)
+                           ->  Hash  (cost=18.50..18.50 rows=734 width=12) (actual rows=735.00 loops=1)
+                                 Buckets: 1024  Batches: 1  Memory Usage: 40kB
+                                 ->  Seq Scan on bloom_snowflake_multi_dim_1_1 d11  (cost=0.00..18.50 rows=734 width=12) (actual rows=735.00 loops=1)
+                                       Filter: (r < '0.75'::double precision)
+                                       Rows Removed by Filter: 265
+                     ->  Hash  (cost=18.50..18.50 rows=757 width=12) (actual rows=758.00 loops=1)
+                           Buckets: 1024  Batches: 1  Memory Usage: 41kB
+                           ->  Seq Scan on bloom_snowflake_multi_dim_1_2 d12  (cost=0.00..18.50 rows=757 width=12) (actual rows=758.00 loops=1)
                                  Filter: (r < '0.75'::double precision)
-                                 Rows Removed by Filter: 265
-               ->  Hash  (cost=18.50..18.50 rows=757 width=12) (actual rows=758.00 loops=1)
-                     Buckets: 1024  Batches: 1  Memory Usage: 41kB
-                     ->  Seq Scan on bloom_snowflake_multi_dim_1_2 d12  (cost=0.00..18.50 rows=757 width=12) (actual rows=758.00 loops=1)
-                           Filter: (r < '0.75'::double precision)
-                           Rows Removed by Filter: 242
-(41 rows)
+                                 Rows Removed by Filter: 242
+   ->  Hash  (cost=222.18..222.18 rows=1000 width=52) (actual rows=1000.00 loops=1)
+         Buckets: 1024  Batches: 1  Memory Usage: 91kB
+         ->  Merge Join  (cost=170.18..222.18 rows=1000 width=52) (actual rows=1000.00 loops=1)
+               Merge Cond: ((d22.a = d2.id22a) AND (d22.b = d2.id22b))
+               ->  Index Scan using bloom_snowflake_multi_dim_2_2_pkey on bloom_snowflake_multi_dim_2_2 d22  (cost=0.29..395.16 rows=10000 width=12) (actual rows=1001.00 loops=1)
+                     Index Searches: 1
+               ->  Sort  (cost=169.90..172.40 rows=1000 width=40) (actual rows=1000.00 loops=1)
+                     Sort Key: d2.id22a, d2.id22b
+                     Sort Method: quicksort  Memory: 79kB
+                     ->  Merge Join  (cost=68.16..120.07 rows=1000 width=40) (actual rows=1000.00 loops=1)
+                           Merge Cond: ((d21.a = d2.id21a) AND (d21.b = d2.id21b))
+                           ->  Index Scan using bloom_snowflake_multi_dim_2_1_pkey on bloom_snowflake_multi_dim_2_1 d21  (cost=0.29..395.16 rows=10000 width=12) (actual rows=1000.00 loops=1)
+                                 Index Searches: 1
+                           ->  Sort  (cost=67.83..70.33 rows=1000 width=28) (actual rows=1000.00 loops=1)
+                                 Sort Key: d2.id21a, d2.id21b
+                                 Sort Method: quicksort  Memory: 71kB
+                                 ->  Seq Scan on bloom_snowflake_multi_dim_2 d2  (cost=0.00..18.00 rows=1000 width=28) (actual rows=1000.00 loops=1)
+(39 rows)
 
 RESET bloom_filter_pushdown_max_build_relids;
 DROP TABLE bloom_snowflake_multi_fact;
diff --git a/src/test/regress/expected/hashjoin_bloom_star.out b/src/test/regress/expected/hashjoin_bloom_star.out
index 99503f7a8f1..1f2316ee0ba 100644
--- a/src/test/regress/expected/hashjoin_bloom_star.out
+++ b/src/test/regress/expected/hashjoin_bloom_star.out
@@ -482,22 +482,22 @@ JOIN bloom_star_multi_dim_7 d7 ON (f.id7a = d7.a AND f.id7b = d7.b)
 WHERE d1.r < 0.5;
                                                                         QUERY PLAN                                                                        
 ----------------------------------------------------------------------------------------------------------------------------------------------------------
- Hash Join  (cost=211.53..4599.11 rows=46900 width=173) (actual rows=46848.00 loops=1)
+ Hash Join  (cost=211.53..4599.60 rows=46900 width=173) (actual rows=46848.00 loops=1)
    Hash Cond: ((f.id7a = d7.a) AND (f.id7b = d7.b))
-   ->  Hash Join  (cost=180.53..4321.89 rows=46900 width=161) (actual rows=46848.00 loops=1)
+   ->  Hash Join  (cost=180.53..4322.37 rows=46900 width=161) (actual rows=46848.00 loops=1)
          Hash Cond: ((f.id6a = d6.a) AND (f.id6b = d6.b))
-         ->  Hash Join  (cost=149.53..4044.66 rows=46900 width=149) (actual rows=46848.00 loops=1)
+         ->  Hash Join  (cost=149.53..4045.15 rows=46900 width=149) (actual rows=46848.00 loops=1)
                Hash Cond: ((f.id5a = d5.a) AND (f.id5b = d5.b))
-               ->  Hash Join  (cost=118.53..3767.43 rows=46900 width=137) (actual rows=46848.00 loops=1)
+               ->  Hash Join  (cost=118.53..3767.92 rows=46900 width=137) (actual rows=46848.00 loops=1)
                      Hash Cond: ((f.id4a = d4.a) AND (f.id4b = d4.b))
-                     ->  Hash Join  (cost=87.53..3490.21 rows=46900 width=125) (actual rows=46848.00 loops=1)
+                     ->  Hash Join  (cost=87.53..3490.70 rows=46900 width=125) (actual rows=46848.00 loops=1)
                            Hash Cond: ((f.id3a = d3.a) AND (f.id3b = d3.b))
-                           ->  Hash Join  (cost=56.53..3212.98 rows=46900 width=113) (actual rows=46848.00 loops=1)
+                           ->  Hash Join  (cost=56.53..3213.47 rows=46900 width=113) (actual rows=46848.00 loops=1)
                                  Hash Cond: ((f.id2a = d2.a) AND (f.id2b = d2.b))
-                                 ->  Hash Join  (cost=25.54..2935.76 rows=46900 width=101) (actual rows=46848.00 loops=1)
+                                 ->  Hash Join  (cost=25.54..2936.25 rows=46900 width=101) (actual rows=46848.00 loops=1)
                                        Hash Cond: ((f.id1a = d1.a) AND (f.id1b = d1.b))
-                                       ->  Seq Scan on bloom_star_multi_fact f  (cost=0.00..2664.00 rows=46900 width=89) (actual rows=46848.00 loops=1)
-                                             Bloom Filter 1: keys=(id1a, id1b) expected=46.9% checked=99999 rejected=53152 (53.2%)
+                                       ->  Seq Scan on bloom_star_multi_fact f  (cost=0.00..2664.00 rows=46993 width=89) (actual rows=46848.00 loops=1)
+                                             Bloom Filter 1: keys=(id1a, id1b) expected=47.0% checked=99999 rejected=53152 (53.2%)
                                        ->  Hash  (cost=18.50..18.50 rows=469 width=12) (actual rows=470.00 loops=1)
                                              Buckets: 1024  Batches: 1  Memory Usage: 29kB
                                              Bloom Filter 1: bits=8192 hashes=4 memory=1kB checked=99999 rejected=53152
@@ -538,23 +538,23 @@ JOIN bloom_star_multi_dim_7 d7 ON (f.id7a = d7.a AND f.id7b = d7.b)
 WHERE d1.r < 0.4 AND d7.r < 0.5;
                                                                         QUERY PLAN                                                                        
 ----------------------------------------------------------------------------------------------------------------------------------------------------------
- Hash Join  (cost=204.78..3573.04 rows=17888 width=173) (actual rows=17864.00 loops=1)
+ Hash Join  (cost=204.78..3574.54 rows=17888 width=173) (actual rows=17864.00 loops=1)
    Hash Cond: ((f.id7a = d7.a) AND (f.id7b = d7.b))
-   ->  Hash Join  (cost=179.12..3453.47 rows=17888 width=161) (actual rows=17899.00 loops=1)
+   ->  Hash Join  (cost=179.12..3454.79 rows=17924 width=161) (actual rows=17899.00 loops=1)
          Hash Cond: ((f.id6a = d6.a) AND (f.id6b = d6.b))
-         ->  Hash Join  (cost=148.12..3328.56 rows=17888 width=149) (actual rows=17899.00 loops=1)
+         ->  Hash Join  (cost=148.12..3329.69 rows=17924 width=149) (actual rows=17899.00 loops=1)
                Hash Cond: ((f.id5a = d5.a) AND (f.id5b = d5.b))
-               ->  Hash Join  (cost=117.12..3203.65 rows=17888 width=137) (actual rows=17899.00 loops=1)
+               ->  Hash Join  (cost=117.12..3204.59 rows=17924 width=137) (actual rows=17899.00 loops=1)
                      Hash Cond: ((f.id4a = d4.a) AND (f.id4b = d4.b))
-                     ->  Hash Join  (cost=86.12..3078.74 rows=17888 width=125) (actual rows=17899.00 loops=1)
+                     ->  Hash Join  (cost=86.12..3079.48 rows=17924 width=125) (actual rows=17899.00 loops=1)
                            Hash Cond: ((f.id3a = d3.a) AND (f.id3b = d3.b))
-                           ->  Hash Join  (cost=55.12..2953.82 rows=17888 width=113) (actual rows=17899.00 loops=1)
+                           ->  Hash Join  (cost=55.12..2954.38 rows=17924 width=113) (actual rows=17899.00 loops=1)
                                  Hash Cond: ((f.id2a = d2.a) AND (f.id2b = d2.b))
-                                 ->  Hash Join  (cost=24.12..2828.91 rows=17888 width=101) (actual rows=17899.00 loops=1)
+                                 ->  Hash Join  (cost=24.12..2829.28 rows=17924 width=101) (actual rows=17899.00 loops=1)
                                        Hash Cond: ((f.id1a = d1.a) AND (f.id1b = d1.b))
-                                       ->  Seq Scan on bloom_star_multi_fact f  (cost=0.00..2710.88 rows=17888 width=89) (actual rows=17899.00 loops=1)
+                                       ->  Seq Scan on bloom_star_multi_fact f  (cost=0.00..2710.94 rows=17947 width=89) (actual rows=17899.00 loops=1)
                                              Bloom Filter 1: keys=(id1a, id1b) expected=37.5% checked=99999 rejected=62610 (62.6%)
-                                             Bloom Filter 2: keys=(id7a, id7b) expected=47.7% checked=37390 rejected=19491 (52.1%)
+                                             Bloom Filter 2: keys=(id7a, id7b) expected=47.8% checked=37390 rejected=19491 (52.1%)
                                        ->  Hash  (cost=18.50..18.50 rows=375 width=12) (actual rows=376.00 loops=1)
                                              Buckets: 1024  Batches: 1  Memory Usage: 25kB
                                              Bloom Filter 1: bits=8192 hashes=4 memory=1kB checked=99999 rejected=62610
@@ -598,24 +598,24 @@ JOIN bloom_star_multi_dim_7 d7 ON (f.id7a = d7.a AND f.id7b = d7.b)
 WHERE d1.r < 0.3 AND d2.r < 0.35 AND d3.r < 0.4 AND d4.r < 0.45 AND d5.r < 0.5 AND d6.r < 0.55 AND d7.r < 0.6;
                                                                         QUERY PLAN                                                                        
 ----------------------------------------------------------------------------------------------------------------------------------------------------------
- Hash Join  (cost=176.01..2991.80 rows=283 width=173) (actual rows=292.00 loops=1)
+ Hash Join  (cost=176.01..2992.01 rows=283 width=173) (actual rows=292.00 loops=1)
    Hash Cond: ((f.id7a = d7.a) AND (f.id7b = d7.b))
-   ->  Hash Join  (cost=149.07..2962.22 rows=503 width=161) (actual rows=526.00 loops=1)
+   ->  Hash Join  (cost=149.07..2962.43 rows=503 width=161) (actual rows=526.00 loops=1)
          Hash Cond: ((f.id6a = d6.a) AND (f.id6b = d6.b))
-         ->  Hash Join  (cost=122.51..2930.75 rows=936 width=149) (actual rows=923.00 loops=1)
+         ->  Hash Join  (cost=122.51..2930.96 rows=936 width=149) (actual rows=923.00 loops=1)
                Hash Cond: ((f.id5a = d5.a) AND (f.id5b = d5.b))
-               ->  Hash Join  (cost=96.24..2895.00 rows=1806 width=137) (actual rows=1774.00 loops=1)
+               ->  Hash Join  (cost=96.24..2895.20 rows=1806 width=137) (actual rows=1774.00 loops=1)
                      Hash Cond: ((f.id4a = d4.a) AND (f.id4b = d4.b))
-                     ->  Hash Join  (cost=71.02..2848.61 rows=4032 width=125) (actual rows=3997.00 loops=1)
+                     ->  Hash Join  (cost=71.02..2848.82 rows=4032 width=125) (actual rows=3997.00 loops=1)
                            Hash Cond: ((f.id3a = d3.a) AND (f.id3b = d3.b))
-                           ->  Hash Join  (cost=46.69..2803.11 rows=4032 width=113) (actual rows=4005.00 loops=1)
+                           ->  Hash Join  (cost=46.69..2803.28 rows=4038 width=113) (actual rows=4005.00 loops=1)
                                  Hash Cond: ((f.id2a = d2.a) AND (f.id2b = d2.b))
-                                 ->  Hash Join  (cost=22.95..2758.20 rows=4032 width=101) (actual rows=4016.00 loops=1)
+                                 ->  Hash Join  (cost=22.95..2758.33 rows=4042 width=101) (actual rows=4016.00 loops=1)
                                        Hash Cond: ((f.id1a = d1.a) AND (f.id1b = d1.b))
-                                       ->  Seq Scan on bloom_star_multi_fact f  (cost=0.00..2714.08 rows=4032 width=89) (actual rows=4016.00 loops=1)
+                                       ->  Seq Scan on bloom_star_multi_fact f  (cost=0.00..2714.14 rows=4045 width=89) (actual rows=4016.00 loops=1)
                                              Bloom Filter 1: keys=(id1a, id1b) expected=29.7% checked=99991 rejected=70413 (70.4%)
                                              Bloom Filter 2: keys=(id2a, id2b) expected=34.9% checked=29587 rejected=19372 (65.5%)
-                                             Bloom Filter 3: keys=(id3a, id3b) expected=38.9% checked=10215 rejected=6199 (60.7%)
+                                             Bloom Filter 3: keys=(id3a, id3b) expected=39.0% checked=10215 rejected=6199 (60.7%)
                                        ->  Hash  (cost=18.50..18.50 rows=297 width=12) (actual rows=298.00 loops=1)
                                              Buckets: 1024  Batches: 1  Memory Usage: 21kB
                                              Bloom Filter 1: bits=8192 hashes=4 memory=1kB checked=99991 rejected=70413
@@ -671,28 +671,28 @@ JOIN bloom_star_multi_dim_7 d7 ON (f.id7a = d7.a AND f.id7b = d7.b)
 WHERE d1.r < 0.3 AND d2.r < 0.35 AND d3.r < 0.4 AND d4.r < 0.45 AND d5.r < 0.5 AND d6.r < 0.55 AND d7.r < 0.6;
                                                                         QUERY PLAN                                                                        
 ----------------------------------------------------------------------------------------------------------------------------------------------------------
- Hash Join  (cost=176.01..2909.59 rows=283 width=173) (actual rows=292.00 loops=1)
+ Hash Join  (cost=176.01..2909.77 rows=283 width=173) (actual rows=292.00 loops=1)
    Hash Cond: ((f.id7a = d7.a) AND (f.id7b = d7.b))
-   ->  Hash Join  (cost=149.07..2881.16 rows=283 width=161) (actual rows=292.00 loops=1)
+   ->  Hash Join  (cost=149.07..2881.33 rows=284 width=161) (actual rows=292.00 loops=1)
          Hash Cond: ((f.id6a = d6.a) AND (f.id6b = d6.b))
-         ->  Hash Join  (cost=122.51..2853.12 rows=283 width=149) (actual rows=292.00 loops=1)
+         ->  Hash Join  (cost=122.51..2853.29 rows=284 width=149) (actual rows=292.00 loops=1)
                Hash Cond: ((f.id5a = d5.a) AND (f.id5b = d5.b))
-               ->  Hash Join  (cost=96.24..2825.37 rows=283 width=137) (actual rows=296.00 loops=1)
+               ->  Hash Join  (cost=96.24..2825.52 rows=285 width=137) (actual rows=296.00 loops=1)
                      Hash Cond: ((f.id4a = d4.a) AND (f.id4b = d4.b))
-                     ->  Hash Join  (cost=71.02..2798.66 rows=283 width=125) (actual rows=296.00 loops=1)
+                     ->  Hash Join  (cost=71.02..2798.80 rows=285 width=125) (actual rows=296.00 loops=1)
                            Hash Cond: ((f.id3a = d3.a) AND (f.id3b = d3.b))
-                           ->  Hash Join  (cost=46.69..2772.84 rows=283 width=113) (actual rows=298.00 loops=1)
+                           ->  Hash Join  (cost=46.69..2772.97 rows=286 width=113) (actual rows=298.00 loops=1)
                                  Hash Cond: ((f.id2a = d2.a) AND (f.id2b = d2.b))
-                                 ->  Hash Join  (cost=22.95..2747.62 rows=283 width=101) (actual rows=299.00 loops=1)
+                                 ->  Hash Join  (cost=22.95..2747.73 rows=286 width=101) (actual rows=299.00 loops=1)
                                        Hash Cond: ((f.id1a = d1.a) AND (f.id1b = d1.b))
-                                       ->  Seq Scan on bloom_star_multi_fact f  (cost=0.00..2723.18 rows=283 width=89) (actual rows=299.00 loops=1)
+                                       ->  Seq Scan on bloom_star_multi_fact f  (cost=0.00..2723.27 rows=286 width=89) (actual rows=299.00 loops=1)
                                              Bloom Filter 1: keys=(id1a, id1b) expected=29.7% checked=99991 rejected=70413 (70.4%)
                                              Bloom Filter 2: keys=(id2a, id2b) expected=34.9% checked=29587 rejected=19372 (65.5%)
-                                             Bloom Filter 3: keys=(id3a, id3b) expected=38.9% checked=10215 rejected=6199 (60.7%)
-                                             Bloom Filter 4: keys=(id4a, id4b) expected=44.8% checked=4016 rejected=2234 (55.6%)
-                                             Bloom Filter 5: keys=(id5a, id5b) expected=51.8% checked=1782 rejected=848 (47.6%)
-                                             Bloom Filter 6: keys=(id6a, id6b) expected=53.7% checked=934 rejected=399 (42.7%)
-                                             Bloom Filter 7: keys=(id7a, id7b) expected=56.3% checked=535 rejected=236 (44.1%)
+                                             Bloom Filter 3: keys=(id3a, id3b) expected=39.0% checked=10215 rejected=6199 (60.7%)
+                                             Bloom Filter 4: keys=(id4a, id4b) expected=44.9% checked=4016 rejected=2234 (55.6%)
+                                             Bloom Filter 5: keys=(id5a, id5b) expected=51.9% checked=1782 rejected=848 (47.6%)
+                                             Bloom Filter 6: keys=(id6a, id6b) expected=53.8% checked=934 rejected=399 (42.7%)
+                                             Bloom Filter 7: keys=(id7a, id7b) expected=56.4% checked=535 rejected=236 (44.1%)
                                        ->  Hash  (cost=18.50..18.50 rows=297 width=12) (actual rows=298.00 loops=1)
                                              Buckets: 1024  Batches: 1  Memory Usage: 21kB
                                              Bloom Filter 1: bits=8192 hashes=4 memory=1kB checked=99991 rejected=70413
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index 3cfcd74a038..955a3b7b5a0 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -218,13 +218,13 @@ SELECT t1.a, t2.e
   WHERE t1.a = t2.d;
  a | e  
 ---+----
- 0 |   
  1 | -1
  2 |  2
- 2 |  4
  3 | -3
+ 2 |  4
  5 | -5
  5 | -5
+ 0 |   
 (7 rows)
 
 --
@@ -1573,13 +1573,13 @@ SELECT *
   FROM J1_TBL INNER JOIN J2_TBL USING (i);
  i | j |   t   | k  
 ---+---+-------+----
- 0 |   | zero  |   
  1 | 4 | one   | -1
  2 | 3 | two   |  2
- 2 | 3 | two   |  4
  3 | 2 | three | -3
+ 2 | 3 | two   |  4
  5 | 0 | five  | -5
  5 | 0 | five  | -5
+ 0 |   | zero  |   
 (7 rows)
 
 -- Same as above, slightly different syntax
@@ -1587,13 +1587,13 @@ SELECT *
   FROM J1_TBL JOIN J2_TBL USING (i);
  i | j |   t   | k  
 ---+---+-------+----
- 0 |   | zero  |   
  1 | 4 | one   | -1
  2 | 3 | two   |  2
- 2 | 3 | two   |  4
  3 | 2 | three | -3
+ 2 | 3 | two   |  4
  5 | 0 | five  | -5
  5 | 0 | five  | -5
+ 0 |   | zero  |   
 (7 rows)
 
 SELECT *
@@ -1681,35 +1681,35 @@ SELECT *
   FROM J1_TBL NATURAL JOIN J2_TBL;
  i | j |   t   | k  
 ---+---+-------+----
- 0 |   | zero  |   
  1 | 4 | one   | -1
  2 | 3 | two   |  2
- 2 | 3 | two   |  4
  3 | 2 | three | -3
+ 2 | 3 | two   |  4
  5 | 0 | five  | -5
  5 | 0 | five  | -5
+ 0 |   | zero  |   
 (7 rows)
 
 SELECT *
   FROM J1_TBL t1 (a, b, c) NATURAL JOIN J2_TBL t2 (a, d);
  a | b |   c   | d  
 ---+---+-------+----
- 0 |   | zero  |   
  1 | 4 | one   | -1
  2 | 3 | two   |  2
- 2 | 3 | two   |  4
  3 | 2 | three | -3
+ 2 | 3 | two   |  4
  5 | 0 | five  | -5
  5 | 0 | five  | -5
+ 0 |   | zero  |   
 (7 rows)
 
 SELECT *
   FROM J1_TBL t1 (a, b, c) NATURAL JOIN J2_TBL t2 (d, a);
  a | b |  c   | d 
 ---+---+------+---
- 0 |   | zero |  
  2 | 3 | two  | 2
  4 | 1 | four | 2
+ 0 |   | zero |  
 (3 rows)
 
 -- mismatch number of columns
@@ -1718,13 +1718,13 @@ SELECT *
   FROM J1_TBL t1 (a, b) NATURAL JOIN J2_TBL t2 (a);
  a | b |   t   | k  
 ---+---+-------+----
- 0 |   | zero  |   
  1 | 4 | one   | -1
  2 | 3 | two   |  2
- 2 | 3 | two   |  4
  3 | 2 | three | -3
+ 2 | 3 | two   |  4
  5 | 0 | five  | -5
  5 | 0 | five  | -5
+ 0 |   | zero  |   
 (7 rows)
 
 --
@@ -1734,22 +1734,22 @@ SELECT *
   FROM J1_TBL JOIN J2_TBL ON (J1_TBL.i = J2_TBL.i);
  i | j |   t   | i | k  
 ---+---+-------+---+----
- 0 |   | zero  | 0 |   
  1 | 4 | one   | 1 | -1
  2 | 3 | two   | 2 |  2
- 2 | 3 | two   | 2 |  4
  3 | 2 | three | 3 | -3
+ 2 | 3 | two   | 2 |  4
  5 | 0 | five  | 5 | -5
  5 | 0 | five  | 5 | -5
+ 0 |   | zero  | 0 |   
 (7 rows)
 
 SELECT *
   FROM J1_TBL JOIN J2_TBL ON (J1_TBL.i = J2_TBL.k);
  i | j |  t   | i | k 
 ---+---+------+---+---
- 0 |   | zero |   | 0
  2 | 3 | two  | 2 | 2
  4 | 1 | four | 2 | 4
+ 0 |   | zero |   | 0
 (3 rows)
 
 --
@@ -3120,16 +3120,18 @@ set enable_memoize to off;
 explain (costs off)
 select count(*) from tenk1 a, tenk1 b
   where a.hundred = b.thousand and (b.fivethous % 10) < 10;
-                         QUERY PLAN                         
-------------------------------------------------------------
+                            QUERY PLAN                            
+------------------------------------------------------------------
  Aggregate
    ->  Hash Join
-         Hash Cond: (a.hundred = b.thousand)
-         ->  Index Only Scan using tenk1_hundred on tenk1 a
+         Hash Cond: (b.thousand = a.hundred)
+         ->  Seq Scan on tenk1 b
+               Filter: ((fivethous % 10) < 10)
+               Bloom Filter 1: keys=(thousand)
          ->  Hash
-               ->  Seq Scan on tenk1 b
-                     Filter: ((fivethous % 10) < 10)
-(7 rows)
+               Bloom Filter 1
+               ->  Index Only Scan using tenk1_hundred on tenk1 a
+(9 rows)
 
 select count(*) from tenk1 a, tenk1 b
   where a.hundred = b.thousand and (b.fivethous % 10) < 10;
@@ -3954,9 +3956,11 @@ where t2 is not null;
    Hash Cond: (t2.b = t1.unique1)
    ->  Seq Scan on tbl_wr t2
          Filter: (t2.* IS NOT NULL)
+         Bloom Filter 1: keys=(b)
    ->  Hash
+         Bloom Filter 1
          ->  Seq Scan on tenk1 t1
-(6 rows)
+(8 rows)
 
 -- reduced to a left join
 explain (costs off)
@@ -3986,9 +3990,11 @@ where t2.w is not null;
    Hash Cond: (t2.a = t1.unique1)
    ->  Seq Scan on tbl_comp t2
          Filter: (w IS NOT NULL)
+         Bloom Filter 1: keys=(a)
    ->  Hash
+         Bloom Filter 1
          ->  Seq Scan on tenk1 t1
-(6 rows)
+(8 rows)
 
 -- composite-type columns: reduced to an antijoin
 explain (costs off)
@@ -4215,14 +4221,16 @@ create temp table tidv (idv mycomptype);
 create index on tidv (idv);
 explain (costs off)
 select a.idv, b.idv from tidv a, tidv b where a.idv = b.idv;
-                        QUERY PLAN                        
-----------------------------------------------------------
- Merge Join
-   Merge Cond: (a.idv = b.idv)
-   ->  Index Only Scan using tidv_idv_idx on tidv a
-   ->  Materialize
-         ->  Index Only Scan using tidv_idv_idx on tidv b
-(5 rows)
+             QUERY PLAN             
+------------------------------------
+ Hash Join
+   Hash Cond: (a.idv = b.idv)
+   ->  Seq Scan on tidv a
+         Bloom Filter 1: keys=(idv)
+   ->  Hash
+         Bloom Filter 1
+         ->  Seq Scan on tidv b
+(7 rows)
 
 set enable_mergejoin = 0;
 set enable_hashjoin = 0;
@@ -7608,27 +7616,23 @@ where exists (select 1 from t t4
                      Hash Cond: (t6.b = t4.b)
                      ->  Seq Scan on pg_temp.t t6
                            Output: t6.a, t6.b
-                           Bloom Filter 2: keys=(t6.b) expected=0.5%
                      ->  Hash
                            Output: t4.b, t5.b, t5.a
-                           Bloom Filter 2
                            ->  Hash Join
                                  Output: t4.b, t5.b, t5.a
                                  Inner Unique: true
                                  Hash Cond: (t5.b = t4.b)
                                  ->  Seq Scan on pg_temp.t t5
                                        Output: t5.a, t5.b
-                                       Bloom Filter 1: keys=(t5.b) expected=0.5%
                                  ->  Hash
                                        Output: t4.b, t4.a
-                                       Bloom Filter 1
                                        ->  Index Scan using t_a_key on pg_temp.t t4
                                              Output: t4.b, t4.a
                                              Index Cond: (t4.a = 1)
    ->  Index Only Scan using t_a_key on pg_temp.t t3
          Output: t3.a
          Index Cond: (t3.a = t5.a)
-(36 rows)
+(32 rows)
 
 select t1.a from t t1
   left join t t2 on t1.a = t2.a
@@ -8925,33 +8929,32 @@ JOIN (
 		)
 	) _t2t3t4
 ON sj_t1.id = _t2t3t4.id;
-                                     QUERY PLAN                                      
--------------------------------------------------------------------------------------
+                                        QUERY PLAN                                         
+-------------------------------------------------------------------------------------------
  Nested Loop
    Join Filter: (sj_t1.id = sj_t3.id)
    ->  Nested Loop
-         Join Filter: (sj_t3.id = sj_t2.id)
-         ->  Nested Loop Semi Join
+         Join Filter: (sj_t3.id = sj_t2_1.id)
+         ->  Nested Loop
+               Join Filter: (sj_t3.id = sj_t2.id)
                ->  Nested Loop
-                     ->  HashAggregate
-                           Group Key: sj_t3.id
+                     ->  Unique
+                           ->  Nested Loop
+                                 ->  Index Only Scan using sj_t3_a_id_idx on sj_t3 sj_t3_1
+                                       Index Cond: (a = 1)
+                                 ->  Seq Scan on sj_t4 sj_t4_1
+                     ->  Index Only Scan using sj_t2_id_idx on sj_t2
+                           Index Cond: (id = sj_t3_1.id)
+               ->  Materialize
+                     ->  Unique
                            ->  Nested Loop
+                                 ->  Index Only Scan using sj_t3_a_id_idx on sj_t3
+                                       Index Cond: (a = 1)
                                  ->  Seq Scan on sj_t4
-                                 ->  Materialize
-                                       ->  Bitmap Heap Scan on sj_t3
-                                             Recheck Cond: (a = 1)
-                                             ->  Bitmap Index Scan on sj_t3_a_id_idx
-                                                   Index Cond: (a = 1)
-                     ->  Index Only Scan using sj_t2_id_idx on sj_t2 sj_t2_1
-                           Index Cond: (id = sj_t3.id)
-               ->  Nested Loop
-                     ->  Index Only Scan using sj_t3_a_id_idx on sj_t3 sj_t3_1
-                           Index Cond: ((a = 1) AND (id = sj_t3.id))
-                     ->  Seq Scan on sj_t4 sj_t4_1
-         ->  Index Only Scan using sj_t2_id_idx on sj_t2
-               Index Cond: (id = sj_t2_1.id)
+         ->  Index Only Scan using sj_t2_id_idx on sj_t2 sj_t2_1
+               Index Cond: (id = sj_t2.id)
    ->  Seq Scan on sj_t1
-(24 rows)
+(23 rows)
 
 --
 -- Test RowMarks-related code
@@ -10263,22 +10266,21 @@ select * from fkest f1
   join fkest f2 on (f1.x = f2.x and f1.x10 = f2.x10b and f1.x100 = f2.x100)
   join fkest f3 on f1.x = f3.x
   where f1.x100 = 2;
-                           QUERY PLAN                            
------------------------------------------------------------------
+                          QUERY PLAN                           
+---------------------------------------------------------------
  Hash Join
-   Hash Cond: (f3.x = f1.x)
-   ->  Seq Scan on fkest f3
-         Bloom Filter 1: keys=(x)
+   Hash Cond: ((f1.x = f2.x) AND (f1.x10 = f2.x10b))
+   ->  Nested Loop
+         ->  Seq Scan on fkest f1
+               Filter: (x100 = 2)
+               Bloom Filter 1: keys=(x, x10)
+         ->  Index Scan using fkest_x_x10_x100_idx on fkest f3
+               Index Cond: (x = f1.x)
    ->  Hash
          Bloom Filter 1
-         ->  Hash Join
-               Hash Cond: ((f2.x = f1.x) AND (f2.x10b = f1.x10))
-               ->  Seq Scan on fkest f2
-                     Filter: (x100 = 2)
-               ->  Hash
-                     ->  Seq Scan on fkest f1
-                           Filter: (x100 = 2)
-(13 rows)
+         ->  Seq Scan on fkest f2
+               Filter: (x100 = 2)
+(12 rows)
 
 rollback;
 --
@@ -10371,7 +10373,7 @@ select * from j1 inner join j3 on j1.id = j3.id;
    Hash Cond: (j1.id = j3.id)
    ->  Seq Scan on public.j1
          Output: j1.id
-         Bloom Filter 1: keys=(j1.id) expected=66.7%
+         Bloom Filter 1: keys=(j1.id) expected=33.3%
    ->  Hash
          Output: j3.id
          Bloom Filter 1
diff --git a/src/test/regress/expected/merge.out b/src/test/regress/expected/merge.out
index 9cb1d87066a..6d4a4ec748d 100644
--- a/src/test/regress/expected/merge.out
+++ b/src/test/regress/expected/merge.out
@@ -39,18 +39,17 @@ USING source AS s
 ON t.tid = s.sid
 WHEN MATCHED THEN
 	DELETE;
-               QUERY PLAN               
-----------------------------------------
+                QUERY PLAN                
+------------------------------------------
  Merge on target t
-   ->  Merge Join
-         Merge Cond: (t.tid = s.sid)
-         ->  Sort
-               Sort Key: t.tid
-               ->  Seq Scan on target t
-         ->  Sort
-               Sort Key: s.sid
+   ->  Hash Join
+         Hash Cond: (t.tid = s.sid)
+         ->  Seq Scan on target t
+               Bloom Filter 1: keys=(tid)
+         ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on source s
-(9 rows)
+(8 rows)
 
 --
 -- Errors
@@ -323,15 +322,17 @@ USING source AS s
 ON t.tid = s.sid
 WHEN MATCHED THEN
 	UPDATE SET balance = 0;
-               QUERY PLAN               
-----------------------------------------
+                QUERY PLAN                
+------------------------------------------
  Merge on target t
    ->  Hash Join
          Hash Cond: (s.sid = t.tid)
          ->  Seq Scan on source s
+               Bloom Filter 1: keys=(sid)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on target t
-(6 rows)
+(8 rows)
 
 EXPLAIN (COSTS OFF)
 MERGE INTO target t
@@ -339,15 +340,17 @@ USING source AS s
 ON t.tid = s.sid
 WHEN MATCHED THEN
 	DELETE;
-               QUERY PLAN               
-----------------------------------------
+                QUERY PLAN                
+------------------------------------------
  Merge on target t
    ->  Hash Join
          Hash Cond: (s.sid = t.tid)
          ->  Seq Scan on source s
+               Bloom Filter 1: keys=(sid)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on target t
-(6 rows)
+(8 rows)
 
 EXPLAIN (COSTS OFF)
 MERGE INTO target t
@@ -1636,42 +1639,38 @@ SELECT explain_merge('
 MERGE INTO ex_mtarget t USING ex_msource s ON t.a = s.a
 WHEN MATCHED THEN
 	UPDATE SET b = t.b + 1');
-                              explain_merge                              
--------------------------------------------------------------------------
+                                   explain_merge                                    
+------------------------------------------------------------------------------------
  Merge on ex_mtarget t (actual rows=0.00 loops=1)
    Tuples: updated=50
-   ->  Merge Join (actual rows=50.00 loops=1)
-         Merge Cond: (t.a = s.a)
-         ->  Sort (actual rows=50.00 loops=1)
-               Sort Key: t.a
-               Sort Method: quicksort  Memory: xxx
-               ->  Seq Scan on ex_mtarget t (actual rows=50.00 loops=1)
-         ->  Sort (actual rows=100.00 loops=1)
-               Sort Key: s.a
-               Sort Method: quicksort  Memory: xxx
+   ->  Hash Join (actual rows=50.00 loops=1)
+         Hash Cond: (t.a = s.a)
+         ->  Seq Scan on ex_mtarget t (actual rows=50.00 loops=1)
+               Bloom Filter 1: keys=(a) expected=50.2% checked=49 rejected=0 (0.0%)
+         ->  Hash (actual rows=100.00 loops=1)
+               Buckets: xxx  Batches: xxx  Memory Usage: xxx
+               Bloom Filter 1: bits=32768 hashes=4 memory=4kB checked=49 rejected=0
                ->  Seq Scan on ex_msource s (actual rows=100.00 loops=1)
-(12 rows)
+(10 rows)
 
 -- only updates to selected tuples
 SELECT explain_merge('
 MERGE INTO ex_mtarget t USING ex_msource s ON t.a = s.a
 WHEN MATCHED AND t.a < 10 THEN
 	UPDATE SET b = t.b + 1');
-                              explain_merge                              
--------------------------------------------------------------------------
+                                   explain_merge                                    
+------------------------------------------------------------------------------------
  Merge on ex_mtarget t (actual rows=0.00 loops=1)
    Tuples: updated=5 skipped=45
-   ->  Merge Join (actual rows=50.00 loops=1)
-         Merge Cond: (t.a = s.a)
-         ->  Sort (actual rows=50.00 loops=1)
-               Sort Key: t.a
-               Sort Method: quicksort  Memory: xxx
-               ->  Seq Scan on ex_mtarget t (actual rows=50.00 loops=1)
-         ->  Sort (actual rows=100.00 loops=1)
-               Sort Key: s.a
-               Sort Method: quicksort  Memory: xxx
+   ->  Hash Join (actual rows=50.00 loops=1)
+         Hash Cond: (t.a = s.a)
+         ->  Seq Scan on ex_mtarget t (actual rows=50.00 loops=1)
+               Bloom Filter 1: keys=(a) expected=50.2% checked=49 rejected=0 (0.0%)
+         ->  Hash (actual rows=100.00 loops=1)
+               Buckets: xxx  Batches: xxx  Memory Usage: xxx
+               Bloom Filter 1: bits=32768 hashes=4 memory=4kB checked=49 rejected=0
                ->  Seq Scan on ex_msource s (actual rows=100.00 loops=1)
-(12 rows)
+(10 rows)
 
 -- updates + deletes
 SELECT explain_merge('
@@ -1680,21 +1679,19 @@ WHEN MATCHED AND t.a < 10 THEN
 	UPDATE SET b = t.b + 1
 WHEN MATCHED AND t.a >= 10 AND t.a <= 20 THEN
 	DELETE');
-                              explain_merge                              
--------------------------------------------------------------------------
+                                   explain_merge                                    
+------------------------------------------------------------------------------------
  Merge on ex_mtarget t (actual rows=0.00 loops=1)
    Tuples: updated=5 deleted=5 skipped=40
-   ->  Merge Join (actual rows=50.00 loops=1)
-         Merge Cond: (t.a = s.a)
-         ->  Sort (actual rows=50.00 loops=1)
-               Sort Key: t.a
-               Sort Method: quicksort  Memory: xxx
-               ->  Seq Scan on ex_mtarget t (actual rows=50.00 loops=1)
-         ->  Sort (actual rows=100.00 loops=1)
-               Sort Key: s.a
-               Sort Method: quicksort  Memory: xxx
+   ->  Hash Join (actual rows=50.00 loops=1)
+         Hash Cond: (t.a = s.a)
+         ->  Seq Scan on ex_mtarget t (actual rows=50.00 loops=1)
+               Bloom Filter 1: keys=(a) expected=50.2% checked=49 rejected=0 (0.0%)
+         ->  Hash (actual rows=100.00 loops=1)
+               Buckets: xxx  Batches: xxx  Memory Usage: xxx
+               Bloom Filter 1: bits=32768 hashes=4 memory=4kB checked=49 rejected=0
                ->  Seq Scan on ex_msource s (actual rows=100.00 loops=1)
-(12 rows)
+(10 rows)
 
 -- only inserts
 SELECT explain_merge('
@@ -1791,21 +1788,20 @@ SELECT explain_merge('
 MERGE INTO ex_mtarget t USING ex_msource s ON t.a = s.a AND t.a < -1000
 WHEN MATCHED AND t.a < 10 THEN
 	DO NOTHING');
-                             explain_merge                             
------------------------------------------------------------------------
+                                   explain_merge                                   
+-----------------------------------------------------------------------------------
  Merge on ex_mtarget t (actual rows=0.00 loops=1)
-   ->  Merge Join (actual rows=0.00 loops=1)
-         Merge Cond: (t.a = s.a)
-         ->  Sort (actual rows=0.00 loops=1)
-               Sort Key: t.a
-               Sort Method: quicksort  Memory: xxx
+   ->  Hash Join (actual rows=0.00 loops=1)
+         Hash Cond: (s.a = t.a)
+         ->  Seq Scan on ex_msource s (actual rows=1.00 loops=1)
+               Bloom Filter 1: keys=(a) expected=50.4% checked=0 rejected=0 (0.0%)
+         ->  Hash (actual rows=0.00 loops=1)
+               Buckets: xxx  Batches: xxx  Memory Usage: xxx
+               Bloom Filter 1: bits=8192 hashes=4 memory=1kB checked=0 rejected=0
                ->  Seq Scan on ex_mtarget t (actual rows=0.00 loops=1)
                      Filter: (a < '-1000'::integer)
                      Rows Removed by Filter: 54
-         ->  Sort (never executed)
-               Sort Key: s.a
-               ->  Seq Scan on ex_msource s (never executed)
-(12 rows)
+(11 rows)
 
 DROP TABLE ex_msource, ex_mtarget;
 DROP FUNCTION explain_merge(text);
@@ -1831,8 +1827,10 @@ WHEN MATCHED AND t.c > s.cnt THEN
          Join Filter: (t.b < (SubPlan expr_1))
          ->  Seq Scan on public.tgt t
                Output: t.ctid, t.a, t.b
+               Bloom Filter 1: keys=(t.a) expected=50.9%
          ->  Hash
                Output: s.a, s.b, s.c, s.d, s.ctid
+               Bloom Filter 1
                ->  Seq Scan on public.src s
                      Output: s.a, s.b, s.c, s.d, s.ctid
          SubPlan expr_1
@@ -1856,7 +1854,7 @@ WHEN MATCHED AND t.c > s.cnt THEN
                    ->  Seq Scan on public.ref r_1
                          Output: r_1.ab, r_1.cd
                          Filter: ((r_1.ab = (s.a + s.b)) AND (r_1.cd = (s.c - s.d)))
-(32 rows)
+(34 rows)
 
 DROP TABLE src, tgt, ref;
 -- Subqueries
diff --git a/src/test/regress/expected/returning.out b/src/test/regress/expected/returning.out
index 9bc392f6026..1f3e1b1642a 100644
--- a/src/test/regress/expected/returning.out
+++ b/src/test/regress/expected/returning.out
@@ -723,19 +723,17 @@ UPDATE joinview SET f3 = f3 + 1 WHERE f3 = 57
          Hash Cond: (joinme_1.f2j = foo_1.f2)
          ->  Seq Scan on pg_temp.joinme joinme_1
                Output: joinme_1.ctid, joinme_1.f2j
-               Bloom Filter 2: keys=(joinme_1.f2j) expected=0.5%
+               Bloom Filter 1: keys=(joinme_1.f2j) expected=0.0%
          ->  Hash
                Output: foo_1.f2, foo_1.tableoid, foo_1.ctid, joinme.ctid, joinme.other, joinme.f2j, foo_2.f1, foo_2.f3, foo_2.ctid, foo_2.f2, foo_2.tableoid
-               Bloom Filter 2
+               Bloom Filter 1
                ->  Hash Join
                      Output: foo_1.f2, foo_1.tableoid, foo_1.ctid, joinme.ctid, joinme.other, joinme.f2j, foo_2.f1, foo_2.f3, foo_2.ctid, foo_2.f2, foo_2.tableoid
                      Hash Cond: (joinme.f2j = foo_1.f2)
                      ->  Seq Scan on pg_temp.joinme
                            Output: joinme.ctid, joinme.other, joinme.f2j
-                           Bloom Filter 1: keys=(joinme.f2j) expected=0.5%
                      ->  Hash
                            Output: foo_1.f2, foo_1.tableoid, foo_1.ctid, foo_2.f1, foo_2.f3, foo_2.ctid, foo_2.f2, foo_2.tableoid
-                           Bloom Filter 1
                            ->  Nested Loop
                                  Output: foo_1.f2, foo_1.tableoid, foo_1.ctid, foo_2.f1, foo_2.f3, foo_2.ctid, foo_2.f2, foo_2.tableoid
                                  Join Filter: (foo_1.f2 = foo_2.f2)
@@ -744,7 +742,7 @@ UPDATE joinview SET f3 = f3 + 1 WHERE f3 = 57
                                        Filter: (foo_2.f3 = 57)
                                  ->  Seq Scan on pg_temp.foo foo_1
                                        Output: foo_1.f2, foo_1.tableoid, foo_1.ctid
-(29 rows)
+(27 rows)
 
 UPDATE joinview SET f3 = f3 + 1 WHERE f3 = 57
   RETURNING old.*, new.*, *, new.f3 - old.f3 AS delta_f3;
diff --git a/src/test/regress/expected/stats_ext.out b/src/test/regress/expected/stats_ext.out
index 42dcf75f38f..c3e0d6cc664 100644
--- a/src/test/regress/expected/stats_ext.out
+++ b/src/test/regress/expected/stats_ext.out
@@ -3642,12 +3642,12 @@ SELECT * FROM sb_1 a, sb_2 b WHERE a.x = b.x AND a.y = b.y AND a.z = b.z;
                          QUERY PLAN                         
 ------------------------------------------------------------
  Hash Join
-   Hash Cond: ((a.x = b.x) AND (a.y = b.y) AND (a.z = b.z))
-   ->  Seq Scan on sb_1 a
+   Hash Cond: ((b.x = a.x) AND (b.y = a.y) AND (b.z = a.z))
+   ->  Seq Scan on sb_2 b
          Bloom Filter 1: keys=(x, y, z)
    ->  Hash
          Bloom Filter 1
-         ->  Seq Scan on sb_2 b
+         ->  Seq Scan on sb_1 a
 (7 rows)
 
 -- The ndistinct extended statistics on (x, y, z) provides more reliable value
@@ -3661,11 +3661,9 @@ SELECT * FROM sb_1 a, sb_2 b WHERE a.x = b.x AND a.y = b.y AND a.z = b.z;
  Hash Join
    Hash Cond: ((a.x = b.x) AND (a.y = b.y) AND (a.z = b.z))
    ->  Seq Scan on sb_1 a
-         Bloom Filter 1: keys=(x, y, z)
    ->  Hash
-         Bloom Filter 1
          ->  Seq Scan on sb_2 b
-(7 rows)
+(5 rows)
 
 -- Check that the Hash Join bucket size estimator detects equal clauses correctly.
 SET enable_nestloop = 'off';
diff --git a/src/test/regress/expected/subselect.out b/src/test/regress/expected/subselect.out
index e2564f877de..4c28658ea9c 100644
--- a/src/test/regress/expected/subselect.out
+++ b/src/test/regress/expected/subselect.out
@@ -783,14 +783,16 @@ order by t1.a, t2.a;
                      Hash Cond: (t2.a = (t3.b + 1))
                      ->  Seq Scan on public.semijoin_unique_tbl t2
                            Output: t2.a, t2.b
+                           Bloom Filter 1: keys=(t2.a) expected=51.1%
                      ->  Hash
                            Output: t3.a, t3.b
+                           Bloom Filter 1
                            ->  HashAggregate
                                  Output: t3.a, t3.b
                                  Group Key: (t3.a + 1), (t3.b + 1)
                                  ->  Seq Scan on public.semijoin_unique_tbl t3
                                        Output: t3.a, t3.b, (t3.a + 1), (t3.b + 1)
-(24 rows)
+(26 rows)
 
 -- encourage use of parallel plans
 set parallel_setup_cost=0;
diff --git a/src/test/regress/expected/updatable_views.out b/src/test/regress/expected/updatable_views.out
index 28c7b8db851..36352e6c282 100644
--- a/src/test/regress/expected/updatable_views.out
+++ b/src/test/regress/expected/updatable_views.out
@@ -2779,12 +2779,14 @@ EXPLAIN (costs off) UPDATE rw_view1 SET a = a + 5;
    ->  Hash Join
          Hash Cond: (b.a = r.a)
          ->  Seq Scan on base_tbl b
+               Bloom Filter 1: keys=(a)
          ->  Hash
+               Bloom Filter 1
                ->  Seq Scan on ref_tbl r
    SubPlan exists_1
      ->  Index Only Scan using ref_tbl_pkey on ref_tbl r_1
            Index Cond: (a = b.a)
-(9 rows)
+(11 rows)
 
 DROP TABLE base_tbl, ref_tbl CASCADE;
 NOTICE:  drop cascades to view rw_view1
@@ -3524,18 +3526,17 @@ EXPLAIN (COSTS OFF) UPDATE v2 SET a = 1;
  Update on t1
    InitPlan exists_1
      ->  Result
-   ->  Merge Join
-         Merge Cond: (t1.a = v1.a)
-         ->  Sort
-               Sort Key: t1.a
-               ->  Seq Scan on t1
-         ->  Sort
-               Sort Key: v1.a
+   ->  Hash Join
+         Hash Cond: (t1.a = v1.a)
+         ->  Seq Scan on t1
+               Bloom Filter 1: keys=(a)
+         ->  Hash
+               Bloom Filter 1
                ->  Subquery Scan on v1
                      ->  Result
                            One-Time Filter: (InitPlan exists_1).col1
                            ->  Seq Scan on t1 t1_1
-(14 rows)
+(13 rows)
 
 DROP VIEW v2;
 DROP VIEW v1;
diff --git a/src/test/regress/expected/window.out b/src/test/regress/expected/window.out
index 0b31f3b39b2..d55bc161159 100644
--- a/src/test/regress/expected/window.out
+++ b/src/test/regress/expected/window.out
@@ -4360,15 +4360,14 @@ WHERE s.c = 1;
          Run Condition: (ntile(e2.salary) OVER w1 <= 1)
          ->  Sort
                Sort Key: e1.depname
-               ->  Merge Join
-                     Merge Cond: (e1.empno = e2.empno)
-                     ->  Sort
-                           Sort Key: e1.empno
-                           ->  Seq Scan on empsalary e1
-                     ->  Sort
-                           Sort Key: e2.empno
+               ->  Hash Join
+                     Hash Cond: (e1.empno = e2.empno)
+                     ->  Seq Scan on empsalary e1
+                           Bloom Filter 1: keys=(empno)
+                     ->  Hash
+                           Bloom Filter 1
                            ->  Seq Scan on empsalary e2
-(15 rows)
+(14 rows)
 
 -- Ensure the run condition optimization is used in cases where the WindowFunc
 -- has a Var from another query level
diff --git a/src/test/regress/expected/with.out b/src/test/regress/expected/with.out
index addb24896be..939b251ea73 100644
--- a/src/test/regress/expected/with.out
+++ b/src/test/regress/expected/with.out
@@ -751,22 +751,20 @@ select * from search_graph order by seq;
      ->  Recursive Union
            ->  Seq Scan on pg_temp.graph0 g
                  Output: g.f, g.t, g.label, ARRAY[ROW(g.f, g.t)]
-           ->  Merge Join
+           ->  Hash Join
                  Output: g_1.f, g_1.t, g_1.label, array_cat(sg.seq, ARRAY[ROW(g_1.f, g_1.t)])
-                 Merge Cond: (g_1.f = sg.t)
-                 ->  Sort
+                 Hash Cond: (g_1.f = sg.t)
+                 ->  Seq Scan on pg_temp.graph0 g_1
                        Output: g_1.f, g_1.t, g_1.label
-                       Sort Key: g_1.f
-                       ->  Seq Scan on pg_temp.graph0 g_1
-                             Output: g_1.f, g_1.t, g_1.label
-                 ->  Sort
+                       Bloom Filter 1: keys=(g_1.f) expected=50.4%
+                 ->  Hash
                        Output: sg.seq, sg.t
-                       Sort Key: sg.t
+                       Bloom Filter 1
                        ->  WorkTable Scan on search_graph sg
                              Output: sg.seq, sg.t
    ->  CTE Scan on search_graph
          Output: search_graph.f, search_graph.t, search_graph.label, search_graph.seq
-(22 rows)
+(20 rows)
 
 with recursive search_graph(f, t, label) as (
 	select * from graph0 g
@@ -824,22 +822,20 @@ select * from search_graph order by seq;
      ->  Recursive Union
            ->  Seq Scan on pg_temp.graph0 g
                  Output: g.f, g.t, g.label, ROW('0'::bigint, g.f, g.t)
-           ->  Merge Join
+           ->  Hash Join
                  Output: g_1.f, g_1.t, g_1.label, ROW(int8inc((sg.seq)."*DEPTH*"), g_1.f, g_1.t)
-                 Merge Cond: (g_1.f = sg.t)
-                 ->  Sort
+                 Hash Cond: (g_1.f = sg.t)
+                 ->  Seq Scan on pg_temp.graph0 g_1
                        Output: g_1.f, g_1.t, g_1.label
-                       Sort Key: g_1.f
-                       ->  Seq Scan on pg_temp.graph0 g_1
-                             Output: g_1.f, g_1.t, g_1.label
-                 ->  Sort
+                       Bloom Filter 1: keys=(g_1.f) expected=50.4%
+                 ->  Hash
                        Output: sg.seq, sg.t
-                       Sort Key: sg.t
+                       Bloom Filter 1
                        ->  WorkTable Scan on search_graph sg
                              Output: sg.seq, sg.t
    ->  CTE Scan on search_graph
          Output: search_graph.f, search_graph.t, search_graph.label, search_graph.seq
-(22 rows)
+(20 rows)
 
 with recursive search_graph(f, t, label) as (
 	select * from graph0 g
@@ -1095,20 +1091,20 @@ select * from search_graph;
  5 | 1 | arc 5 -> 1 | f        | {"(5,1)"}
  1 | 2 | arc 1 -> 2 | f        | {"(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | f        | {"(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | f        | {"(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | f        | {"(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | f        | {"(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | f        | {"(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | f        | {"(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | f        | {"(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | f        | {"(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | f        | {"(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | f        | {"(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | f        | {"(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | f        | {"(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | f        | {"(1,4)","(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | f        | {"(1,4)","(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | f        | {"(1,4)","(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | t        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | f        | {"(4,5)","(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | t        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | t        | {"(4,5)","(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | t        | {"(5,1)","(1,4)","(4,5)","(5,1)"}
  2 | 3 | arc 2 -> 3 | f        | {"(1,4)","(4,5)","(5,1)","(1,2)","(2,3)"}
@@ -1133,20 +1129,20 @@ select * from search_graph;
  5 | 1 | arc 5 -> 1 | f        | {"(5,1)"}
  1 | 2 | arc 1 -> 2 | f        | {"(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | f        | {"(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | f        | {"(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | f        | {"(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | f        | {"(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | f        | {"(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | f        | {"(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | f        | {"(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | f        | {"(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | f        | {"(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | f        | {"(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | f        | {"(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | f        | {"(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | f        | {"(1,4)","(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | f        | {"(1,4)","(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | f        | {"(1,4)","(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | t        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | f        | {"(4,5)","(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | t        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | t        | {"(4,5)","(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | t        | {"(5,1)","(1,4)","(4,5)","(5,1)"}
  2 | 3 | arc 2 -> 3 | f        | {"(1,4)","(4,5)","(5,1)","(1,2)","(2,3)"}
@@ -1208,21 +1204,19 @@ select * from search_graph;
      ->  Recursive Union
            ->  Seq Scan on pg_temp.graph g
                  Output: g.f, g.t, g.label, false, ARRAY[ROW(g.f, g.t)]
-           ->  Merge Join
+           ->  Hash Join
                  Output: g_1.f, g_1.t, g_1.label, CASE WHEN (ROW(g_1.f, g_1.t) = ANY (sg.path)) THEN true ELSE false END, array_cat(sg.path, ARRAY[ROW(g_1.f, g_1.t)])
-                 Merge Cond: (g_1.f = sg.t)
-                 ->  Sort
+                 Hash Cond: (g_1.f = sg.t)
+                 ->  Seq Scan on pg_temp.graph g_1
                        Output: g_1.f, g_1.t, g_1.label
-                       Sort Key: g_1.f
-                       ->  Seq Scan on pg_temp.graph g_1
-                             Output: g_1.f, g_1.t, g_1.label
-                 ->  Sort
+                       Bloom Filter 1: keys=(g_1.f) expected=50.4%
+                 ->  Hash
                        Output: sg.path, sg.t
-                       Sort Key: sg.t
+                       Bloom Filter 1
                        ->  WorkTable Scan on search_graph sg
                              Output: sg.path, sg.t
                              Filter: (NOT sg.is_cycle)
-(20 rows)
+(18 rows)
 
 with recursive search_graph(f, t, label) as (
 	select * from graph g
@@ -1242,20 +1236,20 @@ select * from search_graph;
  5 | 1 | arc 5 -> 1 | f        | {"(5,1)"}
  1 | 2 | arc 1 -> 2 | f        | {"(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | f        | {"(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | f        | {"(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | f        | {"(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | f        | {"(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | f        | {"(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | f        | {"(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | f        | {"(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | f        | {"(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | f        | {"(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | f        | {"(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | f        | {"(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | f        | {"(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | f        | {"(1,4)","(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | f        | {"(1,4)","(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | f        | {"(1,4)","(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | t        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | f        | {"(4,5)","(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | t        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | t        | {"(4,5)","(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | t        | {"(5,1)","(1,4)","(4,5)","(5,1)"}
  2 | 3 | arc 2 -> 3 | f        | {"(1,4)","(4,5)","(5,1)","(1,2)","(2,3)"}
@@ -1279,20 +1273,20 @@ select * from search_graph;
  5 | 1 | arc 5 -> 1 | N        | {"(5,1)"}
  1 | 2 | arc 1 -> 2 | N        | {"(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | N        | {"(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | N        | {"(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | N        | {"(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | N        | {"(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | N        | {"(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | N        | {"(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | N        | {"(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | N        | {"(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | N        | {"(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | N        | {"(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | N        | {"(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | N        | {"(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | N        | {"(1,4)","(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | N        | {"(1,4)","(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | N        | {"(1,4)","(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | Y        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | N        | {"(4,5)","(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | Y        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | Y        | {"(4,5)","(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | Y        | {"(5,1)","(1,4)","(4,5)","(5,1)"}
  2 | 3 | arc 2 -> 3 | N        | {"(1,4)","(4,5)","(5,1)","(1,2)","(2,3)"}
@@ -1444,20 +1438,20 @@ select * from search_graph;
  5 | 1 | arc 5 -> 1 | {"(5,1)"}                                 | f        | {"(5,1)"}
  1 | 2 | arc 1 -> 2 | {"(5,1)","(1,2)"}                         | f        | {"(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | {"(5,1)","(1,3)"}                         | f        | {"(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | {"(5,1)","(1,4)"}                         | f        | {"(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | {"(1,2)","(2,3)"}                         | f        | {"(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | {"(5,1)","(1,4)"}                         | f        | {"(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | {"(1,4)","(4,5)"}                         | f        | {"(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | {"(4,5)","(5,1)"}                         | f        | {"(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | {"(4,5)","(5,1)","(1,2)"}                 | f        | {"(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | {"(4,5)","(5,1)","(1,3)"}                 | f        | {"(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | {"(4,5)","(5,1)","(1,4)"}                 | f        | {"(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | {"(5,1)","(1,2)","(2,3)"}                 | f        | {"(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | {"(4,5)","(5,1)","(1,4)"}                 | f        | {"(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | {"(5,1)","(1,4)","(4,5)"}                 | f        | {"(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | {"(1,4)","(4,5)","(5,1)"}                 | f        | {"(1,4)","(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | {"(1,4)","(4,5)","(5,1)","(1,2)"}         | f        | {"(1,4)","(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | {"(1,4)","(4,5)","(5,1)","(1,3)"}         | f        | {"(1,4)","(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | {"(1,4)","(4,5)","(5,1)","(1,4)"}         | t        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | {"(4,5)","(5,1)","(1,2)","(2,3)"}         | f        | {"(4,5)","(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | {"(1,4)","(4,5)","(5,1)","(1,4)"}         | t        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | {"(4,5)","(5,1)","(1,4)","(4,5)"}         | t        | {"(4,5)","(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | {"(5,1)","(1,4)","(4,5)","(5,1)"}         | t        | {"(5,1)","(1,4)","(4,5)","(5,1)"}
  2 | 3 | arc 2 -> 3 | {"(1,4)","(4,5)","(5,1)","(1,2)","(2,3)"} | f        | {"(1,4)","(4,5)","(5,1)","(1,2)","(2,3)"}
@@ -1482,20 +1476,20 @@ select * from search_graph;
  5 | 1 | arc 5 -> 1 | (0,5,1) | f        | {"(5,1)"}
  1 | 2 | arc 1 -> 2 | (1,1,2) | f        | {"(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | (1,1,3) | f        | {"(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | (1,1,4) | f        | {"(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | (1,2,3) | f        | {"(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | (1,1,4) | f        | {"(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | (1,4,5) | f        | {"(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | (1,5,1) | f        | {"(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | (2,1,2) | f        | {"(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | (2,1,3) | f        | {"(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | (2,1,4) | f        | {"(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | (2,2,3) | f        | {"(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | (2,1,4) | f        | {"(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | (2,4,5) | f        | {"(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | (2,5,1) | f        | {"(1,4)","(4,5)","(5,1)"}
  1 | 2 | arc 1 -> 2 | (3,1,2) | f        | {"(1,4)","(4,5)","(5,1)","(1,2)"}
  1 | 3 | arc 1 -> 3 | (3,1,3) | f        | {"(1,4)","(4,5)","(5,1)","(1,3)"}
- 1 | 4 | arc 1 -> 4 | (3,1,4) | t        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  2 | 3 | arc 2 -> 3 | (3,2,3) | f        | {"(4,5)","(5,1)","(1,2)","(2,3)"}
+ 1 | 4 | arc 1 -> 4 | (3,1,4) | t        | {"(1,4)","(4,5)","(5,1)","(1,4)"}
  4 | 5 | arc 4 -> 5 | (3,4,5) | t        | {"(4,5)","(5,1)","(1,4)","(4,5)"}
  5 | 1 | arc 5 -> 1 | (3,5,1) | t        | {"(5,1)","(1,4)","(4,5)","(5,1)"}
  2 | 3 | arc 2 -> 3 | (4,2,3) | f        | {"(1,4)","(4,5)","(5,1)","(1,2)","(2,3)"}
@@ -1675,20 +1669,20 @@ select * from v_cycle1;
  5 | 1 | arc 5 -> 1
  1 | 2 | arc 1 -> 2
  1 | 3 | arc 1 -> 3
- 1 | 4 | arc 1 -> 4
  2 | 3 | arc 2 -> 3
+ 1 | 4 | arc 1 -> 4
  4 | 5 | arc 4 -> 5
  5 | 1 | arc 5 -> 1
  1 | 2 | arc 1 -> 2
  1 | 3 | arc 1 -> 3
- 1 | 4 | arc 1 -> 4
  2 | 3 | arc 2 -> 3
+ 1 | 4 | arc 1 -> 4
  4 | 5 | arc 4 -> 5
  5 | 1 | arc 5 -> 1
  1 | 2 | arc 1 -> 2
  1 | 3 | arc 1 -> 3
- 1 | 4 | arc 1 -> 4
  2 | 3 | arc 2 -> 3
+ 1 | 4 | arc 1 -> 4
  4 | 5 | arc 4 -> 5
  5 | 1 | arc 5 -> 1
  2 | 3 | arc 2 -> 3
@@ -1705,20 +1699,20 @@ select * from v_cycle2;
  5 | 1 | arc 5 -> 1
  1 | 2 | arc 1 -> 2
  1 | 3 | arc 1 -> 3
- 1 | 4 | arc 1 -> 4
  2 | 3 | arc 2 -> 3
+ 1 | 4 | arc 1 -> 4
  4 | 5 | arc 4 -> 5
  5 | 1 | arc 5 -> 1
  1 | 2 | arc 1 -> 2
  1 | 3 | arc 1 -> 3
- 1 | 4 | arc 1 -> 4
  2 | 3 | arc 2 -> 3
+ 1 | 4 | arc 1 -> 4
  4 | 5 | arc 4 -> 5
  5 | 1 | arc 5 -> 1
  1 | 2 | arc 1 -> 2
  1 | 3 | arc 1 -> 3
- 1 | 4 | arc 1 -> 4
  2 | 3 | arc 2 -> 3
+ 1 | 4 | arc 1 -> 4
  4 | 5 | arc 4 -> 5
  5 | 1 | arc 5 -> 1
  2 | 3 | arc 2 -> 3
-- 
2.34.1

