Skip to content

Blog

Three bugs behind OpenSearch's 7x count gap

When I announced Luxir earlier this week, the benchmark pages that came with it got more attention than I expected, and one result on them bothered me. On the Elasticsearch vs OpenSearch page, two engines on nearly the same Lucene version were far apart on the same queries: at 32 connections, Elasticsearch 9.5.4 counted the matches for an AND or OR of two frequent terms about seven times as fast as OpenSearch 3.8.0, and answered three-character prefix queries for the top 10 about ten times as fast. The count gap was already 6.5x with a single connection, so it wasn’t a scaling problem. Something was making every query do more work.

A gap that size between two Lucene engines usually means one of them has tripped over something specific, and I didn’t want my pages painting OpenSearch as much slower than Elasticsearch if the cause was a passing regression in one release, fixed a month later and forgotten. So I went looking, expecting to find something small and transient.

What I found was neither. The cause is three bugs in OpenSearch’s query cancellation: two in the wrapper it puts around Lucene’s index reader so that a cancelled search can stop in the middle of a query, and an older bottleneck in the check itself that the first two had been hiding. The wrapper arrived in OpenSearch 3.7.0, and it hides the bulk postings operations that Lucene has been moving its hot paths onto since 10.2. From that release on, every Lucene optimization built on those operations showed up in Elasticsearch’s numbers and passed OpenSearch by, and each new Lucene widens the gap a little more: the changelog for the next release already lists more of them. The fix is PR 23117 against OpenSearch (issue 23107). With it, OpenSearch counts within a few percent of Elasticsearch, and most of the prefix gap closes too. Here is the whole story in one chart: each fix, before and after, on the queries it targeted, and then all three together against the 3.8.0 release.

Three fixes to OpenSearch’s query-cancellation wrapper, queries per second at 32 connections before and after each fix. Fix 1, forwarding Lucene’s bulk operations: counts of an AND or OR of two frequent terms rise from about 900 to about 6,200 queries per second, 6.8 and 7.5 times. Fix 2, keeping postings reuse: prefix, wildcard, and regex counts rise 3.5, 1.8, and 2.7 times. Fix 3, no allocation in the cancellation check: prefix counts rise a further 1.22 times and wildcard 1.07 times; other cells 1.03 to 1.05 times. All three together, 3.8.0 release against the patched build: the two boolean counts rise 7.6 and 8.2 times, prefix top 10 6.3 times, wildcard top 10 2.3 times, regex top 10 2.9 times.

Cancellation stays enabled in every measurement here; the fixes change what a check costs, not whether it happens, and the pull request’s tests exercise cancellation inside the restored bulk paths. The rest of this post is what each bug was, how I found it, and what’s left.

OpenSearch has a search.low_level_cancellation setting, on by default, that lets a cancelled or timed-out search stop while it is still inside Lucene rather than at the next phase boundary. It works by wrapping the index reader in an “exitable” reader, in the style of Lucene’s own ExitableDirectoryReader, whose terms enumeration checks a cancellation flag every so often, and by wrapping the bulk scorer so that it checks between windows of documents.

In a change merged in May 2026 and shipped in 3.7.0, OpenSearch extended this to every PostingsEnum as well, so that field-data loading, which walks postings outside any bulk scorer, could also be cancelled. That is a reasonable thing to want. The wrapper it added is where the first two bugs live, which means OpenSearch 3.7.0 and 3.8.0 are affected at their default settings; the third is older, and only became visible once the first two were fixed.

The other half of the story is Lucene. Lucene 10.2 added DocIdSetIterator.intoBitSet, a bulk operation that lets a postings list set a whole window of bits at once instead of being asked for one document at a time, and by 10.5 dense boolean scoring and most constant-score paths run through intoBitSet, docIDRunEnd, and nextPostings. These are the methods that make the current Lucene fast on exactly the queries where OpenSearch was slow.

OpenSearch’s postings wrapper extends Lucene’s FilterPostingsEnum, which forwards docID, freq, nextDoc, advance, positions, and cost to the wrapped iterator, and nothing else. intoBitSet, docIDRunEnd, and nextPostings fall through to the base-class defaults, and the default intoBitSet is a loop calling nextDoc() and setting one bit per call. So every wrapped postings list silently lost the codec’s bulk path wherever Lucene uses it, exhaustive counts and dense boolean scoring most of all, and paid the wrapper’s cancellation-sampling counter on each of those nextDoc() calls as well.

The fix forwards the three methods to the codec, with a cancellation check between bitset windows of at most a million document IDs, matching the bound OpenSearch’s bulk scorer already uses. On its own, against an unmodified build of the same source, at 32 connections:

Query Before After
AND of two frequent terms, count 911 6,177 6.8x
OR of two frequent terms, count 838 6,324 7.6x
OR, exhaustive top 10 249 420 1.7x

Queries per second; a 10 million document Wikipedia index in one segment, query caches off, fixed CPU frequency.

That was the count gap. Large gaps remained on the multi-term queries, though: wildcard counts gained 44%, but prefix and regex did not move at all, and they had their own 4x to 10x gap on the benchmark page.

