FilterChain Tutorial

Copy Markdown View Source
Mix.install([
  {:ex_data_sketch, "~> 0.10"}
],
config: [
    ex_data_sketch: [
      backend: ExDataSketch.Backend.Rust,
      integrations: [opentelemetry: false]
    ]
  ])

Introduction

ExDataSketch.FilterChain composes multiple membership filters into a single query pipeline -- a cheap, larger first-pass filter to reject most non-members fast, backed by a smaller, more precise second-pass filter for the items that survive the first check. It's not a new sketch algorithm; it's a capability-aware wrapper around the filters covered in the other tutorials here (Bloom, Cuckoo, Quotient, CQF, XorFilter) plus IBLT as a non-queryable "adjunct." member?/2 short-circuits on the first stage that says "definitely not":

flowchart LR
    A[item] --> B["Stage 1\n(cheap, looser FPR)"]
    B -->|no| C["definitely\nnot present"]
    B -->|maybe| D["Stage 2\n(pricier, tighter FPR)"]
    D -->|no| C
    D -->|maybe| E["... more stages"]
    E --> F["every stage said maybe:\nprobably present"]

A false positive now requires every stage to independently false-positive on the same item -- since each stage's false-positive rate is (roughly) independent, chaining multiplies them together, not adds them. put/2 fans an insert out to every stage that supports writes (skipping static XorFilter stages); delete/2 requires every stage to support deletion or it raises.

Use it for: squeezing a much lower compound false-positive rate out of filters you'd use anyway, at the cost of a second lookup only for items that survive the first -- most non-members get rejected cheaply by the first stage and never reach the second. Also for attaching an IBLT stage alongside a query chain for reconciliation.

Don't use it for: cases where a single filter already meets your accuracy target -- chaining adds real per-query overhead (another hash, another lookup) for items that pass the first stage, which buys you nothing if the first stage alone was already precise enough. Deletion across a chain that includes any non-deletable stage (Bloom, XorFilter) isn't possible either -- delete/2 raises upfront rather than silently skipping that stage.

What it buys you: chaining a Bloom (5% target FPR) with a Cuckoo stage measured ~0.14% observed false-positive rate against 100,000 novel items -- versus Bloom alone's ~4.8% on the exact same input, roughly a 34x reduction, from filters you'd likely reach for individually anyway:

ApproachObserved FPR (100,000 novel items)
ExDataSketch.Bloom alone (5% target)~4.8%
FilterChain (Bloom + Cuckoo)~0.14%

Sample data (cached locally)

{inserted, novel} = ExDataSketch.SampleData.filter_chain_users()
IO.puts("#{length(inserted)} inserted, #{length(novel)} novel users")

Building a chain

alias ExDataSketch.{Bloom, Cuckoo, FilterChain}

chain =
  FilterChain.new()
  |> FilterChain.add_stage(Bloom.new(capacity: 500_000, false_positive_rate: 0.05))
  |> FilterChain.add_stage(Cuckoo.new(capacity: 500_000))

FilterChain.stages(chain) |> Enum.map(& &1.__struct__)

Inserting: fans out to every writable stage

{:ok, chain} = FilterChain.put(chain, "hello")
FilterChain.member?(chain, "hello")

put_many/2 for a batch -- it batches through each stage's own put_many/2 (Rust-accelerated where a stage's backend supports it) instead of looping put/2 once per item, which matters a lot at this scale: for 500,000 items across two 500,000-capacity stages, item-by-item put/2 calls would take on the order of a minute, since every family's single-item put/2 always runs in the Pure backend (no per-item Rust NIF exists for any family) and reconstructs its entire state binary per call. Returns {:ok, chain} or {:error, :full, partial_chain}:

{:ok, chain} = FilterChain.put_many(chain, inserted)
IO.puts("Both stages now contain the inserted set")

update_many/2 (its Sketch-behaviour-compatible name, raising instead of returning {:error, :full, ...}) delegates to put_many/2 and is equally fast.

Querying: short-circuit AND across stages

