QBit: Quick Search
OpenSearch-Powered Full-Text Search for QQQ Applications
This QBit provides global search across QQQ application tables via OpenSearch. Annotate entity classes or declare tables via config, configure a connection, produce the QBit, and gain real-time indexing, scheduled basepull catch-up, per-field relevance boosting, and a pagination-aware search API.
Core Capabilities
- Real-Time Indexing: Post-insert, post-update, and post-delete customizers index records immediately on write
- Basepull Safety Net: Scheduled process catches records missed during downtime or bulk imports
- Field-Level Boosting:
@QuickSearchField(weight = N)controls relevance ranking per field - Edge-Ngram Typeahead: Custom analyzer enables prefix matching ("ord" finds "order")
- Paginated Search:
QuickSearchActionreturns results with scores, highlights, total hits, and hasMore flag - Config-Driven Indexing:
SearchableTableConfigAPI indexes tables from third-party QBit jars without modifying their source - Extensible Publisher:
IndexEventPublisherinterface allows swapping in RabbitMQ, Kafka, or any async transport - Admin Observability: Operational tables track index status and run history
Open Source & Full Control
QBit Quick Search is 100% open source under Apache 2.0. All data stays in your OpenSearch cluster.
Architecture
Design Principles
-
Real-time first, basepull as safety net: Table customizers index records synchronously on every insert, update, and delete. The basepull process runs on a schedule as a catch-up mechanism for bulk imports, OpenSearch downtime, or any records the real-time path missed.
-
Annotation and config-driven discovery: Entity classes marked with
@QuickSearchableand@QuickSearchFieldare discovered at produce time. Tables from third-party jars can be declared viaSearchableTableConfigin code. Both paths merge duringproduce(). -
Extensible transport: The
IndexEventPublisherstrategy interface decouples the write path from the indexing path. The defaultSynchronousIndexEventPublisherwrites directly to OpenSearch. Consumers can provide a queue-backed implementation for async indexing.
Technology Stack
- Java 21 with QQQ backend modules
- OpenSearch 2.x for full-text search with custom edge-ngram analyzer
- QQQ Framework: Entities, processes, customizers, permissions, API layer
Module Organization
qbit-quick-search/
src/main/java/com/kingsrook/qbits/quicksearch/
QuickSearchQBitConfig.java -- Configuration and validation
QuickSearchQBitProducer.java -- Produces tables, processes, customizers
QuickSearchQBitContext.java -- Static runtime context
QuickSearchableTableConfig.java -- Per-table index configuration
SearchableFieldConfig.java -- Config-driven field definition
SearchableTableConfig.java -- Config-driven table definition
annotations/ -- @QuickSearchable, @QuickSearchField
model/ -- QuickSearchIndex, QuickSearchIndexRun entities
opensearch/ -- OpenSearch client, document model, bulk results
actions/ -- QuickSearchAction, input/output DTOs
processes/ -- AbstractIndexingStep, BasepullIndexStep, FullReindexStep
customizers/ -- Post-insert, post-update, post-delete customizers
publisher/ -- IndexEventPublisher interface, SynchronousIndexEventPublisher
Getting Started
Prerequisites
- Java 21+
- Maven 3.8+
- OpenSearch 2.x instance
- QQQ Application (this is a QBit, not a standalone application)
Usage
Maven dependency
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-quick-search</artifactId>
<version>0.2.0-SNAPSHOT</version>
</dependency>
Annotate entity classes
@QuickSearchable(tableName = "customer")
public class Customer
{
@QuickSearchField(weight = 3)
private String name;
@QuickSearchField(weight = 1)
private String email;
@QuickSearchField(weight = 2, includeLabel = true)
private String accountNumber;
}
Minimal setup
QuickSearchQBitConfig config = new QuickSearchQBitConfig()
.withBackendName("yourBackendName")
.withOpensearchHost("localhost")
.withOpensearchPort(9200)
.withOpensearchIndexName("my-app-search")
.withSearchableEntityClasses(List.of(Customer.class, Order.class));
new QuickSearchQBitProducer()
.withConfig(config)
.produce(qInstance);
With authentication and SSL
QuickSearchQBitConfig config = new QuickSearchQBitConfig()
.withBackendName("yourBackendName")
.withOpensearchHost("search.example.com")
.withOpensearchPort(443)
.withUseSsl(true)
.withOpensearchUsername("admin")
.withOpensearchPassword("secretPassword")
.withOpensearchIndexName("my-app-search")
.withSearchableEntityClasses(List.of(Customer.class));
With custom publisher (async indexing)
QuickSearchQBitConfig config = new QuickSearchQBitConfig()
.withBackendName("yourBackendName")
.withOpensearchHost("localhost")
.withOpensearchPort(9200)
.withOpensearchIndexName("my-app-search")
.withSearchableEntityClasses(List.of(Customer.class))
.withIndexEventPublisher(new RabbitMqIndexEventPublisher(connectionFactory));
Config-driven table indexing
When tables come from third-party QBit jars (like qbit-crm or qbit-wms), you cannot add annotations to their entity classes. Use SearchableTableConfig and SearchableFieldConfig to declare those tables as searchable in your host application config.
QuickSearchQBitConfig config = new QuickSearchQBitConfig()
.withBackendName("rdbms")
.withOpensearchHost("localhost")
.withOpensearchPort(9200)
.withOpensearchIndexName("voyage-search")
.withSearchableTable("crmContact", List.of(
new SearchableFieldConfig("firstName").withWeight(2),
new SearchableFieldConfig("lastName").withWeight(2),
new SearchableFieldConfig("email").withWeight(3),
new SearchableFieldConfig("phone").withWeight(1)))
.withSearchableTable(new SearchableTableConfig("crmFormSubmission", List.of(
new SearchableFieldConfig("email").withWeight(3),
new SearchableFieldConfig("firstName").withWeight(1),
new SearchableFieldConfig("message").withWeight(1)))
.withRecordLabelFormat("%s - %s", "formType", "email"));
withSearchableTable() accepts either a (String tableName, List<SearchableFieldConfig> fields) shorthand or a full SearchableTableConfig for advanced options.
SearchableFieldConfig
| Method | Description |
|---|---|
new SearchableFieldConfig(fieldName) |
Constructor; QQQ field name is required |
.withWeight(int) |
Boost factor for relevance ranking (default 1) |
.withIncludeLabel(boolean) |
Prefix indexed text with the field label (default false) |
SearchableTableConfig
| Method | Description |
|---|---|
new SearchableTableConfig(tableName, fields) |
Constructor; table name and at least one field required |
.withBasepullIntervalMinutes(int) |
Override basepull schedule (defaults to config-level setting) |
.withBasepullTimestampField(String) |
Change detection field (default "modifyDate") |
.withEnabledByDefault(boolean) |
Whether indexing starts active (default true) |
.withRecordLabelFormat(String format, String... fields) |
String.format pattern and field names for search result display label |
Record label override
By default, search results use QQQ's record label for display. Config-driven tables can override this with withRecordLabelFormat(). The format string is passed to String.format() with field values pulled from the record in the order specified.
new SearchableTableConfig("crmFormSubmission", fields)
.withRecordLabelFormat("%s - %s", "formType", "email");
// produces labels like "Contact Form - user@example.com"
Mixing annotations and config
Annotations and config-driven declarations can be used together. Both paths merge during produce(). Each table must appear in only one source; duplicate table names across annotation and config sources will produce a validation error.
QuickSearchQBitConfig config = new QuickSearchQBitConfig()
.withBackendName("rdbms")
.withOpensearchHost("localhost")
.withOpensearchPort(9200)
.withOpensearchIndexName("my-app-search")
.withSearchableEntityClasses(List.of(Customer.class, Order.class))
.withSearchableTable("crmContact", List.of(
new SearchableFieldConfig("firstName").withWeight(2),
new SearchableFieldConfig("lastName").withWeight(2),
new SearchableFieldConfig("email").withWeight(3)));
Searching
Once the QBit is produced, use QuickSearchAction to execute searches:
QuickSearchOutput output = new QuickSearchAction().execute(
new QuickSearchInput()
.withSearchTerm("widget")
.withLimit(25)
.withOffset(0));
// Check results
System.out.println("Total hits: " + output.getTotalHits());
System.out.println("Has more: " + output.getHasMore());
for(QuickSearchResult result : output.getResults())
{
System.out.println(result.getTableName() + ":" + result.getRecordId()
+ " - " + result.getRecordLabel()
+ " (score: " + result.getScore() + ")");
}
To filter results to a single table:
QuickSearchOutput output = new QuickSearchAction().execute(
new QuickSearchInput()
.withSearchTerm("blue")
.withTableName("customer")
.withLimit(10));
Triggering a Full Reindex
The full reindex process can be triggered programmatically:
RunBackendStepInput input = new RunBackendStepInput();
input.addValue("tableName", "customer"); // optional: omit to reindex all tables
RunBackendStepOutput output = new RunBackendStepOutput();
new FullReindexStep().run(input, output);
Or through the QQQ admin UI via the "Full Reindex" process.
Data Model
Tables (2)
| Table | Description |
|---|---|
quickSearchIndex |
Per-table index configuration, status, and basepull tracking |
quickSearchIndexRun |
Individual indexing run history with record counts and error tracking |
Processes (2)
| Process | Description |
|---|---|
| Basepull Index | Scheduled: queries each source table for records modified since last run, indexes in batches |
| Full Reindex | On-demand: deletes and re-indexes all records for one or all tables |
Configuration Reference
| Field | Type | Default | Description |
|---|---|---|---|
backendName |
String |
required | QQQ backend for operational tables |
opensearchHost |
String |
required | OpenSearch hostname |
opensearchPort |
Integer |
required | OpenSearch port |
opensearchIndexName |
String |
required | OpenSearch index name |
searchableEntityClasses |
List<Class<?>> |
none | Entity classes with @QuickSearchable |
searchableTables |
List<SearchableTableConfig> |
none | Config-driven table definitions |
opensearchUsername |
String |
none | HTTP basic auth username |
opensearchPassword |
String |
none | HTTP basic auth password |
useSsl |
Boolean |
false |
Use HTTPS |
tableNamePrefix |
String |
none | Prefix for produced table names |
enableRealTimeIndexing |
Boolean |
true |
Register post-insert/update/delete customizers |
enableScheduledProcesses |
Boolean |
true |
Register scheduled basepull process |
defaultBasepullIntervalMinutes |
Integer |
5 |
Default basepull schedule |
bulkBatchSize |
Integer |
500 |
Documents per OpenSearch bulk request |
sourceBatchSize |
Integer |
1000 |
Records per source table query batch |
indexEventPublisher |
IndexEventPublisher |
sync | Custom publisher for async transport |
At least one of searchableEntityClasses or searchableTables must be provided. Both can be used together.
Annotations
@QuickSearchable
| Attribute | Type | Default | Description |
|---|---|---|---|
tableName |
String |
required | QQQ table name |
fields |
String[] |
{} |
Fallback field list if no @QuickSearchField annotations |
basepullIntervalMinutes |
int |
5 |
Basepull schedule for this table |
basepullTimestampField |
String |
"modifyDate" |
Field for change detection |
enabledByDefault |
boolean |
true |
Active on first produce |
@QuickSearchField
| Attribute | Type | Default | Description |
|---|---|---|---|
weight |
int |
1 |
Boost factor for search relevance |
includeLabel |
boolean |
false |
Prefix value with field label in indexed text |
Companion QBit Integration
qbit-crm
Index crmContact, crmCompany, and crmDeal for full-text global search across CRM records.
qbit-wms
Index wmsItem and wmsLocation for warehouse item and location search.
Testing
mvn test # Run all 174 unit tests
mvn verify # Run unit tests + integration tests (requires Docker)
mvn test -Dtest=ClassName # Run single test class
Coverage
- 70%+ instruction coverage (JaCoCo enforced)
- 90%+ class coverage (JaCoCo enforced)
- All unit tests run in-memory via MemoryRecordStore (no OpenSearch required)
- Integration tests use Testcontainers with real OpenSearch
Documentation
- QQQ Wiki - Framework documentation
- QBit Development Guide - How QBits work
Contributing
QBit Quick Search is open source and welcomes contributions.
- Report Issues - Bug reports and feature requests
- QQQ Contribution Guide - How to contribute
About Kingsrook
QBit Quick Search is built by Kingsrook - making engineers more productive through intelligent automation and developer tools.
- Website: https://qrun.io
- Contact: contact@kingsrook.com
- GitHub: https://github.com/QRun-IO
License
This project is licensed under the Apache License, Version 2.0 - see the LICENSE file for details.