A prefix, wildcard, or regex query expands to many terms, and Lucene visits their postings one term after another. To avoid allocating a fresh decoder for each one, the terms enumeration passes the previous PostingsEnum back to the codec as a reuse candidate. The wrapper’s postings(reuse, flags) called the codec with null instead, every time, because the wrapper type didn’t match the codec’s type and nobody unwrapped it. On a query that touches thousands of terms, that is thousands of buffer and decoder allocations per query, and a profile of prefix queries showed 31% of CPU samples constructing codec postings objects.

The fix unwraps OpenSearch’s own wrapper, hands the native iterator to the codec, which still decides whether it can reuse it, and puts a fresh wrapper around whatever comes back. With bug 1 already fixed, at 32 connections:

Query Before After
Three-character prefix, count 529 1,836 3.5x
Wildcard, count 1,339 2,353 1.8x
Regex, count 70 188 2.7x

That closed most of the prefix gap, but not as much as I expected: prefix counts sat at about 1,840 queries per second against Elasticsearch’s roughly 2,390 on the same hardware and protocol. So I profiled again. About 20% of CPU samples were now inside the cancellation check, and most of those were in a HashSet iterator.

OpenSearch keeps the per-search cancellation callbacks in a HashSet<Runnable> and iterates it on every check. This one is older than the wrapper; it was invisible until the first two fixes removed the work around it. There are one or two callbacks, but the check runs every 16 terms during enumeration, every 8,192 postings, and between every bulk-scoring window, on every one of 28 threads, and each call allocated a fresh iterator. Once the first two bugs stopped hiding it, that allocation was a visible fraction of a prefix query.

The fix is an ArrayList with an indexed loop; duplicate rejection, removal, and cleanup behave as before, and no check was removed or made less frequent. With bugs 1 and 2 already fixed, at 32 connections:

Query Before After
Three-character prefix, count 1,836 2,237 1.22x
Wildcard, count 2,367 2,531 1.07x
Regex, count 188 194 1.03x
AND of two frequent terms, count 6,124 6,404 1.05x

Repeating the prefix and wildcard cells in the opposite order with fresh JVMs gave 1.25x and 1.14x, so those two gains are solid; the smaller ones did not get an independent recheck. The iterator hotspot is gone from the profile, and the cancellation check’s share of samples fell from about 20% to about 6%.

Measured the way the benchmark page measures, the patched build’s counts of two frequent terms are within about 3% of Elasticsearch’s at 32 connections. On prefix and wildcard top-10 queries it matches Elasticsearch within 5% with a single connection and trails by about 1.6x at 32; that remaining cost grows with load, is specific to OpenSearch’s multi-term path, and is not something I have identified. OpenSearch keeps its lead on the query family where it was already ahead.

The bugs are in 3.7.0 and 3.8.0 with default settings, and they cost the most on exact counts of frequent-term boolean queries and on prefix, wildcard, and regex queries. The pull request is under review as I write this; if it is merged, the fix will ship in a later release.

Everything here was measured on the same machine and index as the benchmark page, with SearchBench at the commit the page’s results record. The incremental tables come from the pull request’s A/B campaigns, which compare builds of the same OpenSearch source revision that differ only in the server jar, with two repetitions per cell. The chart’s last panel and the paragraph above come from a separate run with the page’s protocol, three 10-second repetitions per cell against the 3.8.0 release. That run compares complete builds rather than isolating the patch:

Build OpenSearch Lucene JDK
Stock 3.8.0 official distribution 10.5.0 bundled Temurin 25.0.4
Patched 3.10.0 snapshot with the pull request’s code 10.5.1 OpenJDK 25.0.4.1
Elasticsearch 9.5.4 official distribution 10.5.1 bundled OpenJDK 26.0.2

Query caches were off throughout so that repeated queries measure execution; the exact requests, and what was done to make both engines fast, are described on the full comparison.

The Elasticsearch vs OpenSearch page keeps the 3.8.0 numbers, because that is what the release does; it now carries a note pointing here.

Introducing Luxir

I’ve been working on a new search engine. It’s called Luxir, it’s open source under the Apache License 2.0, and you can download it and try it today. It’s a hybrid search engine written in modern C++ with full-text search, vector search, faceting, and analytics. You talk to it over gRPC or HTTP/JSON.

The name is lux (light) plus IR (information retrieval). I pronounce it like “Luxeer”, but you can pronounce it however you like.

I wrote the first version of Solr in 2004 at CNET. Servers were smaller back then - less RAM, fewer CPU cores, and spinning disks. Java was a decent choice for that machine, and Lucene was (and still is) a great library to build on.

The hardware kept changing, and the JVM wasn’t keeping up. Native code offered the following benefits:

  • Removal of GC pauses, which continued to be a major issue
  • Smaller total RAM needs (garbage collection needs extra space)
  • Direct access to SIMD instructions
  • Better resource sharing with other processes on a host (you can give memory back to the OS)
  • Predictable latency and code generation (JIT can compile differently based on what traffic it sees first)
  • No warmup - much faster time from process launch to first search