member?/2 only reports "yes" if every stage agrees -- it stops at the first "no." A false positive requires every stage to independently false-positive on the same item, which is far less likely than any one filter false-positiving alone -- the compounding accuracy gain is the whole point of chaining:

false_positives = Enum.count(novel, &FilterChain.member?(chain, &1))
observed_fpr = false_positives / length(novel)
IO.puts("Chain false positives: #{false_positives} / #{length(novel)} (#{Float.round(observed_fpr * 100, 5)}%)")

# Compare against the first stage (Bloom, configured at a loose 5% FPR) alone:
bloom_only = Bloom.new(capacity: 500_000, false_positive_rate: 0.05) |> Bloom.put_many(inserted)
bloom_fps = Enum.count(Enum.take(novel, 100_000), &Bloom.member?(bloom_only, &1))
IO.puts("Bloom stage alone (5% target): #{Float.round(bloom_fps / 100_000 * 100, 2)}% observed")

Deletion requires every stage to support it

This chain has a Bloom stage, and Bloom has no delete/2 -- calling FilterChain.delete/2 on it raises:

try do
  FilterChain.delete(chain, "hello")
rescue
  e in ExDataSketch.Errors.UnsupportedOperationError -> IO.puts("Raised as expected: #{Exception.message(e)}")
end

A chain built entirely from delete-capable stages (Cuckoo, Quotient, CQF) supports it:

alias ExDataSketch.Quotient

deletable_chain =
  FilterChain.new()
  |> FilterChain.add_stage(Cuckoo.new(capacity: 1000))
  |> FilterChain.add_stage(Quotient.new(q: 12, r: 8))

{:ok, deletable_chain} = FilterChain.put(deletable_chain, "temp_item")
IO.puts("Before delete: #{FilterChain.member?(deletable_chain, "temp_item")}")

deletable_chain = FilterChain.delete(deletable_chain, "temp_item")
IO.puts("After delete: #{FilterChain.member?(deletable_chain, "temp_item")}")

A static terminal stage: XorFilter

XorFilter can only be the last query stage (no incremental put/2 after construction) -- useful as a precise, space-efficient final check behind a mutable first pass that absorbs new writes.

There's a real limitation worth understanding here: member?/2 requires every stage to agree, XorFilter included. put/2 correctly skips writing to the static XorFilter stage, so a genuinely new item lands in Cuckoo but not in XorFilter -- meaning FilterChain.member?/2 on the whole chain will (correctly) say "no" for it, forever, until the XorFilter stage is rebuilt to include it. Absorbing new writes into Cuckoo doesn't make them queryable through the combined chain; it just means Cuckoo, queried on its own, already has the answer XorFilter doesn't have yet:

alias ExDataSketch.XorFilter

{:ok, xor} = XorFilter.build(Enum.take(inserted, 100_000))

hybrid_chain =
  FilterChain.new()
  |> FilterChain.add_stage(Cuckoo.new(capacity: 500_000))
  |> FilterChain.add_stage(xor)

# put/2 skips the static XorFilter stage automatically -- only the
# Cuckoo stage actually receives new writes.
{:ok, hybrid_chain} = FilterChain.put(hybrid_chain, "newly_seen_item")

[cuckoo_stage, _xor_stage] = FilterChain.stages(hybrid_chain)
IO.puts("Present in Cuckoo stage alone: #{Cuckoo.member?(cuckoo_stage, "newly_seen_item")}")
IO.puts("Present via the whole chain (Cuckoo AND XorFilter): #{FilterChain.member?(hybrid_chain, "newly_seen_item")}")

Serialization

binary = FilterChain.serialize(chain)
{:ok, restored} = FilterChain.deserialize(binary)
IO.puts("Round-tripped membership check: #{FilterChain.member?(restored, hd(inserted))}")

See also

  • ExDataSketch.FilterChain module documentation -- full API reference, including IBLT adjunct stages for reconciliation alongside a query chain.
  • livebooks/sketches/bloom.livemd, cuckoo.livemd, quotient.livemd, cqf.livemd, xor_filter.livemd -- the individual filter families this module composes.