Engineering

Optimizing OpenSearch: How We Achieved 15x Faster Queries

November 24, 2025

Optimizing OpenSearch: How We Achieved 15x Faster Queries

This article is authored by Hitanshu Bachawat

For most engineering teams, the safest bet is to follow “best practices.” They’re comforting. They feel correct. They promise stability.

But every once in a while, you run into a problem where the best practices are the reason things are slow.

That’s exactly what happened to us.

We were building a system to analyze customer feedback at massive scale, where each feedback comment was to be associated with multiple themes – sentiment pairs, for example, “delivery: negative,” “pricing: positive,” or “customer support: neutral.” This allowed businesses to filter comments by sentiment, drill into specific aspects, and make better decisions. Given this structure, nested objects felt like the most natural and conventional modelling choice.

Except… OpenSearch didn’t agree.

The schema looked clean. The queries looked correct. And the performance looked like it was powered by dial-up internet.

This is the story of how we reduced 150 ms queries to 10 ms, from 4 GB indexes to 200 MB, and from “why is this dashboard so slow?” to “is that… instant?”

And yes, it happened because we broke the rules.


The Model That Looked Perfect (Until We Tested It)

Like any reasonable engineer, we started with the recommended nested object schema:

{
  "comment": "Great product but poor service",
  "aspects": [
    {"aspect": "product quality", "sentiment": "positive"},
    {"aspect": "customer service", "sentiment": "negative"}
  ]
}

It looked exactly like the data.
It mapped perfectly to our mental model.
And it made querying feel intuitive.

Then reality arrived.


The Hidden Explosion Inside Nested Fields

To OpenSearch, this single document is secretly many documents:

  • 1 parent
  • multiple nested children

Now multiply that:

  • 10M feedback entries × 5 aspects = 50M+ internal documents
  • ~4 GB of storage
  • every query is a parent–child join

And joins are expensive.

A simple query such as “find comments where customer service was negative” results in:

  1. Parse the nested query structure
  2. Search across the nested index
  3. Join the results back to the parent document
  4. Apply filters and aggregations

Queries that should take 10–20 ms were taking 100–200 ms. Dashboards took seconds.

We knew we had to rethink the whole thing.


The Moment We Realized We Didn’t Need Nesting At All

Realizing the hidden costs of nesting, we asked ourselves:

Do we really need all this structure?

The answer was: no.

Our queries always involved aspect + sentiment together. We never searched/queried aspects or sentiments independently.

That insight changed everything.


Phase 1 : Shrinking the Data Using IDs

Each document repeated strings like “customer service,” “positive,” “negative,” etc. Millions of times.

So we added mapping tables:

ASPECTS = {
0: "overall experience",
1: "customer service",
2: "product quality",
3: "pricing"
}

SENTIMENTS = {
-1: "negative",
0: "neutral",
1: "positive"
}

Then every feedback entry stored:

{"aspectId": 1, "sentiment": -1}

This single change dropped values from ~35 bytes → ~8 bytes.

This reduced storage dramatically and made queries faster since IDs are easier to index and compare than strings.

But the real breakthrough was still ahead.


Phase 2 : Merging Aspect + Sentiment Into a Single Token

Once we accepted that aspect + sentiment always appear together, we realized we don’t need two fields.

We need one term.

So we encoded them:

Before (nested, structured, slow):

{
 "aspects": [
   {"aspectId": 1, "sentiment": -1},
   {"aspectId": 2, "sentiment": 1}
 ]
}

After (flat, encoded, fast):

{
 "aspects": ["1:-1", "2:1"]
}

This further dropped values from ~8 bytes → ~4 bytes.

Every query became:

{"term": {"aspects": "1:-1"}}

No joins.
No nested scanning.
Just a direct inverted index lookup.

Developers who saw it raised their eyebrows.
Benchmarks, on the other hand, gave us a standing ovation.


Why This Works (The Inverted Index Superpower)

OpenSearch is built on the inverted index: a map of terms → documents. Learn more here.

Nested queries fight against this natural strength. They require extra layers of processing before the engine can use the inverted index.

However, a single encoded string like "1:-1" is a perfect citizen of the inverted index.

OpenSearch’s inverted index behaves like a pre-computed join table:

OpenSearch’s Inverted Index

Counts are pre-computed at index time. No calculation needed during queries!

The query path becomes:

Direct lookup path: No joins, no nested scanning

This consistently gave us response times around 10 ms.

It felt like cheating but it was just aligning with how the engine is built.


The Results (The Numbers We Still Show Off)

Query Performance

Before vs After Query Latency

Cluster Resource Impact

Resource Drop After Schema Change

Real Query Comparison

Nested version (~150 ms):

{
 "query": {
   "nested": {
     "path": "aspects",
     "query": {
       "bool": {
         "must": [
           {"term": {"aspects.aspectId": 1}},
           {"term": {"aspects.sentiment": -1}}
         ]
       }
     }
   }
 }
}

Encoded version (~10 ms):

{
 "query": {
   "term": { "aspects": "1:-1" }
 }
}

Sometimes dirty hacks are just well aligned optimizations.


Where This Approach Works (And Where It Doesn’t)

This design shines when:

  • aspect + sentiment are always queried together
  • you can maintain a mapping table
  • you prioritize performance over readability

It’s not suitable when:

  • you need aspect or sentiment queried independently
  • sentiment is used in numeric calculations
  • schema changes frequently

In short:
It’s perfect for stable, predictable query patterns.


The Bigger Lesson (The One We Actually Remember)

The real win wasn’t just a faster index. It was a shift in how we think about system design.

We learned to:

1. Understand the engine you’re using.
We spent weeks studying Lucene’s internals and OpenSearch’s architecture before designing our schema. This deep understanding revealed that we could leverage inverted index directly rather than fighting against it with nested documents. Nested fields look elegant, but elegance doesn’t equal speed.

2. Design for your actual queries, not theoretical ones.

Instead of asking “How should documents be structured?”, we asked:

  • What queries will we run millions of times?
  • What does the engine do efficiently?
  • How can we align our data with the engine’s strengths?

Our schema changed when our query patterns dictated it.

3. Question “best practices.”
Best practices are averages. Your use case might be an outlier. We discovered:

  • Nested objects aren’t always best for related data
  • De-normalization can be faster than normalization
  • Encoding can be better than readable storage
  • “Wrong” can be right

4. Test early, test realistically.
Our biggest insight came from a simple stress test.

Engineering becomes much easier when you stop designing for aesthetics and start designing for runtime reality.


Conclusion

By flattening nested aspects into a single encoded token, we unlocked:

  • 10–15× faster queries
  • 95% storage savings
  • 75% lower memory usage
  • 75% lower monthly cost

All because we chose a schema that made OpenSearch happy, not humans.

{"aspects": ["1:-1", "2:1"]}

It may look unconventional, but the performance speaks for itself.

Sometimes, the best engineering decision is the one that breaks all the rules, deliberately, thoughtfully, and with deep understanding of why those rules exist and when they don’t apply.

Leave a comment

Leave a Reply

Discover more from CleverTap Tech Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading