Real-time customer segmentation tutorial
This tutorial builds a promotion decision from values in Redis, updates those values live, and compiles the rule for repeated low-latency calls.
By the end, you will have followed the complete serving lifecycle: seed data, define a source-backed feature, refresh the serving process, evaluate it, observe a live data change, and create a prepared statement.
Complete the FeatureMesh homepage walkthrough first if source features or serving refreshes are new. These examples require the serving backend, Redis, and a serving client. Run them from top to bottom because later steps reuse the persisted model and mutated Redis data.
Meet the three customers
Each customer is stored as a Redis hash with two precomputed values:
days_since_ordermeasures recency.lifetime_value_centsstores currency as an integer.
Seed the three hashes:
DEL tutorial:segment:1 tutorial:segment:2 tutorial:segment:3
HSET tutorial:segment:1 days_since_order 45 lifetime_value_cents 15000
HSET tutorial:segment:2 days_since_order 10 lifetime_value_cents 8000
HSET tutorial:segment:3 days_since_order 60 lifetime_value_cents 5000The starting values are:
- Customer
1: 45 days, 15000 cents - Customer
2: 10 days, 8000 cents - Customer
3: 60 days, 5000 cents
Now reset the tutorial feature namespace so the model starts clean:
DROP FEATURES IF EXISTS IN FM.TUTORIALS.REALTIME_SEGMENTATION UP TO LEVEL 9;| feature_name VARCHAR | status VARCHAR | message VARCHAR |
|---|
The promotion rule will return true when the last order was at least 30 days ago and lifetime value is at least $100 (10000 cents).
Define the serving model
The model follows the same path the serving query will execute:
- Bind a typed
CUSTOMER_ID. - Build the corresponding Redis key.
- Read the two hash fields with
EXTERNAL_REDIS(). - Cast the external values to
BIGINT. - Evaluate the
SHOW_PROMOrule.
CREATE OR REPLACE FEATURES IN FM.TUTORIALS.REALTIME_SEGMENTATION AS
PERSIST
CUSTOMERS := ENTITY(),
CUSTOMER_ID := INPUT(BIGINT#CUSTOMERS),
REDIS_SOURCE := SOURCE_REDIS(
'redis://host.docker.internal:6380'
WITH (timeout='500ms')
),
REDIS_KEY := 'tutorial:segment:' || CAST(KEY_TO_SCALAR(CUSTOMER_ID) AS VARCHAR),
DAYS_SINCE_ORDER := CAST(
EXTERNAL_REDIS(KEY REDIS_KEY FIELD 'days_since_order' FROM REDIS_SOURCE)
AS BIGINT
),
LIFETIME_VALUE_CENTS := CAST(
EXTERNAL_REDIS(KEY REDIS_KEY FIELD 'lifetime_value_cents' FROM REDIS_SOURCE)
AS BIGINT
),
SHOW_PROMO := DAYS_SINCE_ORDER >= 30 AND LIFETIME_VALUE_CENTS >= 10000
;| feature_name VARCHAR | status VARCHAR | message VARCHAR |
|---|---|---|
| FM.TUTORIALS.REALTIME_SEGMENTATION.CUSTOMERS | CREATED | Feature created as not exists |
| FM.TUTORIALS.REALTIME_SEGMENTATION.CUSTOMER_ID | CREATED | Feature created as not exists |
| FM.TUTORIALS.REALTIME_SEGMENTATION.REDIS_SOURCE | CREATED | Feature created as not exists |
| FM.TUTORIALS.REALTIME_SEGMENTATION.REDIS_KEY | CREATED | Feature created as not exists |
| FM.TUTORIALS.REALTIME_SEGMENTATION.DAYS_SINCE_ORDER | CREATED | Feature created as not exists |
| FM.TUTORIALS.REALTIME_SEGMENTATION.LIFETIME_VALUE_CENTS | CREATED | Feature created as not exists |
| FM.TUTORIALS.REALTIME_SEGMENTATION.SHOW_PROMO | CREATED | Feature created as not exists |
Persisting this definition updates the registry, but the running serving process does not automatically install the new connection. Refresh the Redis source before evaluating the model:
REFRESH FEATURES FM.TUTORIALS.REALTIME_SEGMENTATION.REDIS_SOURCE;| FEATURE VARCHAR | KIND VARCHAR | STATUS VARCHAR | MESSAGE VARCHAR |
|---|---|---|---|
| FM.TUTORIALS.REALTIME_SEGMENTATION.REDIS_SOURCE | SOURCE_REDIS | REFRESHED | (empty) |
This refresh is about the source definition—its URL, timeout, and connection—not the customer values stored behind it.
Evaluate the starting population
Bind all three customer IDs and return the source values beside the decision:
RETURN
FM.TUTORIALS.REALTIME_SEGMENTATION.CUSTOMER_ID := BIND_VALUES(ARRAY[1, 2, 3]),
FM.TUTORIALS.REALTIME_SEGMENTATION.DAYS_SINCE_ORDER,
FM.TUTORIALS.REALTIME_SEGMENTATION.LIFETIME_VALUE_CENTS,
FM.TUTORIALS.REALTIME_SEGMENTATION.SHOW_PROMO
;| CUSTOMER_ID BIGINT | DAYS_SINCE_ORDER BIGINT | LIFETIME_VALUE_CENTS BIGINT | SHOW_PROMO BOOLEAN |
|---|---|---|---|
| 1 | 45 | 15000 | TRUE |
| 2 | 10 | 8000 | FALSE |
| 3 | 60 | 5000 | FALSE |
Customer 1 passes both thresholds. Customer 2 is too recent and below $100; customer 3 is old enough but below $100.
Observe a live Redis update
Now simulate a reverse-ETL update for customer 2. Its Redis hash changes to 40 days since the last order and 20000 cents in lifetime value.
The next query reads the new values and reevaluates the existing feature:
RETURN
FM.TUTORIALS.REALTIME_SEGMENTATION.CUSTOMER_ID := BIND_VALUE(2),
FM.TUTORIALS.REALTIME_SEGMENTATION.SHOW_PROMO
;| CUSTOMER_ID BIGINT | SHOW_PROMO BOOLEAN |
|---|---|
| 2 | TRUE |
Customer 2 now returns true. No refresh was needed because the source definition did not change; only the data behind the connection changed.
Compile the rule for serving
An ad-hoc serving query still needs to compile and plan the FeatureQL expression. PREPARED_STATEMENT() compiles SHOW_PROMO once and declares CUSTOMER_ID as its input:
CREATE OR REPLACE FEATURE FM.TUTORIALS.REALTIME_SEGMENTATION.SHOW_PROMO_PS AS
PREPARED_STATEMENT(
FM.TUTORIALS.REALTIME_SEGMENTATION.SHOW_PROMO
USING FM.TUTORIALS.REALTIME_SEGMENTATION.CUSTOMER_ID
);| feature_name VARCHAR | status VARCHAR | message VARCHAR |
|---|---|---|
| FM.TUTORIALS.REALTIME_SEGMENTATION.SHOW_PROMO_PS | CREATED | Feature created as not exists |
Like a new source, a newly persisted prepared statement must be refreshed into the serving process:
REFRESH FEATURES FM.TUTORIALS.REALTIME_SEGMENTATION.SHOW_PROMO_PS;| FEATURE VARCHAR | KIND VARCHAR | STATUS VARCHAR | MESSAGE VARCHAR |
|---|---|---|---|
| FM.TUTORIALS.REALTIME_SEGMENTATION.SHOW_PROMO_PS | PREPARED_STATEMENT | REFRESHED | (empty) |
Call it first with all three customers, then with customer 2 alone:
FM.TUTORIALS.REALTIME_SEGMENTATION.SHOW_PROMO_PS{"input_table_1": [[1], [2], [3]]} | CUSTOMER_ID BIGINT | SHOW_PROMO_PS BOOLEAN |
|---|---|
| 1 | TRUE |
| 2 | TRUE |
| 3 | FALSE |
{"input_table_1": [[2]]} | CUSTOMER_ID BIGINT | SHOW_PROMO_PS BOOLEAN |
|---|---|
| 2 | TRUE |
The first response returns true for customers 1 and 2, and false for 3. The second response contains only customer 2. Each payload becomes a separate input table and receives a separate result.
Full query vs prepared statement
The notebook can compare both call paths. The first loop sends the full FeatureQL query 100 times; the second reuses the prepared plan and changes only its input rows:
import time
_N = 100
_full_query = """
RETURN
FM.TUTORIALS.REALTIME_SEGMENTATION.CUSTOMER_ID,
FM.TUTORIALS.REALTIME_SEGMENTATION.SHOW_PROMO
FOR
FM.TUTORIALS.REALTIME_SEGMENTATION.CUSTOMER_ID := BIND_VALUES(ARRAY[1, 2, 3])
;
"""
_prepared_id = "FM.TUTORIALS.REALTIME_SEGMENTATION.SHOW_PROMO_PS"
_prepared_inputs = {"input_table_1": [[1], [2], [3]]}
_t0 = time.perf_counter()
for _ in range(_N):
_result = serving_client.query(_full_query)
if _result.errors:
raise RuntimeError(_result.errors)
_full_seconds = time.perf_counter() - _t0
_t0 = time.perf_counter()
for _ in range(_N):
_result = serving_client.execute_prepared_statement(_prepared_id, _prepared_inputs)
if _result.errors:
raise RuntimeError(_result.errors)
_prepared_seconds = time.perf_counter() - _t0
print(
f"Full FeatureQL query × {_N}: {_full_seconds:.3f}s "
f"({_full_seconds / _N * 1000:.2f} ms/call)"
)
print(
f"Prepared statement × {_N}: {_prepared_seconds:.3f}s "
f"({_prepared_seconds / _N * 1000:.2f} ms/call)"
)
print(f"Speedup: {_full_seconds / _prepared_seconds:.1f}×") Run this cell in the notebook to discover the result. Treat the printed timings as a local observation, not a universal benchmark. Network latency, warm-up, and the serving environment all affect the ratio. The architectural difference is stable: the prepared path avoids recompiling the same feature graph on every request.
What's next
You have separated source installation from live source data, then exposed the same promotion rule through an ad-hoc query and a prepared statement.
Continue with Federated serving to follow one feature graph across PostgreSQL and Redis. Use the Redis source and prepared statement pages when you need complete syntax and payload options.