Searching Date, Text, Keyword, and Integer Fields with Lucene Query Syntax in nlp4j-local-search
nlp4j-local-search is a lightweight Python package that lets you use Apache Lucene directly from Python.
One useful feature of the recent versions is support for typed fields.
A single document can contain different kinds of data, for example:
- full-text content
- string keyword fields
- integer fields
- date/time fields
These fields can then be combined in a single query using Lucene Query Parser syntax.
For example:
maker:Nissan
AND year_i:[2024 TO 2026]
AND created_dt:[2026-08-01T00:00:00Z TO 2026-09-01T00:00:00Z]
AND text_en:battery
In this article, I will create a small vehicle-report dataset and search it using several field types.
Installation
Install nlp4j-local-search from PyPI.
pip install nlp4j-local-search==0.5.1
Then import SearchEngine.
from nlp4j_local_search import SearchEngine
Internally, the architecture is roughly:
Python
↓
nlp4j-local-search
↓
Java LocalSearch
↓
Apache Lucene
No Elasticsearch, OpenSearch, Solr, or separate search server is required.
Our Document Model
Suppose we want to index vehicle issue reports.
Each document contains:
id
body
maker
model_year_i
created_dt
The fields have different purposes.
| Field | Type | Example |
|---|---|---|
id |
Keyword | "1" |
body / text_en
|
Text | "Nissan reported a battery problem." |
maker |
Keyword | "Nissan" |
model_year_i |
Integer | 2025 |
created_dt |
Date | "2026-08-10T09:00:00Z" |
This is a fairly typical document structure.
We want body to support full-text search, maker to support exact matching, and the year and date fields to support range queries.
Field Naming Conventions
nlp4j-local-search can resolve some field types automatically from the field name.
For example:
model_year_i → INTEGER
created_dt → DATE
maker → KEYWORD
The important suffixes include:
*_i → INTEGER
*_l → LONG
*_d → DOUBLE
*_dt → DATE
Fields without a special suffix are normally treated as keyword fields when they are added as additional JSON fields.
For English text, the document body is indexed into the language-specific text field:
text_en
Therefore, a document like this:
{
"id": "1",
"body": "Nissan reported a battery problem.",
"maker": "Nissan",
"model_year_i": 2025,
"created_dt": "2026-08-10T09:00:00Z",
}
contains four interesting searchable dimensions:
text_en TEXT
maker KEYWORD
model_year_i INTEGER
created_dt DATE
Adding Documents
Let's create a small dataset.
from nlp4j_local_search import SearchEngine
DOCUMENTS = [
{
"id": "1",
"body": "Nissan reported a battery problem.",
"maker": "Nissan",
"model_year_i": 2025,
"created_dt": "2026-08-10T09:00:00Z",
},
{
"id": "2",
"body": "Nissan reported a broken door mirror.",
"maker": "Nissan",
"model_year_i": 2024,
"created_dt": "2026-07-20T12:00:00Z",
},
{
"id": "3",
"body": "Toyota reported a battery charging problem.",
"maker": "Toyota",
"model_year_i": 2026,
"created_dt": "2026-08-18T15:30:00Z",
},
{
"id": "4",
"body": "Toyota reported a brake problem.",
"maker": "Toyota",
"model_year_i": 2023,
"created_dt": "2026-06-15T08:00:00Z",
},
{
"id": "5",
"body": "Honda reported a battery warning light.",
"maker": "Honda",
"model_year_i": 2025,
"created_dt": "2026-08-22T10:15:00Z",
},
]
Now add them to the index.
with SearchEngine("en", auto_analyze=False) as engine:
for doc in DOCUMENTS:
engine.add_json(doc)
engine.commit()
add_json() accepts Python dictionaries, so we do not have to manually construct Lucene documents.
The Python layer passes the document to the Java implementation, where the field definitions are resolved and the corresponding Lucene fields are created.
A Helper for Lucene Query Strings
search_response_json() accepts an OpenSearch-style query request.
To execute Lucene Query Parser syntax, we can use the query_string query.
For convenience, let's define a small helper.
def search_lucene(engine, query, size=10):
return engine.search_response_json({
"size": size,
"query": {
"query_string": {
"query": query,
"default_field": "text_en",
}
},
})
Now we can write queries like:
response = search_lucene(
engine,
"maker:Nissan AND model_year_i:[2024 TO 2026]",
)
The important part is the query itself:
maker:Nissan AND model_year_i:[2024 TO 2026]
This is Lucene Query Parser syntax.
The surrounding JSON only tells the nlp4j-local-search API that the string should be interpreted as a query_string.
Printing Search Results
Let's also create a helper for displaying the results.
def print_hits(response):
for hit in response["hits"]["hits"]:
source = hit["_source"]
print(
source["id"],
source.get("maker"),
source.get("model_year_i"),
source.get("created_dt"),
source.get("text_en"),
)
Depending on the API version and stored source representation, you can of course access the complete _source dictionary directly as well.
1. Keyword Search
First, let's search the maker field.
response = search_lucene(
engine,
"maker:Nissan",
)
print_hits(response)
The query is simply:
maker:Nissan
This is an exact keyword-field condition.
Expected matches:
1 Nissan 2025 ... Nissan reported a battery problem.
2 Nissan 2024 ... Nissan reported a broken door mirror.
This is different from a full-text search.
The value Nissan is stored as a keyword value, so it represents a structured attribute of the document.
This is similar to fields such as:
country:Japan
category:vehicle
status:open
source:NHTSA
2. Full-Text Search
The body supplied to SearchEngine("en") is searchable through:
text_en
For example:
response = search_lucene(
engine,
"text_en:battery",
)
print_hits(response)
This should find documents such as:
Nissan reported a battery problem.
Toyota reported a battery charging problem.
Honda reported a battery warning light.
Unlike maker, text_en is a full-text field.
The query is analyzed by the text analyzer rather than treated simply as an exact string value.
This gives us an important distinction:
maker:Nissan
means approximately:
The
makerfield has the keyword valueNissan.
while:
text_en:battery
means:
Search the analyzed English text field for
battery.
3. Integer Exact Match
Now let's search an integer field.
Because the field is named:
model_year_i
it is resolved as an integer field.
An exact query looks almost identical to a keyword query:
response = search_lucene(
engine,
"model_year_i:2025",
)
print_hits(response)
Expected matches:
1 Nissan 2025
5 Honda 2025
Although the query syntax looks simple, internally this is not handled as a string comparison.
The schema-aware Lucene query parser knows that model_year_i is an integer field and creates an appropriate numeric query.
4. Integer Range Search
Typed fields become particularly useful when we use ranges.
For example:
response = search_lucene(
engine,
"model_year_i:[2024 TO 2026]",
)
print_hits(response)
The Lucene range syntax:
[2024 TO 2026]
means that both endpoints are included.
Conceptually:
2024 <= model_year_i <= 2026
Documents for model years 2024, 2025, and 2026 will match.
Exclusive Ranges
Lucene also supports exclusive endpoints using { and }.
For example:
model_year_i:{2024 TO 2026}
means:
2024 < model_year_i < 2026
Therefore, only:
2025
matches.
You can also mix inclusive and exclusive boundaries.
model_year_i:{2024 TO 2026]
means:
2024 < year <= 2026
while:
model_year_i:[2024 TO 2026}
means:
2024 <= year < 2026
This is standard Lucene Query Parser range syntax, but nlp4j-local-search maps it to the appropriate numeric Lucene query according to the field schema.
5. Date Range Search
Dates work in much the same way.
Consider:
created_dt
Because the name ends with _dt, it is treated as a date field.
Let's find reports created during August 2026.
response = search_lucene(
engine,
(
"created_dt:[2026-08-01T00:00:00Z "
"TO 2026-08-31T23:59:59Z]"
),
)
print_hits(response)
Expected matches include documents 1, 3, and 5.
The syntax is still standard Lucene range syntax:
field:[lower TO upper]
but the values are parsed as date/time values rather than ordinary strings.
Internally, date values can therefore be compared chronologically.
Open-Ended Date Ranges
Lucene's * syntax is useful for date searches.
For example, everything on or after August 1, 2026:
response = search_lucene(
engine,
"created_dt:[2026-08-01T00:00:00Z TO *]",
)
Or everything before August 2026:
response = search_lucene(
engine,
"created_dt:[* TO 2026-07-31T23:59:59Z]",
)
These are particularly convenient when implementing filters such as:
since this date
before this date
after this release
historical records only
6. Combining Keyword and Integer Fields
Now we can start combining different types.
For example:
response = search_lucene(
engine,
"maker:Nissan AND model_year_i:[2024 TO 2025]",
)
print_hits(response)
This query combines:
maker KEYWORD
model_year_i INTEGER
The query can be read directly:
Find Nissan documents whose model year is between 2024 and 2025.
Both Nissan records in our example match.
7. Combining Text and Keyword Search
We can also combine structured metadata with full text.
response = search_lucene(
engine,
"maker:Nissan AND text_en:battery",
)
print_hits(response)
Expected result:
1 Nissan 2025 Nissan reported a battery problem.
This is one of the main reasons I like using a Lucene-style query syntax for exploratory search.
The query is compact and readable:
maker:Nissan AND text_en:battery
instead of requiring separate API calls for the structured filter and the full-text condition.
8. Combining Text, Keyword, Integer, and Date
Now let's combine all four field types.
query = (
"maker:Nissan "
"AND model_year_i:[2024 TO 2026] "
"AND created_dt:[2026-08-01T00:00:00Z TO 2026-09-01T00:00:00Z} "
"AND text_en:battery"
)
response = search_lucene(engine, query)
print_hits(response)
The query is:
maker:Nissan
AND model_year_i:[2024 TO 2026]
AND created_dt:[2026-08-01T00:00:00Z TO 2026-09-01T00:00:00Z}
AND text_en:battery
It combines:
maker → Keyword
model_year_i → Integer
created_dt → Date
text_en → Text
The expected result is document 1:
Nissan reported a battery problem.
Why?
Because it satisfies all four conditions:
maker = Nissan
model year = 2025
created = 2026-08-10
body contains = battery
This is the key idea of this article:
A single Lucene query can combine structured typed fields and analyzed text fields.
9. OR Queries
Standard Boolean operators can also be used.
For example:
response = search_lucene(
engine,
"maker:Nissan OR maker:Toyota",
)
Or:
response = search_lucene(
engine,
"(maker:Nissan OR maker:Honda) AND text_en:battery",
)
The parentheses make the intention explicit:
(Nissan OR Honda)
AND
battery
Expected matches are:
Nissan reported a battery problem.
Honda reported a battery warning light.
10. NOT Queries
We can exclude values as well.
For example:
response = search_lucene(
engine,
"text_en:battery AND NOT maker:Toyota",
)
Conceptually:
battery reports
minus
Toyota reports
The remaining documents are the Nissan and Honda battery-related records.
Why Typed Fields Matter
If every JSON value were indexed simply as a string, this query:
model_year_i:[9 TO 100]
would potentially behave like a lexical string comparison rather than a numeric comparison.
Likewise, dates should not be compared merely as arbitrary text.
nlp4j-local-search keeps a schema describing logical field types.
For example:
KEYWORD
TEXT
INTEGER
LONG
DOUBLE
DATE
KNN_VECTOR
STORED_ONLY
The query layer can therefore interpret:
model_year_i:[2024 TO 2026]
as an integer range and:
created_dt:[2026-08-01T00:00:00Z TO 2026-09-01T00:00:00Z]
as a date range.
This schema-aware behavior is important when using Lucene Query Parser syntax across heterogeneous fields.
Dynamic Fields Keep the Python API Small
One design goal of nlp4j-local-search is to avoid requiring a large schema definition for simple applications.
For common cases, the field name itself provides enough information.
{
"maker": "Nissan",
"model_year_i": 2025,
"created_dt": "2026-08-10T09:00:00Z",
}
From these names:
maker → keyword
model_year_i → integer
created_dt → date
the Java layer can build the appropriate Lucene representation.
This makes adding JSON documents from Python quite straightforward.
engine.add_json(document)
At the same time, the Java API also supports explicit schema definitions when more control is needed.
So the approach is essentially:
simple case
↓
dynamic field naming
advanced case
↓
explicit schema
Why Use Lucene Query Syntax?
You could expose every search condition as an individual Python argument.
For example:
search(
maker="Nissan",
min_year=2024,
max_year=2026,
start_date=...,
words=["battery"],
)
That works for a specific application, but the API quickly becomes application-specific.
Lucene Query Parser syntax gives us a compact general-purpose language.
For example:
maker:Nissan AND text_en:battery
or:
maker:Toyota
AND model_year_i:[2024 TO *]
AND created_dt:[2026-01-01T00:00:00Z TO *]
or:
(maker:Nissan OR maker:Honda)
AND text_en:battery
AND model_year_i:[2025 TO 2026]
For interactive analysis, notebooks, debugging, and search tools, this can be very convenient.
This Is Still Apache Lucene
One thing I find interesting about this approach is that there is no external search service involved.
The application structure remains:
Python application
↓
Java API through JPype
↓
Apache Lucene
↓
local index
You do not need to start:
Elasticsearch
OpenSearch
Solr
before executing the example.
Of course, those systems provide many features beyond Lucene itself and are excellent choices for distributed search infrastructure.
The purpose of nlp4j-local-search is different.
It is useful when you want something closer to:
“I would like to use Lucene as a library from Python.”
For local applications and text-analysis experiments, that can be a surprisingly useful option.
Full Example
Here is the complete example.
from nlp4j_local_search import SearchEngine
DOCUMENTS = [
{
"id": "1",
"body": "Nissan reported a battery problem.",
"maker": "Nissan",
"model_year_i": 2025,
"created_dt": "2026-08-10T09:00:00Z",
},
{
"id": "2",
"body": "Nissan reported a broken door mirror.",
"maker": "Nissan",
"model_year_i": 2024,
"created_dt": "2026-07-20T12:00:00Z",
},
{
"id": "3",
"body": "Toyota reported a battery charging problem.",
"maker": "Toyota",
"model_year_i": 2026,
"created_dt": "2026-08-18T15:30:00Z",
},
{
"id": "4",
"body": "Toyota reported a brake problem.",
"maker": "Toyota",
"model_year_i": 2023,
"created_dt": "2026-06-15T08:00:00Z",
},
{
"id": "5",
"body": "Honda reported a battery warning light.",
"maker": "Honda",
"model_year_i": 2025,
"created_dt": "2026-08-22T10:15:00Z",
},
]
def search_lucene(engine, query, size=10):
return engine.search_response_json({
"size": size,
"query": {
"query_string": {
"query": query,
"default_field": "text_en",
}
},
})
def print_hits(response):
for hit in response["hits"]["hits"]:
source = hit["_source"]
print(
f"id={source['id']} "
f"maker={source.get('maker')} "
f"year={source.get('model_year_i')} "
f"created={source.get('created_dt')} "
f"text={source.get('text_en')}"
)
with SearchEngine("en", auto_analyze=False) as engine:
for doc in DOCUMENTS:
engine.add_json(doc)
engine.commit()
print("=== Keyword ===")
print_hits(
search_lucene(
engine,
"maker:Nissan",
)
)
print()
print("=== Integer range ===")
print_hits(
search_lucene(
engine,
"model_year_i:[2024 TO 2026]",
)
)
print()
print("=== Date range ===")
print_hits(
search_lucene(
engine,
"created_dt:[2026-08-01T00:00:00Z TO 2026-09-01T00:00:00Z}",
)
)
print()
print("=== Full text + keyword ===")
print_hits(
search_lucene(
engine,
"maker:Nissan AND text_en:battery",
)
)
print()
print("=== Combined typed-field query ===")
query = (
"maker:Nissan "
"AND model_year_i:[2024 TO 2026] "
"AND created_dt:[2026-08-01T00:00:00Z TO 2026-09-01T00:00:00Z} "
"AND text_en:battery"
)
print("Query:")
print(query)
print()
print_hits(search_lucene(engine, query))
The final query is the most interesting part:
maker:Nissan
AND model_year_i:[2024 TO 2026]
AND created_dt:[2026-08-01T00:00:00Z TO 2026-09-01T00:00:00Z}
AND text_en:battery
It combines a keyword, an integer range, a date range, and full-text search in one expression.
From Search to Analysis
The same field model can also be used by other nlp4j-local-search APIs.
For example, after indexing the documents, we can inspect an aggregatable field:
engine.view("maker")
or inspect a subset using a Lucene query:
engine.view(
"maker",
lucene_query="model_year_i:[2024 TO 2026]",
)
So a workflow can look like:
JSON documents
↓
typed Lucene fields
↓
Lucene Query search
↓
view / aggregation
↓
text analysis
This is the direction I am currently exploring with nlp4j-local-search: using the same local Lucene index for both document search and lightweight exploratory text analysis.
Summary
With nlp4j-local-search, a Python dictionary can contain multiple field types:
{
"id": "1",
"body": "Nissan reported a battery problem.",
"maker": "Nissan",
"model_year_i": 2025,
"created_dt": "2026-08-10T09:00:00Z",
}
These correspond to different Lucene field semantics:
body / text_en → full-text TEXT
maker → KEYWORD
model_year_i → INTEGER
created_dt → DATE
Then Lucene Query Parser syntax can combine them naturally:
maker:Nissan
AND model_year_i:[2024 TO 2026]
AND created_dt:[2026-08-01T00:00:00Z TO 2026-09-01T00:00:00Z}
AND text_en:battery
For me, the attractive part is that this gives Python applications access to fairly expressive Lucene queries while keeping the deployment model extremely small:
pip install
↓
create SearchEngine
↓
add JSON
↓
search
No search server is required.
If you need a distributed search platform, Solr or OpenSearch will often be the better choice.
But if what you want is simply:
“Use Apache Lucene directly inside a Python application.”
nlp4j-local-search provides another option.
Links
- GitHub:
https://github.com/oyahiroki/nlp4j-local-search - PyPI:
nlp4j-local-search - Apache Lucene:
https://lucene.apache.org/