In 2014, I created the Heliosearch fork of Solr to try to address some of the issues. I moved filters and the field-cache off-heap, and added native code (C++) faceting that was twice as fast as the Java version. The new faceting API from Heliosearch went back into Solr as the JSON Facet API. The off-heap and native code work did not.

Around 2015, I began thinking about how I would go about writing a successor to Lucene and Solr. I also started thinking about what cloud computing changes for infrastructure software:

  • Compute was no longer a sunk cost, but a recurring cost that would be much more visible to developers. For common shared pieces of infrastructure like open source databases or search engines, it made sense to put in the harder engineering work to get to faster and more efficient solutions.
  • Pricing changed: bigger machines no longer carried a price premium (for example within an instance family in AWS, doubling the CPUs and RAM doubles the price). Scaling up can yield better efficiency by avoiding networking hops, host coordination, and other per-node costs.

In 2020, I retired from my “working” career, and among other things, started to work on Luxir, designed from the start to get the most out of every core and every gigabyte of one large node. Starting with a clean slate and twenty years of hindsight allowed for better decisions and fixing things I had wanted to fix for a long time (things like automatic parallel indexing and an easier multi-select faceting API). Check out the full features list on the website for more.

The whole point of building a successor to Lucene/Solr from the ground up is efficiency and speed.

In the first full-text benchmarks comparing Luxir with Elasticsearch and OpenSearch, Luxir’s throughput is 1.6 times Elasticsearch’s and 2.0 times OpenSearch’s (geometric mean across 60 query types).

Geometric mean queries per second across 60 full-text query types. 1 connection: Elasticsearch 121, OpenSearch 99, Luxir 194. 8 connections: Elasticsearch 912, OpenSearch 720, Luxir 1,492. 32 connections: Elasticsearch 1,730, OpenSearch 1,236, Luxir 3,703.

The benchmark harness, the engine configurations, and the raw results are all public, so anyone can re-run them and check my work. More will follow: faceting, indexing throughput, memory use, and vector search.

Luxir is pre-release software. The APIs and the on-disk index format will change without notice before 1.0, so expect to reindex when you upgrade (reindexing is fast). It is a single-node engine today. Replication and distributed search are not implemented yet, and neither is authentication or TLS, so run it behind your own security boundary. Binary releases are for Linux on x86-64.

Download a single binary and start it:

Terminal window
./luxir
curl http://localhost:9400/health

The quickstart gets you from there to your first requests. The source is on GitHub.

I’d love to hear how it works for you. Questions and ideas go in GitHub Discussions, and bugs in Issues.

Solr 7.1 Features

Here’s an overview of some of the new features in Solr 7.1:

 

There is now a JSON mapping to Solr QParsers. Currently, one must use the JSON Request API to use this JSON syntax, but SOLR-11295 will provide a more general purpose entry point to JSON syntax in the future.

The general form of a query type in existing local params syntax is:

{!query_type param1=val1 param2=val2}query_value

OR, specifying the main query value using the v parameter:

{!query_type param1=val1 param2=val2 v=query_value}

In the new JSON syntax, the corresponding mappings would be

{query_type:{param1:val1, param2:val2, v:query_value}}

OR using query (which is a synonym for v when mapping to local params)

{query_type:{param1:val1, param2:val2, query:query_value}}

  Here’s an example of a pseudo-join query in local-params syntax that queries for book_review1 and follows the “book_id” field of the result(s) to the matching “id” field:

curl http://localhost:8983/solr/techproducts/query -d 'q={!join from:book_id to:id}id:book_review1'

In JSON syntax (indented for better readability), this would be

curl http://localhost:8983/solr/techproducts/query -d '
{
query:{
join:{
from : book_id,
to : id,
query : "id:book_review1"
}
}
}'

Note that the query parameter is specified in lucene syntax above, but it can be any query in local-params syntax or JSON syntax. Example:

curl http://localhost:8983/solr/techproducts/query -d '
{
query:{
join:{
from : book_id,
to : id,
query : { field : {f:"id", v:"book_review1"} } // invokes the "field" QParser
}
}
}'

 

Integral and date type support for min/max

Section titled “Integral and date type support for min/max”

Min and max aggregations in the JSON Facet API have been updated to include support for int, long, and date types. Previously, min and max of all of these field types returned double results.

 

Solr’s auto-scaling framework contains a number of new features and improvements, including

  • Ability to move replicas when nodes are added or removed to the cluster.
  • Solr’s autoAddReplicas feature now uses the autoscaling framework and works for all filesystems.
  • New API to control triggers and listeners (set-trigger, remove-trigger, suspend-trigger, set-listener, etc.)

As of this writing, the Solr 7.1 reference guide is not published yet. However you can check out the auto-scaling section of the very latest version of the unreleased reference guide for the master branch.

 

Here’s an example field using the new Geo3D spatialContextFactory that supports polygons:

