Making Search Results Faster By Starting With Less
Hello! I've been thinking about search systems lately and noticed something weird: when you add more filters while shopping online, the searches don't get any faster. Even though there are now fewer items to look through! Let's dig into why this happens and how we could fix it.
The Problem: Filters Should Get Faster, But Don't
Imagine you're helping someone shop in a huge warehouse of shoes. They say:
"I want blue Nike sneakers in size 10, under $100"
How would you actually find these shoes? You wouldn't check every single shoe against all these conditions. That would be exhausting! Instead, you'd probably:
- First find the sneakers section and make a pile of just sneakers
- Look through that pile for blue ones, making a new smaller pile
- From those blue sneakers, pull out just the Nikes
- Then check sizes on that much smaller pile
- Finally check prices on the few remaining shoes
Each time you filter, you're working with a smaller and smaller pile. You'd never go back to check the whole warehouse again!
But surprisingly, most computer search systems DO check the whole warehouse every time. They're like an incredibly dedicated but inefficient clerk who keeps starting over from scratch:
YOU: "Show me sneakers"
CLERK: *checks all 100,000 shoes*
"Here's 40,000 sneakers!"
YOU: "Just blue ones"
CLERK: *checks all 100,000 shoes again*
"Here's 8,000 blue sneakers!"
YOU: "Only Nikes"
CLERK: *checks all 100,000 shoes yet again*
"Here's 2,000 blue Nikes!"
Something seems wrong here. When we added the "blue" filter, we went from 100,000 possible shoes down to 20,000. So why did the next search still take the same amount of time? We know there are only 20,000 blue shoes to check – why are we looking through all 100,000 shoes again?
What's Actually Happening Behind the Scenes
Most search systems work like this:
-- What actually runs for each filter combination
SELECT * FROM shoes
WHERE color = 'blue'
AND brand = 'nike'
AND size = 10;
-- The database still:
-- 1. Scans the whole shoes table
-- 2. Checks every row against all conditions
-- 3. Returns matching results
Even though each filter reduces the number of results we'll eventually show, the database still has to check every single row against all the conditions. It's like having a very dedicated but inefficient store clerk:
YOU: "I want blue Nike shoes in size 10"
CLERK: "Let me check every shoe in the store..."
*walks past 100,000 shoes, checking each one*
"Here are your 500 matches!"
YOU: "Actually, I only want the ones under $100"
CLERK: "Let me check every shoe in the store again..."
*walks past all 100,000 shoes once more*
A Better Way: Remember What We Found
Here's how a more efficient system could work:
YOU: "Show me blue shoes"
CLERK: *checks all shoes, makes a pile of blue ones*
"Here are 20,000 blue shoes! I'll keep this pile handy."
YOU: "From those, show me Nike ones"
CLERK: *only looks through the pile of blue shoes*
"Found 5,000 blue Nike shoes! I'll make a new pile."
YOU: "From those, size 10 please"
CLERK: *only looks through the blue Nike pile*
"Here are your 500 matches!"
Each time we add a filter, we only need to look through the results from our previous search. This seems obvious when we think about how we'd do it in person, but it's not how most search systems work!
Why Don't Systems Already Work This Way?
The obvious question is: why don't search systems already do this? There are a few reasons:
-
Cache Invalidation: What if the underlying data changes? If someone adds a new blue Nike shoe, should we update all the cached piles that might include it?
-
Storage Space: Keeping track of all these intermediate result sets takes space. If you have 1 million users all doing different searches, that's a lot of piles to manage!
-
Consistency: Users expect search results to be consistent. If you show them a pile of blue shoes from 5 minutes ago, but the actual inventory has changed, they might see outdated information.
But here's the thing: these aren't unsolvable problems. They're just problems that require us to think differently about how we structure our search system. Instead of treating each search as an independent query that starts from scratch, what if we thought about searches as a series of transformations on existing result sets?
Introducing the Pile Abstraction
This brings us to the core idea: instead of thinking about searches as queries that run from scratch each time, let's think about them as operations that create and transform piles of results. A pile is simply:
- An ordered list of IDs from our main table (like product IDs)
- A label we can use to reference this pile later
- Information about when and how the pile was created
Now our search flow becomes:
Initial search: "blue shoes"
→ Creates Pile A: 20,000 product IDs
Add filter: "nike only"
→ Takes Pile A as input
→ Creates Pile B: 5,000 product IDs
Add filter: "size 10"
→ Takes Pile B as input
→ Creates Pile C: 500 product IDs
The key insights are:
- Each pile has a unique label we can reference
- New filters operate on existing piles instead of starting from scratch
- The system can track how piles were created and when they might need updating
This isn't just an optimization - it's a fundamentally different way of thinking about how search should work. And as we'll see, it enables some powerful capabilities that go way beyond just making filters faster.
One API To Rule Them All
The entire system is built around a single API endpoint. Every search operation looks like this:
searchClient.filter({
base: "pile_label", // Which pile to start with
filters: [ // List of filters to apply
filter1,
filter2,
...
]
}) -> new_pile_label
That's the whole API! Let's see it in action with a simple example:
// Initial search
const blueShoesPile = await searchClient.filter({
base: "latest", // A mutable label that tracks current data
filters: [{ type: "color", value: "blue" }],
})
// Returns: { pile: "pile_A", status: "complete" }
// Add another filter
const blueNikeShoesPile = await searchClient.filter({
base: "pile_A", // Start with blue shoes
filters: [{ type: "brand", value: "nike" }],
})
// Returns: { pile: "pile_B", status: "complete" }
Understanding Pile Labels
A pile label is just a reference to a result set, but there are two important types:
Mutable Labels
// These labels might point to different results over time:
"latest" // Always tracks current data
"in_progress_123" // A computation that's still running
"user_favorites" // A user's changing list
// The system tells you what you're actually getting:
{
pile: "in_progress_123",
status: "computing",
progress: 0.45, // 45% done
resolved_from: "catalog_20240201_1415"
}
Immutable Labels
// These always point to the same results:
"catalog_20240201_1415" // Specific data snapshot
"computation_xyz_done" // Completed calculation
{
pile: "computation_xyz_done",
status: "complete",
resolved_from: "catalog_20240201_1415"
}
This distinction lets clients make smart decisions about their queries:
// Starting fresh from latest data
const results1 = await searchClient.filter({
base: "latest",
filters: [
{ type: "color", value: "blue" },
{ type: "brand", value: "nike" },
],
})
// Continuing from a previous result
const results2 = await searchClient.filter({
base: "pile_A", // Known complete result
filters: [{ type: "brand", value: "nike" }],
})
// Watching a computation in progress
const results3 = await searchClient.filter({
base: "in_progress_123",
filters: [{ type: "price", max: 100 }],
})
// Returns: {
// pile: "in_progress_124",
// status: "computing",
// parent_progress: 0.45
// }
Example: A Complete Search Flow
Let's see how this works in a real shopping scenario:
// Initial page load
const response = await searchClient.filter({
base: "latest",
filters: [],
})
// Returns: {
// pile: "pile_X",
// status: "complete",
// resolved_from: "catalog_20240201_1415"
// }
// User adds color filter
const blueResults = await searchClient.filter({
base: "pile_X", // We know this is complete
filters: [{ type: "color", value: "blue" }],
})
// Returns: { pile: "pile_Y", status: "complete" }
// Start an expensive computation
const smartResults = await searchClient.filter({
base: "pile_Y",
filters: [{ type: "smart_recommendations", model: "winter_outfits" }],
})
// Returns: {
// pile: "computing_123",
// status: "computing",
// progress: 0.0
// }
// Client can poll or watch for updates
const progress = await searchClient.filter({
base: "computing_123",
filters: [],
})
// Returns: {
// pile: "computing_123",
// status: "computing",
progress: 0.3
// }
Why This Matters
This design gives us incredible flexibility:
- Consistency: You can choose between latest data or stable snapshots
- Caching: Completed computations become reusable building blocks
- Progressive Loading: Long computations can stream results as they're ready
- Flexibility: Clients can decide whether to rerun entire chains or continue from intermediate steps
- Simplicity: One API handles everything from simple filters to complex streaming computations
But we're just getting started! Because every operation just creates a new pile (whether immediately or progressively), we can add all sorts of interesting ways to filter results without changing the API at all. In the next section, we'll look at how this same simple API can handle complex operations like joining against auxiliary tables.
Adding Power with Joins: Making Personal Lists Fast
So far we've looked at filtering one table. But real applications need to combine data from multiple sources. You might want to:
- Show only items from a user's wishlist
- Filter by user-assigned ratings
- Match against custom tags
- Check available inventory
Let's see how the pile abstraction handles this elegantly.
The Key Insight: Joins Are Just Filters
When you think about it, checking if something is in your wishlist is just another kind of filter:
// These are conceptually the same:
{ type: "color", value: "blue" } // Regular filter
{ type: "in_wishlist", user_id: "sarah" } // Join filter
// Both just say "keep this item or not"
The difference is where the data comes from. A color filter checks a column in our main table, while a wishlist filter needs to check another table. But to the pile API, they're both just filters!
Example: Sarah's Shopping Experience
Let's see how this works in practice:
// Get items Sarah has marked as "Amazing"
const results = await searchClient.filter({
base: "latest",
filters: [
{
type: "rating_check",
user: "sarah",
value: "Amazing"
}
]
})
// Returns: {
// pile: "pile_A",
// status: "complete",
// resolved: {
// latest: "catalog_20240201_1415",
// sarah_ratings: "ratings_20240201_1415"
// }
}
Notice something interesting? The response tells us which versions of both tables were used:
- The main catalog snapshot
- The snapshot of Sarah's ratings
This is crucial! It means we know exactly what data state our pile represents.
Building Complex Queries
The real power comes when we combine regular filters with joins:
// Find blue Nike shoes that Sarah rated "Amazing"
const results = await searchClient.filter({
base: "latest",
filters: [
{ type: "color", value: "blue" },
{ type: "brand", value: "nike" },
{
type: "rating_check",
user: "sarah",
value: "Amazing",
},
],
})
The system is smart about the order of operations:
- First apply cheap filters (color, brand)
- Then do the join against ratings
- All while maintaining our pile abstraction!
Handling Changes to Auxiliary Data
What happens when Sarah updates a rating? The system handles this gracefully:
// Original search using ratings
const results1 = await searchClient.filter({
base: "latest",
filters: [
{ type: "rating_check", user: "sarah", value: "Amazing" }
]
})
// Returns: {
// pile: "pile_A",
// resolved: {
// latest: "catalog_20240201_1415",
// sarah_ratings: "ratings_20240201_1415"
// }
}
// Sarah updates some ratings...
// Same search after ratings change
const results2 = await searchClient.filter({
base: "latest",
filters: [
{ type: "rating_check", user: "sarah", value: "Amazing" }
]
})
// Returns: {
// pile: "pile_B", // New pile!
// resolved: {
// latest: "catalog_20240201_1415",
// sarah_ratings: "ratings_20240201_1416" // New ratings version
// }
}
The client can detect that Sarah's ratings changed and decide whether to:
- Keep showing results from the old ratings snapshot
- Rerun the query to get updated results
- Show a "Refresh available" notification
Progressive Loading with Joins
Remember how pile labels can be mutable? This is especially powerful with joins:
// Start an expensive join operation
const results = await searchClient.filter({
base: "latest",
filters: [
{ type: "color", value: "blue" },
{
type: "style_match", // Complex join against style database
style: "winter_casual"
}
]
})
// Returns: {
// pile: "computing_123",
// status: "computing",
// progress: 0.0,
// resolved: {
// latest: "catalog_20240201_1415",
// style_db: "styles_20240201_1415"
// }
}
// Client can watch for updates
const progress = await searchClient.filter({
base: "computing_123",
filters: []
})
// Returns: {
// pile: "computing_123",
// status: "computing",
// progress: 0.3,
// partial_results: true // Can show preliminary matches
}
Composition: Multiple Auxiliary Tables
Because joins are just filters, we can combine them freely:
// Find items that are:
// - In stock
// - In Sarah's wishlist
// - Rated "Amazing" by Sarah
// - Match her style preferences
const results = await searchClient.filter({
base: "latest",
filters: [
{ type: "inventory_check", min_stock: 1 },
{ type: "in_wishlist", user: "sarah" },
{ type: "rating_check", user: "sarah", value: "Amazing" },
{ type: "style_match", user: "sarah", min_score: 0.8 },
],
})
Each join creates a new pile, just like regular filters. The system can:
- Order operations efficiently
- Cache intermediate results
- Track data freshness for each source
- Stream results for expensive operations
Why This is Powerful
This approach gives us several benefits:
- Consistent API: Joins don't require special handling in the API
- Efficient Processing: System can optimize join order
- Clear Data Versioning: Know exactly which data versions were used
- Flexible Updates: Can detect and handle changes to any data source
- Progressive Loading: Works with expensive join operations
But we're still just scratching the surface! Next, we'll look at how this same API can handle computations that go beyond simple SQL operations. Want to embed a machine learning model or constraint solver? No problem - they're just another type of filter!
Beyond SQL: When Simple Filters Aren't Enough
Up until now, we've looked at relatively straightforward filters - checking colors, brands, or joining with user data. But what if you want to do something more complex? Let's look at how the pile abstraction handles sophisticated computations while keeping the API simple.
The Problem: Complex Filters Are Expensive
Consider a real estate search that tries to help people find homes that work for hybrid work schedules:
// This looks innocent enough...
const results = await searchClient.filter({
base: "latest",
filters: [
{ type: "price_range", max: 800000 },
{
type: "hybrid_commute",
schedules: [
{
person: "Maya",
office: "Google Cambridge",
days: ["Tuesday", "Thursday"],
mode: "transit",
max_time: 45,
},
{
person: "Alex",
office: "Seaport",
days: ["Monday", "Wednesday", "Friday"],
mode: "drive",
max_time: 30,
},
],
},
],
})
But think about what needs to happen for each property:
- Call transit APIs to get commute times
- Call traffic APIs to get drive times
- Check different times of day
- Validate against everyone's schedules
At $0.01 per API call, checking 1 million properties would cost $100,000!
The Solution: Filter First, Compute Later
Here's where the pile abstraction shines. Instead of running expensive computations on every property, we can:
// Start with basic filters
const initialResults = await searchClient.filter({
base: "latest",
filters: [
{ type: "price_range", max: 800000 },
{ type: "location_radius", center: "Cambridge MA", radius_miles: 15 },
],
})
// Returns: {
// pile: "pile_A",
// status: "complete",
// resolved: { latest: "catalog_20240201_1415" }
// }
// Now run expensive computation on smaller set
const commuteResults = await searchClient.filter({
base: "pile_A", // Start with ~1000 properties instead of 1M!
filters: [
{
type: "hybrid_commute",
schedules: [
/*...*/
],
},
],
})
// Returns: {
// pile: "computing_123",
// status: "computing",
// progress: 0.0
// }
The system can progressively process properties and update the client:
// Check progress
const progress = await searchClient.filter({
base: "computing_123",
filters: [],
})
// Returns: {
// pile: "computing_123",
// status: "computing",
// progress: 0.45,
// partial_results: true,
// results_processed: 450,
// matches_found: 28
// }
Example: Machine Learning Filters
Want to use an ML model to score properties? No problem:
// Find properties likely to appreciate
const results = await searchClient.filter({
base: "boston_properties",
filters: [
{ type: "price_range", max: 800000 },
{
type: "ml_prediction",
model: "property_appreciation",
min_score: 0.8,
},
],
})
The pile system handles:
- Running the model efficiently on filtered sets
- Caching prediction results
- Streaming results as they're processed
- Tracking which model version was used
Example: Constraint Solving
Need to solve complex constraints? Embed a Prolog interpreter:
// Find properties matching complex criteria
const results = await searchClient.filter({
base: "boston_properties",
filters: [
{ type: "price_range", max: 800000 },
{
type: "prolog_rules",
rules: `
good_investment(Property) :-
school_score(Property, SchoolScore),
SchoolScore > 8,
crime_rate(Property, CrimeRate),
CrimeRate < 2,
appreciation_trend(Property, Trend),
Trend > 0.05.
`,
},
],
})
Composition: Building Complex Features
The real power comes from combining different types of filters:
// Find properties that:
const results = await searchClient.filter({
base: "latest",
filters: [
// 1. Basic criteria
{ type: "price_range", max: 800000 },
{ type: "location_radius" /* ... */ },
// 2. Check auxiliary data
{ type: "school_district", min_rating: 8 },
{ type: "crime_stats", max_rate: 2 },
// 3. Run ML model
{
type: "ml_prediction",
model: "property_appreciation",
min_score: 0.8,
},
// 4. Solve commute constraints
{
type: "hybrid_commute",
schedules: [
/*...*/
],
},
],
})
The system can:
- Order operations efficiently (cheap filters first)
- Cache intermediate results
- Process expensive operations progressively
- Track data versions and model versions
- All through the same simple API!
Behind the Scenes
How does this work? The system:
- Analyzes the filter chain
- Identifies expensive operations
- Plans the execution order
- Manages compute resources
- Handles caching and invalidation
But clients don't need to know about any of this! They just:
- Specify what they want through filters
- Get back pile labels
- Track progress for long-running operations
Why This Matters
This approach means you can:
-
Add Complex Features Easily
- Embed ML models
- Add constraint solvers
- Call external APIs
- All without changing the API!
-
Control Costs
- Run expensive operations only on filtered sets
- Cache results effectively
- Scale compute based on demand
-
Improve User Experience
- Show progressive results
- Maintain consistency
- Handle expensive operations gracefully
The pile abstraction makes it practical to add sophisticated features that would be prohibitively expensive or complex otherwise. Want to use an LLM to analyze properties? Run complex simulations? Add a recommendation engine? They're all just filters that create new piles!
Next, we'll look at some extra superpowers that come from maintaining total ordering in our piles. It turns out that keeping results in a consistent order enables some very useful features for building responsive UIs!
Hidden Superpowers: Total Ordering and Group By
Remember how we mentioned that piles maintain a total ordering of results? This seemingly simple property enables some powerful features for building responsive UIs. Let's explore this using a fashion retail example.
The Total Ordering Guarantee
Every pile maintains two key properties:
- Results appear in a consistent order
- Each result has a stable position
// These will return the same items in the same order
const page1 = await searchClient.filter({
base: "pile_A",
filters: [],
range: { start: 0, length: 20 },
})
const page2 = await searchClient.filter({
base: "pile_A",
filters: [],
range: { start: 20, length: 20 },
})
This makes pagination reliable, but it enables something much more interesting: efficient grouping.
Grouping: The Hidden Superpower
Imagine you're browsing dresses and want to see them organized by:
- Price ranges
- Brands
- Colors
- Sizes
- etc.
Here's the key insight: if results are in a stable order, we can cache group boundaries!
// Get group information for a pile
const groups = await searchClient.analyze({
base: "dresses_pile",
group_by: "price_range",
ranges: [
{ max: 50, label: "Under $50" },
{ min: 50, max: 100, label: "$50-$100" },
{ min: 100, max: 200, label: "$100-$200" },
{ min: 200, label: "$200+" }
]
})
// Returns:
{
pile: "dresses_pile",
groups: [
{
label: "Under $50",
count: 245,
start: 0,
end: 244
},
{
label: "$50-$100",
count: 532,
start: 245,
end: 776
},
// ...
]
}
Now the client can load any group independently:
// Load just the $50-$100 dresses
const midRange = await searchClient.filter({
base: "dresses_pile",
filters: [],
range: { start: 245, length: 532 },
})
Building Rich UIs
This enables smooth interactions:
// Initial load shows counts
"Dresses by Price:
Under $50 (245)
$50-$100 (532)
$100-$200 (367)
$200+ (156)"
// User expands "$50-$100"
// Only load that section!
await searchClient.filter({
base: "dresses_pile",
filters: [],
range: { start: 245, length: 20 } // First page of group
})
Multiple Group Bys
It gets better! We can analyze multiple groupings:
const analysis = await searchClient.analyze({
base: "dresses_pile",
group_by: [
{
field: "price_range",
ranges: [
{ max: 50, label: "Under $50" },
{ min: 50, max: 100, label: "$50-$100" },
{ min: 100, max: 200, label: "$100-$200" },
{ min: 200, label: "$200+" }
]
},
{
field: "brand",
type: "terms" // Use distinct values
},
{
field: "color",
type: "terms",
max_groups: 10 // Top 10 colors
}
]
})
// Returns:
{
pile: "dresses_pile",
groups: {
price_range: [
{ label: "Under $50", count: 245, start: 0, end: 244 },
// ...
],
brand: [
{ label: "Zara", count: 156, start: 0, end: 155 },
{ label: "H&M", count: 143, start: 156, end: 298 },
// ...
],
color: [
{ label: "Black", count: 245, start: 0, end: 244 },
// ...
]
}
}
The Magic: Independent Scrolling
Because we know the position ranges for each group, we can:
- Load different groups independently
- Implement infinite scroll within groups
- Keep track of scroll position per group
// User interface showing multiple groups
"BY PRICE:
Under $50 ▼
[Scrollable list of 245 items]
$50-$100 ▼
[Scrollable list of 532 items]
$100-$200 ▶ (collapsed)
$200+ ▶ (collapsed)
BY BRAND:
Zara ▼
[Scrollable list of 156 items]
H&M ▶ (collapsed)
..."
Each expanded section can load and scroll independently!
Dynamic Updates
What happens when data changes? The system is smart about it:
// Original analysis
const analysis1 = await searchClient.analyze({
base: "latest",
group_by: [
/*...*/
],
})
// Returns groups for catalog_20240201_1415
// New products added...
// Get updated analysis
const analysis2 = await searchClient.analyze({
base: "latest",
group_by: [
/*...*/
],
})
// Returns groups for catalog_20240201_1416
// Client can decide whether to update
Why This Matters
This approach enables:
-
Efficient UIs
- Load only visible groups
- Smooth infinite scroll
- Independent group navigation
-
Rich Categorization
- Multiple grouping dimensions
- Nested groups
- Dynamic updates
-
Consistent Experience
- Stable ordering
- Predictable navigation
- Clear update boundaries
The key insight is that by maintaining total ordering and caching group boundaries, we can build rich, responsive interfaces without loading everything at once. Combined with our pile abstraction, this gives us a powerful toolkit for building sophisticated search and browse experiences.
Real World Example: Fashion Browse
Here's how a real fashion site might use this:
// Initial category page load
const dresses = await searchClient.filter({
base: "latest",
filters: [{ type: "category", value: "dresses" }],
})
// Get group analysis
const analysis = await searchClient.analyze({
base: dresses.pile,
group_by: [
{
field: "price_range",
ranges: [
/*...*/
],
},
{ field: "brand", type: "terms" },
{ field: "color", type: "terms" },
{ field: "size", type: "terms" },
{
field: "style",
type: "ml_categorization",
model: "fashion_classifier",
},
],
})
// User expands "Casual Dresses $50-$100"
const casualMidRange = await searchClient.filter({
base: dresses.pile,
range: {
start:
analysis.groups.style.casual.start +
analysis.groups.price_range.mid.start,
length: 20,
},
})
The UI stays responsive because:
- Group counts load immediately
- Sections load independently
- Scroll position is maintained per group
- Updates are clear and predictable
All of this from one simple property: maintaining total ordering in our piles!
The Bigger Picture: Architecture in an AI-First World
The pile abstraction might seem like a small change - just caching some intermediate results and keeping track of their order. But this slight tweak to how we organize data has profound implications for how we build and evolve systems. Let me illustrate this with a real conversation I've seen play out multiple times:
PM: "Hey! We noticed shoppers often want to explore outfit combinations when they find something they like. We could show them:
- Complete outfits that match their piece
- Let them filter by price, brand, size
- Show why items work together
- Let them swap pieces in and out
- Save outfit combinations..."
Engineer: "Hmm. We could use a rules engine to generate valid outfits from our catalog..."
PM: "Perfect! Let's do that!"
Engineer: "Well... but then we'd need to:
- Build a separate outfit service
- Rearchitect the product pages to handle outfit results
- Modify the search backend to filter outfit combinations
- Change how inventory/availability works
- Update the shopping cart...
Maybe we could just show a carousel of 5 pre-selected outfits instead?"
PM: "Yeah okay, let's do that for now..."
The tragedy isn't that we CAN'T build the richer feature - it's that we often don't even try! We optimize for engineering constraints instead of user needs.
What's Really Going On Here?
Why does an engineer jump to building a separate service?
ENGINEERING INSTINCTS:
1. Complex rules = Separate service
- Can't integrate with main search
- Too many moving parts to change
- Let's keep it simple and isolated
2. Separate service = Enrichment happens AFTER filtering
- Score what we show
- Cache for performance
3. Avoid expensive operations
- "We can't run this on every combination"
- "Let's pre-compute what we can"
- "Keep the filters basic"
But with our pile architecture, this entire conversation changes:
// The outfit feature becomes just another filter:
const results = await searchClient.filter({
base: "latest",
filters: [
{ type: "category", value: "dresses" },
{ type: "price_range", max: 100 },
{
type: "outfit_completion",
seed_item: "dress_123",
rules: {
style: "casual_summer",
max_total_price: 200,
preferred_brands: ["zara", "hm"],
},
},
],
})
// The system handles:
// 1. Running expensive rules only on pre-filtered sets
// 2. Caching outfit combinations
// 3. Streaming results as they're found
// 4. Keeping track of what version of style rules were used
The AI Connection
When I started designing this system, I was focused on solving product problems:
- Make filtering fast and consistent
- Handle complex data relationships
- Enable rich search experiences
But as I worked with AI tools to implement pieces of the system, I noticed something interesting: the way we divided the problem naturally created perfect-sized tasks for AI assistance:
SYSTEM COMPONENTS:
1. Simple Filters
- SQL generation
- Basic validation
- Easy for AI to write
2. Complex Filters
- Rules engines
- ML models
- AI can help design and implement
3. Analysis & Grouping
- Statistics
- Categorization
- AI excels at these tasks
4. Frontend Components
- UI patterns
- State management
- AI understands these well
The pile abstraction creates natural boundaries that align well with how AI tools can help us build systems:
- Each filter is a self-contained unit of logic
- The API contract is simple and consistent
- Components have clear inputs and outputs
- Complexity is managed through composition
This isn't an accident - it's a hint about how we should be thinking about architecture in 2024. By structuring our systems around clear abstractions with well-defined boundaries, we make it easier to:
- Experiment with new features
- Leverage AI assistance effectively
- Evolve systems incrementally
- Focus on user value
Designing for AI's Growing Problem Size
We're entering an era where chunks of code around 200-300 lines in complexity are effectively "free" - AI can generate these reliably with near-zero errors. Beyond that size, reliability drops off sharply. This isn't about fitting in a context window (which is much larger); it's about the current practical limits of AI to produce correct, working code in one go. This fundamentally changes how we should think about structuring systems.
Consider how this played out in our pile system:
// Each filter is a self-contained "chunk" of logic
filters: [
// "Free" - Simple SQL generation
{ type: "price_range", max: 100 },
// "Free" - Basic validation and transformation
{ type: "size_normalization", region: "EU" },
// "Free" - Complex but contained logic
{
type: "outfit_completion",
seed_item: "dress_123",
rules: {
style: "casual_summer",
max_total_price: 200,
},
},
// "Free" - Entire constraint satisfaction problems
{
type: "prolog_rules",
rules: `
good_outfit(Items) :-
total_price(Items, Price),
Price < 200,
style_coherent(Items),
seasonal_match(Items, summer).
`,
},
]
Each filter represents a contained problem that fits within an AI's capacity to understand and generate code for. As AI models grow more capable, the size of these "free" chunks will expand:
The Implications
This suggests a new way to evaluate architecture:
-
Chunk Size Alignment
- Does your system naturally break problems into AI-sized chunks?
- Are your abstractions sized to match what AI can handle?
- Will your system benefit from expanding chunk sizes?
-
Composition Over Integration
- Instead of building complex services that talk to each other
- Build systems that compose chunks of logic
- Let AI handle the complexity within each chunk
-
Future Proofing
- What happens when chunk sizes double? Triple?
- Which parts of your system will become "free"?
- How can you structure things to benefit from this?
A Lens for System Design
The pile abstraction worked well because it happened to align with these principles:
- Each filter is a self-contained chunk of logic
- The API composes chunks naturally
- More complex filters become possible as AI capabilities grow
But the bigger insight is this: we should explicitly design systems around the maximum problem size that AI can handle. This size is growing rapidly, and systems that are structured to take advantage of this will have a significant advantage.
Questions to ask when designing systems:
- What size problems can current AI models solve reliably?
- How are you breaking down your system's complexity?
- What becomes possible when chunk sizes grow?
- How can you structure things to benefit from future AI capabilities?
The pile system wasn't designed with this in mind - it just happened to fit this pattern. Imagine what we could build if we explicitly designed systems around the growing problem-solving capabilities of AI.
[Want to dive deeper into AI-first architecture? Check out my follow-up post: "Designing Systems for the AI Era"]