<fieldType name="srptgeom_geo3d" class="solr.RptWithGeometrySpatialField"
spatialContextFactory="Geo3D" planetModel="wgs84"/>

Note: “wgs84” is an ellipsoid coordinate system for the Earth used by GPS. “sphere” can also be used for a spherical model that has faster but less accurate distance calculations.

 

Expanded support for statistical functions including various distributions, rank correlations, distances and more:

harmonicFit, polyfit, polyfitDerivative, betaDistribution, gammaDistribution, zipFDistribution, logNormalDistribution, weibullDistribution, monteCarlo, expMovingAverage, binomialCoefficient, factorial, movingMedian, primes, sumDifference, meanDifference, ebeAdd, ebeSubtract, ebeDivide, ebeMultiply, dotProduct, cosineSimilarity, Canberra, Chebyshev, Earth Movers and Manhattan Distance, Kendall’s Tau-b rank and Spearmans rank, discrete counting and probability, cumulativeProbability, normalDistribution, uniformDistribution, kolmogorovSmirnov, addAll

Solr 6.6 Features

Here’s an overview of some of the new features in Solr 6.6, released on June 6, 2017.

Download Solr 6.6 to try these features out and give us feedback! You can also check out upcoming features of the next Solr release.

This is likely to be the last Solr 6.x release before Solr 7.

  The release notes from the Apache Solr Wiki:

  • Payload support with payload() value source and {!payload_score} and {!payload_check} query parsers
  • Solr support for SimpleTextCodec, via in solrconfig.xml (per-field specification in the schema is not possible)
  • Multi-field support to TermsComponent when requesting terms’ statistics
  • Support for PointFields in Grouping, CollapseQParser, and ExpandComponent.
  • UPLOAD command (Config Set API) for uploading zipped configsets
  • MOVEREPLICA command (Collections API) for moving a replica across nodes
  • LISTALIASES command (Collections API) to return a list of all collection aliases
  • STATUS command (Core Admin API) to emit collection details of each core
  • Basic authentication can be enabled/disabled using bin/solr|bin/solr.cmd
  • ls command to ZkCLI for listing only sub-directories
  • Variance and Standard Deviation aggregators for the JSON Facet API
  • JSON Faceting now supports a query time ‘join’ domain change option
  • CartesianProductStream, which turns a single tuple with a multi-valued field into N tuples, one for each value in the multi-valued field
  • stats and search Streaming Expressions should now work in non-SolrCloud mode
  • analyze Stream Evaluator to support streaming NLP
  • New Stream Evaluators: Basic math, Date/time, UUID, Correlation, regress, predict, covariance, convolution, normalize
  • New Streaming Expressions: shuffle, echo, eval, timeseries, let, get
  • Solr default/example uses WordDelimiterGraphFilterFactory and SynonymGraphFilterFactory
  • New DataImportHandler ‘atom’ example, replacing broken ‘rss’ example
  • Redone DataImportHandler ‘tika’ example, removing all unused and irrelevant definitions
  • Expose cache statistics using metrics API
  • Improvements to metric reporters and API: support for “regex” parameter in /admin/metrics, “enabled” flag in reporter configurations, correct handling of “serviceUrl” in SolrJmxReporter, better handling of service clients for JMX, Ganglia and Graphite reporters
  • Deprecated LatLonType, GeoHashField, SpatialPointVectorFieldType, and SpatialTermQueryPrefixTreeFieldType. Instead, switch to LatLonPointSpatialField or SpatialRecursivePrefixTreeFieldType or RptWithGeometrySpatialField
  • Deprecated PostingsSolrHighlighter. Use UnifiedSolrHighlighter instead.
  • CloudSolrClient can now be initialized using the base URL of a Solr instance instead of ZooKeeper hosts
  • SolrJ: Added SolrParams.toLocalParamsString() and ClientUtils.encodeLocalParamVal
  • New AtomicUpdateProcessor to convert normal update operations to atomic update operations
  • totalTermFreq support to TermsComponent
  • Hide keystore and truststore passwords from /admin/info/* outputs
  • Configurability for thread pool size to recoveryExecutor
  • Introducing sort=childfield(field) asc for searching by {!parent}
  • facet.heatmap is now significantly faster when the docset (base query) matches everything and there are no deleted docs. It is also faster when the docset matches a small fraction of the index or none
  • Reduced heap consumption for filter({!join … score=…})
  • JSON Facet API now uses hyper-log-log++ for determining the number of buckets when merging requests from a multi-shard distributed request
  • Better ZkStateWriter batching
  • Using cache for DistributedQueue in case of single-consumer

Solr 6.5 Features

Here’s an overview of some of the new features in Solr 6.5. Download Solr 6.5 to try these features out and give us feedback!

This feature list was adapted from the release notes on the wiki: https://wiki.apache.org/solr/ReleaseNote65

  • PointFields (fixed-width multi-dimensional numeric & binary types enabling fast range search) are now supported
  • In-place updates to numeric docValues fields (single valued, non-stored, non-indexed) supported using atomic update syntax
  • A new LatLonPointSpatialField that uses points or doc values for query
  • It is now possible to declare a field as “large” in order to bypass the document cache
  • New sow=false request param (split-on-whitespace) for edismax & standard query parsers enables query-time multi-term synonyms
  • XML QueryParser (defType=xmlparser) now supports span queries
  • hl.maxAnalyzedChars now have consistent default across highlighters
  • UnifiedSolrHighlighter and PostingsSolrHighlighter now support CustomSeparatorBreakIterator
  • Scoring formula is adjusted for the scoreNodes function
  • Calcite Planner now applies constant Reduction Rules to optimize plans
  • A new significantTerms Streaming Expression that is able to extract the significant terms in an index
  • StreamHandler is now able to use runtimeLib jars
  • Arithmetic operations are added to the SelectStream
  • Added modernized self-documenting /v2 API
  • The .system collection is now created on first request if it does not exist
  • Admin UI: Added shard deletion button
  • Metrics API now supports non-numeric metrics (version, disk type, component state, system properties…)
  • The disk free and aggregated disk free metrics are now reported
  • The DirectUpdateHandler2 now implements MetricsProducer and exposes stats via the metrics api and configured reporters.
  • BlockCache is faster due to less failures when caching a new block
  • MMapDirectoryFactory now supports “preload” option to ask mapped pages to be loaded into physical memory on init
  • Security: BasicAuthPlugin now supports standalone mode
  • Arbitrary java system properties can be passed to zkcli
  • SolrHttpClientBuilder can be configured via java system property
  • Javadocs and Changes.html are no longer included in the binary distribution, but are hosted online

For more detailed lists with pointers to JIRA issues, see the HTML version of CHANGES.txt

Facet Domains

The domain of a facet is the set of values (normally defined by a set of documents) that calculations will be done over. The root domain is the set of documents that match the base query and any filters.

For any facet command, one can use the domain keyword to change the facet domain before facet computation.

The existence of the blockParent parameter in the domain block will cause incoming child documents to be mapped to their parents. The value of the blockParent parameter is the parent filter that specifies the complete set of parent documents for this block join operation.

Nested Documents -> Faceting On Parents for an example.

The existence of the blockChildren parameter in the domain block will cause incoming parent documents to be mapped to their children. The value of the blockChildren parameter is the parent filter that specifies the complete set of parent documents for this block join operation.

Nested Documents -> Faceting On Children for an example.

The excludeTags parameter causes the domain to be re-calculated from the root all the way to the current point, but excluding any filters with the specified tags. This is normally used as part of multi-select faceting.

The filter parameter is used to specify a filter or list of filters to be intersected with the incoming domain before faceting. These filters are applied after other domain transformations such as blockParent, blockChildren, or excludeTags. The same exact syntax for filter is supported in the JSON Request API for the top level document list (or will be after SOLR-9733).

Example:

json.facet = {
categories : {
type : terms,
field : cat,
domain : { filter:"user:yonik" }
}
}

The “param” query type can be used to grab zero or more filters from a request parameter:

q=my query&
myfilt=type:review&
myfilt=rating:5&
json.facet = {
categories : {
type : terms,
field : cat,
domain : {
filter:[
"user:yonik",
{param : myfilt} ]
}
}
}

Solr 6.4 Features

Here’s an overview of some of the new features in Solr 6.4.

Download Solr 6.4 to try these features out and give us feedback! You can also check out upcoming features of the next Solr release.

Any JSON facet command (terms, range, query) can now filter the facet domain in a simpler manner, without resorting to nested query facets.

Example:

json.facet = {
categories : {
type : terms,
field : cat,
domain : { filter:"user:yonik" }
}
}

The filters are applied after other domain change operations and are particularly useful when faceting on child documents. The filter attribute can be a single query or a list of multiple queries to intersect.

Using a param to refer to a filter by query parameter was added shortly after.

 

Learning to Rank (LTR) plugin for reranking results with machine learning models.

See the Lucene/Solr Revolution presentation Learning to Rank in Solr as well as the Solr Ref Guide.

 

snapshotcli.sh command line tool to manage snapshots

Section titled “snapshotcli.sh command line tool to manage snapshots”

The full path of the script is ./solr/server/scripts/cloud-scripts/snapshotscli.sh

An example from the pull request:

// Start solr and initialize a sample collection
bin/solr start -c
bin/solr create_collection -c books
curl 'http://localhost:8983/solr/books/update?commit=true' -H 'Content-type:application/json' -d '
[ {"id" : "book1", "title" : "American Gods", "author" : "Neil Gaiman" } ]'
//Create and export a snapshot
./snapshotscli.sh --create snap-1 -c books -z localhost:9983
./snapshotscli.sh --list -c books -z localhost:9983
./snapshotscli.sh --describe snap-1 -c books -z localhost:9983
./snapshotscli.sh --export snap-1 -c books -z localhost:9983 -d /tmp
./snapshotscli.sh --delete snap-1 -c books -z localhost:9983
// Restore the backup and verify the doc count
curl 'http://localhost:8983/solr/admin/collections?action=restore&name=snap-1&location=/tmp&collection=books_restored'
curl 'http://localhost:8983/solr/books_restored/select?q=*:*'

 

When parsing filter queries (including fq parameters) the standard solr query parser will avoid using BooleanQuery for term disjunctions on string and numeric fields, and will use TermsQuery instead.

This has a number of positive effects:

  • Avoids Lucene’s dreaded static maxBooleanClauses issue that causes “too many boolean clauses” exceptions
  • The resulting query should be smaller to cache
  • The resulting query should have higher performance

For example, the following filter will now be faster, and will no longer throw a “too many boolean clauses” exception:

fq=id:(myid1 myid2 myid3 myid4 ... myid2000)

 

The having streaming expression provides functionality like the SQL HAVING clause, and will be used to implement that in the future. Aggregated buckets produced by rollup are filtered by a having clause.

The following streaming expression filters out buckets (authors) with less than 100,000 in sales across all of their books:

having(rollup(over=author,
sum(sales),
search(book_sales_collection,
q=*:*,
fl="author,sales",
sort="author asc")),
gte(sum(sales), 100000))

Also see the a streaming expressions documentation in the ref guide.

Nested Objects in Solr

Nested Documents (also called Nested Objects) provides the ability to “nest” some documents inside of other documents in a parent/child relationship.

One reason for using nested documents is to prevent false matches. For example, we may have a T-Shirt with 2 SKUs, a Large Red, and a Medium Blue.

Say we tried to model this as a single document:

{
product : "Awesome T-Shirt",
color : [ "Red", "Blue" ],
size : [ "L", "M" ]
}

Now if we search for color:RED AND size:M , it would incorrectly match our document! But if we represented the SKUs as two different documents, then there would be no incorrect match.

{
color : "Red",
size : "L",
}
{
color : "Blue",
size : "M",
}

Lucene has a flat object model and does not really support “nesting” of documents in the index. Lucene *does* support adding a list of documents atomically and contiguously (i.e. a virtual “block”), and this is the feature used by Solr to implement “nested objects”.

When you add a parent document with 3 children, these appear int the index contiguously as

child1, child2, child3, parent

There is no Lucene-level information that links parent and child, or distinguishes this parent/child block from the other documents in the index that come before or after. Successfully using parent/child relationships relies on more information being provided at query time.

All children of a parent document must be indexed together with the parent document. One cannot update any document (parent or child) individually. The entire block needs to be re-indexed of any changes need to be made.

There are no schema requirements except that the _root_ field must exist (but that is there by default in all our schemas). Any document can have nested child documents.

“Block Join” refers to the set of related query technologies to efficiently map from parents to children or vice versa at query time. The locality of children and parents can be used to both speed up query operations and lower memory requirements compared to other join methods.

NOTE: This example currently requires Solr 5.3 or later.

First, bring up Solr and create a collection (if you have not done so already):

$ bin/solr start # this starts solr
$ bin/solr create -c demo # this creates a document collection called "demo"

Let’s remove any leftover docs from other examples:

curl http://localhost:8983/solr/demo/update?commitWithin=3000 -d '{delete:{query:"*:*"}}'

Now let’s add a book with some reviews as nested child documents (notice the _childDocuments_ element):

$ curl http://localhost:8983/solr/demo/update?commitWithin=3000 -d '
[
{id : book1, type_s:book, title_t : "The Way of Kings", author_s : "Brandon Sanderson",
cat_s:fantasy, pubyear_i:2010, publisher_s:Tor,
_childDocuments_ : [
{ id: book1_c1, type_s:review, review_dt:"2015-01-03T14:30:00Z",
stars_i:5, author_s:yonik,
comment_t:"A great start to what looks like an epic series!"
}
,
{ id: book1_c2, type_s:review, review_dt:"2014-03-15T12:00:00Z",
stars_i:3, author_s:dan,
comment_t:"This book was too long."
}
]
}
]'

Now we can see that these are really just indexed as 3 documents, all visible by default:

curl http://localhost:8983/solr/demo/query -d 'q=*:*&fl=id'
"response":{"numFound":3,"start":0,"docs":[
{
"id":"book1_c1"},
{
"id":"book1_c2"},
{
"id":"book1"}]
}

Now lets add an additional document with nested child documents for use with our query examples:

$ curl http://localhost:8983/solr/demo/update?commitWithin=3000 -d '
[
{id : book2, type_s:book, title_t : "Snow Crash", author_s : "Neal Stephenson",
cat_s:sci-fi, pubyear_i:1992, publisher_s:Bantam,
_childDocuments_ : [
{ id: book2_c1, type_s:review, review_dt:"2015-01-03T14:30:00Z",
stars_i:5, author_s:yonik,
comment_t:"Ahead of its time... I wonder if it helped inspire The Matrix?"
}
,
{ id: book2_c2, type_s:review, review_dt:"2015-04-10T9:00:00Z",
stars_i:2, author_s:dan,
comment_t:"A pizza boy for the Mafia franchise? Really?"
}
,
{ id: book2_c3, type_s:review, review_dt:"2015-06-02T00:00:00Z",
stars_i:4, author_s:mary,
comment_t:"Neal is so creative and detailed! Loved the metaverse!"
}
]
}
]'

TODO

One can return child documents along with every returned parent document by using the [child] doc transformer (it’s added to the fl field list parameter ). The list of child documents will be included under the “_childDocuments_” field of each parent.

$ curl http://localhost:8983/solr/demo/query -d '
q=cat_s:(fantasy OR sci-fi)&
fl=id,[child parentFilter=type_s:book]'
"response":{"numFound":2,"start":0,"docs":[
{
"id":"book1",
"_childDocuments_":[
{
"id":"book1_c1",
"type_s":"review",
"review_dt":"2015-01-03T14:30:00Z",
"stars_i":5,
"author_s":"yonik",
"comment_t":["A great start to what looks like an epic series!"]},
{
"id":"book1_c2",
"type_s":"review",
[...]

Child Doc Transformer Parameters:

  • parentFilter - identifies all of the parents. See the section on The Parent Filter for more info.
  • childFilter - optional query to filter which child documents should be included.
  • limit - maximum number of child documents to return per parent (defaults to 10)

Also see the Solr ref guide entry on the [child] doc transformer.

The JSON Facet API has support for switching the facet domain based on the nested document relationships.

The main query gives us a document list of reviews by author_s:yonik If we want to facet on the book genre (cat_s field) then we need to switch the domain from the children (type_s:reviews) to the parents (type_s:books).

$ curl http://localhost:8983/solr/demo/query -d '
q=author_s:yonik&fl=id,comment_t&
json.facet={
genres : {
type: terms,
field: cat_s,
domain: { blockParent : "type_s:book" }
}
}'

And we get a facet over the books which yonik reviewed:

"response":{"numFound":2,"start":0,"docs":[
{
"id":"book1_c1",
"comment_t":["A great start to what looks like an epic series!"]},
{
"id":"book2_c1",
"comment_t":["Ahead of its time... I wonder if it helped inspire The Matrix?"]}]
},
"facets":{
"count":2,
"genres":{
"buckets":[{
"val":"fantasy",
"count":1},
{
"val":"sci-fi",
"count":1}]
}}

Now lets say we’re displaying the top sci-fi and fantasy books, and we want to find out who reviews the most books out of our selection. Since our root implicit facet bucket (formed by the query and filters) consists of parent documents (books), we need to switch the facet domain to the children for the author facet.

$ curl http://localhost:8983/solr/demo/query -d '
q=cat_s:(sci-fi OR fantasy)&fl=id,title_t&
json.facet={
top_reviewers : {
type: terms,
field: author_s,
domain: { blockChildren : "type_s:book" }
}
}'

Response:

"response":{"numFound":2,"start":0,"docs":[
{
"id":"book1",
"title_t":["The Way of Kings"]},
{
"id":"book2",
"title_t":["Snow Crash"]}]
},
"facets":{
"count":2,
"top_reviewers":{
"buckets":[{
"val":"dan",
"count":2},
{
"val":"yonik",
"count":2},
{
"val":"mary",
"count":1}]
}}

 

By default, blockChildren will match all children of every parent doc from the input domain. It’s often the case that only a subset of the children are desired. The easiest way to limit children is with the filter clause.

For example, if we wanted to find the same top reviewers as before, but only for 5 star reviews:

$ curl http://localhost:8983/solr/demo/query -d '
q=cat_s:(sci-fi OR fantasy)&fl=id,title_t&
json.facet={
top_reviewers : {
type: terms,
field: author_s,
domain: {
blockChildren : "type_s:book",
filter : "stars_i:5"
}
}
}'

Note that regardless of which direction we are mapping (parents to children or children to parents), or what documents we are operating on, we provide a parent filter to define the complete set of parents in the index. In these examples, the parent filter is "type_s:book".

Solr JSON Request API

Although query parameters are often an easy method to create a Solr requests by hand, they have a number of drawbacks:

  • Inherently un-structured, requiring unsightly parameters like f.facet_name.facet.range.start=5
  • Inherently un-typed… everything is a string.
  • More difficult to decipher large requests.
  • Harder to programmatically create a request.
  • Impossible to validate. Because of the lack of structure, we don’t know the set of valid parameter and thus can’t do good error checking.

Solr already had a JSON API for faceting and analytics, and this new feature has extended that to the complete Solr request!

First let’s add a few excellent books from the fantasy genre (the “commitWithin=1000” will cause them to be visible to searches within 1000 milliseconds):

$ curl http://localhost:8983/solr/update?commitWithin=1000 -d '
[
{"id":"book1", "author":"Brandon Sanderson", "title":"The Final Empire",
"series_s":"Mistborn", "sequence_i":1, "genre_s":"fantasy"},
{"id":"book2", "author":"Brandon Sanderson", "title":"The Well of Ascension",
"series_s":"Mistborn", "sequence_i":2, "genre_s":"fantasy"},
{"id":"book3", "author":"Brandon Sanderson", "title":"The Hero of Ages",
"series_s":"Mistborn", "sequence_i":3, "genre_s":"fantasy"}
]'

Now we can search them with a JSON request rather than using query parameters:

$ curl http://localhost:8983/solr/query -d '
{
query:"hero"
}'

RESPONSE:

{
"responseHeader":{
"status":0,
"QTime":2,
"params":{
"json":"n{n query:"hero"n}"}},
"response":{"numFound":1,"start":0,"docs":[
{
"id":"book3",
"author":"Brandon Sanderson",
"author_s":"Brandon Sanderson",
"title":["The Hero of Aages"],
"series_s":"Mistborn",
"sequence_i":3,
"genre_s":"fantasy",
"_version_":1486581355536973824
}]
}
}

A few things to note from our example:

  • JSON body is considered a parameter named “json” and echoed back with the other params (unless you disable it with echoParams=none). This will also cause it to be logged, which is normally important.
  • The JSON we send to Solr can include unquoted simple strings and can contain comments. See JSON Extensions.
  • We don’t need to pass the Content-Type for indexing or for querying when we’re using JSON since Solr is now smart enough to auto-detect it when Curl is the client.
  • HTTP GET requests are now allowed to have a request body (i.e. try using “curl -XGET” for the query)

Here’s a more complete example:

curl -XGET http://localhost:8983/solr/query -d '
{
query : "*:*",
filter : [
"author:brandon",
"genre_s:fantasy"
],
offset : 0,
limit : 5,
fields : ["title","author"], // we could also use the string form "title,author"
sort : "sequence_i desc",
facet : { // the JSON Facet API is nicely integrated as well
avg_price : "avg(price)",
top_authors : {terms : author}
}
}'

It may sometimes be more convenient to pass the JSON body as a request parameter rather than in the actual body of the HTTP request. Solr treats a json parameter the same as a JSON body.

$ curl http://localhost:8983/solr/query -d 'json={query:"hero"}&fq=author:brandon'

Multiple json parameters in a single request are merged before being interpreted.

  • Single-valued elements are overwritten by the last value.
  • Multi-valued elements likefields and filter are appended.
  • Parameters of the form json.<path>=<json_value> are merged in the appropriate place in the hierarchy. For example a json.facet parameter is the same as “facet” within the JSON body.
  • A JSON body, or straight json parameters are always parsed first, meaning that other request parameters come after, and overwrite single valued elements.

Smart merging gives the best of both worlds… the structure of JSON with the ability to selectively separate out / decompose parts of the request!

curl 'http://localhost:8983/solr/query?json.limit=5&json.filter="genre_s:fantasy"' -d '
{
query : "hero",
limit : 10,
filter : "author:brandon"
}'

is equivalent to

curl http://localhost:8983/solr/query -d '
{
query : "hero",
limit : 5, // this parameter was overwritten
filter : [ "author:brandon" , "genre_s:fantasy" ] // this parameter was appended to
}'

In fact, you don’t even need to start with a JSON body for smart merging to be very useful. Consider the following request composed entirely of request params:

curl http://localhost:8983/solr/query -d 'q=*:*&rows=1&
json.facet.avg_price="avg(price)"&
json.facet.top_authors={type:terms,field:author_s,limit:5}'

That is equivalent to having the following JSON body or json parameter:

{
facet: {
avg_price: "avg(price)",
top_authors: {
type: terms,
field: author_s",
limit: 5
}
}
}

What to see what your merged JSON looks like? Just ask for debugging information (i.e. use the debug=true param), and it will come back under the "json" key along with the other debugging information.

We can also pass normal request parameters in the JSON body within the params block:

$ curl "http://localhost:8983/solr/query?fl=title,author"-d '
{
params:{
q:"title:hero",
rows:1
}
}
'

Which is equivalent to:

$ curl "http://localhost:8983/solr/query?fl=title,author&q=title:hero&rows=1"

Because we didn’t pollute the root body of the JSON request with the normal Solr request parameters (they are all contained in the params block), we now have the ability to validate requests and return an error for unknown JSON keys.

$ curl http://localhost:8983/solr/query -d '
{
query : "hero",
fulter : "author:brandon" // oops, we misspelled "filter"
}'

And we get an error back containing the error string:

"Unknown top-level key in JSON request : fulter"

Of course request templating via Parameter Substitution works fully with JSON request bodies or parameters as well.

Example:

$ curl "http://localhost:8983/solr/query?FIELD=text&TERM=hero&HOWMANY=10" -d '
{
query:"${FIELD}:${TERM}",
limit:${HOWMANY}
}'

The JSON Request API is currently in it’s infancy - only a few query parameters are supported (although the JSON Facet API which is part of this is more mature). Other Solr features you may want access to (like highlighting) currently need to be controlled through the normal Solr request params (e.g. just ad hl=true to the normal request parameters, or in the params block of a JSON request.

Have ideas on what will make the API better? Want to help out with development? We’d love to hear from you on the solr-user mailing list!