Build a store search system for hundreds of millions of records

Updated at:
Copy as MD

Use Tablestore search indexes and geo-location queries to build a store search system that supports multi-condition retrieval across hundreds of millions of records.

Solution overview

A store search platform must run multi-condition queries across hundreds of millions of records. Typical use cases include finding stores of a specific type near a location, searching by name with results sorted by rating, and aggregating store counts and ratings by type. The core challenge is combining efficient geo-location retrieval with flexible multi-condition queries.

A single Tablestore table supports petabyte-scale data storage and tens of millions of TPS. Search indexes support GEO_POINT geo-location indexing, full-text indexing, multi-condition queries, and aggregations.

Solution design

This solution uses one data table for store records and a search index for geo-location and multi-condition retrieval. The following table describes the fields in the data table and search index.

Field type

Field

Data type

Index field type

Description

Primary key column

_id

STRING

The MD5 hash of the store ID. This value is used as the partition key to evenly distribute data.

Attribute columns

shop_id

STRING

KEYWORD

The store ID.

type

STRING

KEYWORD

The store type, such as bubble tea, coffee, or hotpot.

name

STRING

TEXT

The store name. The TEXT type supports full-text search.

pos

STRING

GEO_POINT

The geographic location of the store, in the format of "latitude,longitude" (for example, "30.270000,120.150000").

score

DOUBLE

DOUBLE

The store rating (1.0 to 5.0).

avg_price

DOUBLE

DOUBLE

The average spending per person (CNY).

Implementation

Before you begin, activate Tablestore and create an instance. If you already have an instance, skip this step. For more information, see Activate Tablestore and create an instance.

Note

The following examples use Tablestore SDK for Java and Tablestore SDK for Python. Before you begin, install and configure Java SDK or Python SDK.

Step 1: Create a data table and a search index

When you create a data table, specify only the primary key columns. Attribute columns do not need to be predefined. Then create a search index that includes a GEO_POINT geo-location index and a TEXT full-text index.

Java

// Create a data table
private static void createTable(SyncClient client) {
    TableMeta tableMeta = new TableMeta("shop");
    tableMeta.addPrimaryKeyColumn(new PrimaryKeySchema("_id", PrimaryKeyType.STRING));

    TableOptions tableOptions = new TableOptions();
    tableOptions.setMaxVersions(1);
    tableOptions.setTimeToLive(-1);

    client.createTable(new CreateTableRequest(tableMeta, tableOptions));
}

// Create a search index
private static void createSearchIndex(SyncClient client) {
    CreateSearchIndexRequest request = new CreateSearchIndexRequest();
    request.setTableName("shop");
    request.setIndexName("shop_index");

    IndexSchema schema = new IndexSchema();
    schema.setFieldSchemas(Arrays.asList(
        new FieldSchema("shop_id", FieldType.KEYWORD).setIndex(true).setEnableSortAndAgg(true),
        new FieldSchema("type", FieldType.KEYWORD).setIndex(true).setEnableSortAndAgg(true),
        new FieldSchema("name", FieldType.TEXT).setIndex(true).setAnalyzer(FieldSchema.Analyzer.MaxWord),
        new FieldSchema("pos", FieldType.GEO_POINT).setIndex(true).setEnableSortAndAgg(true),
        new FieldSchema("score", FieldType.DOUBLE).setIndex(true).setEnableSortAndAgg(true),
        new FieldSchema("avg_price", FieldType.DOUBLE).setIndex(true).setEnableSortAndAgg(true)
    ));

    request.setIndexSchema(schema);
    client.createSearchIndex(request);
}

Python

# Create a data table
def create_table(client):
    table_meta = tablestore.TableMeta("shop", [("_id", "STRING")])
    table_options = tablestore.TableOptions(time_to_live=-1, max_version=1)
    reserved = tablestore.ReservedThroughput(tablestore.CapacityUnit(0, 0))
    client.create_table(table_meta, table_options, reserved)

# Create a search index
def create_search_index(client):
    fields = [
        tablestore.FieldSchema("shop_id", tablestore.FieldType.KEYWORD, index=True, enable_sort_and_agg=True),
        tablestore.FieldSchema("type", tablestore.FieldType.KEYWORD, index=True, enable_sort_and_agg=True),
        tablestore.FieldSchema("name", tablestore.FieldType.TEXT, index=True, analyzer="max_word"),
        tablestore.FieldSchema("pos", tablestore.FieldType.GEOPOINT, index=True, enable_sort_and_agg=True),
        tablestore.FieldSchema("score", tablestore.FieldType.DOUBLE, index=True, enable_sort_and_agg=True),
        tablestore.FieldSchema("avg_price", tablestore.FieldType.DOUBLE, index=True, enable_sort_and_agg=True),
    ]
    client.create_search_index("shop", "shop_index", tablestore.SearchIndexMeta(fields))

Step 2: Write test data

The following code uses BatchWriteRow to write 10,000 store records covering five store types. The geographic locations are distributed within the Hangzhou urban area (latitude 30.1° to 30.4°, longitude 120.0° to 120.3°). Modify the TOTAL_ROWS constant to adjust the data volume.

Java

private static final int TOTAL_ROWS = 10000;
private static final String[] TYPES = {"Milk Tea", "Coffee", "Hot Pot", "Japanese Food", "Bakery"};
private static final String[] PREFIXES = {"John's", "Jane's", "Bob's", "Alice's", "Fresh"};
private static final String[] SUFFIXES = {"Tea Shop", "Coffee House", "Hot Pot Place", "Cuisine House", "Bread House"};

private static void writeTestData(SyncClient client) throws Exception {
    Random rand = new Random(42);
    int written = 0;

    while (written < TOTAL_ROWS) {
        BatchWriteRowRequest batch = new BatchWriteRowRequest();
        int batchSize = Math.min(200, TOTAL_ROWS - written);

        for (int i = 0; i < batchSize; i++) {
            int idx = written + i;
            String shopId = String.format("shop-%06d", idx);
            String id = md5(shopId);
            String type = TYPES[rand.nextInt(TYPES.length)];
            String name = PREFIXES[rand.nextInt(PREFIXES.length)] + SUFFIXES[rand.nextInt(SUFFIXES.length)];

            // Random location within the Hangzhou urban area
            double lat = 30.1 + rand.nextDouble() * 0.3;
            double lon = 120.0 + rand.nextDouble() * 0.3;
            // GEO_POINT format: "latitude,longitude"
            String pos = String.format("%.6f,%.6f", lat, lon);
            double score = Math.round((3.0 + rand.nextDouble() * 2.0) * 10.0) / 10.0;
            double avgPrice = 10 + rand.nextInt(191);

            PrimaryKeyBuilder pkb = PrimaryKeyBuilder.createPrimaryKeyBuilder();
            pkb.addPrimaryKeyColumn("_id", PrimaryKeyValue.fromString(id));
            RowPutChange row = new RowPutChange("shop", pkb.build());
            row.addColumn("shop_id", ColumnValue.fromString(shopId));
            row.addColumn("type", ColumnValue.fromString(type));
            row.addColumn("name", ColumnValue.fromString(name));
            row.addColumn("pos", ColumnValue.fromString(pos));
            row.addColumn("score", ColumnValue.fromDouble(score));
            row.addColumn("avg_price", ColumnValue.fromDouble(avgPrice));
            batch.addRowChange(row);
        }
        client.batchWriteRow(batch);
        written += batchSize;
    }
    System.out.println("Successfully wrote " + TOTAL_ROWS + " rows of data.");
}

Python

import hashlib, random

TOTAL_ROWS = 10000
TYPES = ["Milk Tea", "Coffee", "Hot Pot", "Japanese Food", "Bakery"]
PREFIXES = ["John's", "Jane's", "Bob's", "Alice's", "Fresh"]
SUFFIXES = ["Tea Shop", "Coffee House", "Hot Pot Place", "Cuisine House", "Bread House"]

def write_test_data(client):
    rand = random.Random(42)
    written = 0

    while written < TOTAL_ROWS:
        batch_size = min(200, TOTAL_ROWS - written)
        put_rows = []

        for i in range(batch_size):
            idx = written + i
            shop_id = f"shop-{idx:06d}"
            _id = hashlib.md5(shop_id.encode()).hexdigest()
            shop_type = rand.choice(TYPES)
            name = rand.choice(PREFIXES) + rand.choice(SUFFIXES)

            lat = 30.1 + rand.random() * 0.3
            lon = 120.0 + rand.random() * 0.3
            pos = f"{lat:.6f},{lon:.6f}"
            score = round(3.0 + rand.random() * 2.0, 1)
            avg_price = float(10 + rand.randint(0, 190))

            pk = [("_id", _id)]
            cols = [
                ("shop_id", shop_id), ("type", shop_type), ("name", name),
                ("pos", pos), ("score", score), ("avg_price", avg_price),
            ]
            row = tablestore.Row(pk, cols)
            put_rows.append(tablestore.PutRowItem(row,
                tablestore.Condition(tablestore.RowExistenceExpectation.IGNORE)))

        request = tablestore.BatchWriteRowRequest()
        request.add(tablestore.TableInBatchWriteRowItem("shop", put_rows))
        client.batch_write_row(request)
        written += batch_size

    print(f"Successfully wrote {TOTAL_ROWS} rows of data.")

Step 3: Query data

After data is written, the search index syncs automatically, typically within seconds. The following examples cover three common store search use cases.

Use case 1: Search for nearby stores of a specific type

This use case combines GeoDistanceQuery (geo-distance query), TermQuery (exact match on store type), and RangeQuery (upper limit on average spending) to find bubble tea stores within 5 km of a specified location with average spending no more than CNY 50, sorted by rating in descending order.

Java

public static void queryNearbyShops(SyncClient client) {
    // Geo-distance query: within 5 km of the specified center point
    GeoDistanceQuery geoQuery = new GeoDistanceQuery();
    geoQuery.setFieldName("pos");
    geoQuery.setCenterPoint("30.270000,120.150000");
    geoQuery.setDistanceInMeter(5000);

    // Exact match on shop type
    TermQuery typeQuery = new TermQuery();
    typeQuery.setFieldName("type");
    typeQuery.setTerm(ColumnValue.fromString("Milk Tea"));

    // Average price per person no more than 50 CNY
    RangeQuery priceQuery = new RangeQuery();
    priceQuery.setFieldName("avg_price");
    priceQuery.lessThanOrEqual(ColumnValue.fromDouble(50));

    // Boolean query
    BoolQuery boolQuery = new BoolQuery();
    boolQuery.setMustQueries(Arrays.asList(geoQuery, typeQuery, priceQuery));

    SearchQuery searchQuery = new SearchQuery();
    searchQuery.setQuery(boolQuery);
    searchQuery.setGetTotalCount(true);
    searchQuery.setLimit(5);
    searchQuery.setSort(new Sort(Arrays.asList(new FieldSort("score", SortOrder.DESC))));

    SearchRequest searchRequest = new SearchRequest("shop", "shop_index", searchQuery);
    SearchRequest.ColumnsToGet columnsToGet = new SearchRequest.ColumnsToGet();
    columnsToGet.setReturnAll(true);
    searchRequest.setColumnsToGet(columnsToGet);

    SearchResponse response = client.search(searchRequest);
    System.out.println("Total matches: " + response.getTotalCount());
    for (Row row : response.getRows()) {
        System.out.println(row);
    }
}

Python

def query_nearby_shops(client):
    bool_query = tablestore.BoolQuery(must_queries=[
        tablestore.GeoDistanceQuery("pos", "30.270000,120.150000", 5000),
        tablestore.TermQuery("type", "bubble tea"),
        tablestore.RangeQuery("avg_price", range_to=50, include_upper=True),
    ])

    result = client.search("shop", "shop_index",
        tablestore.SearchQuery(bool_query, get_total_count=True, limit=5,
            sort=tablestore.Sort(sorters=[tablestore.FieldSort("score", tablestore.SortOrder.DESC)])),
        tablestore.ColumnsToGet(return_type=tablestore.ColumnReturnType.ALL))

    print(f"Total matches: {result.total_count}")
    for row in result.rows:
        print(row)

Use case 2: Search for stores by name

Use MatchQuery for a full-text search on the TEXT-type name field, with results sorted by rating in descending order.

Java

public static void queryByName(SyncClient client) {
    MatchQuery nameQuery = new MatchQuery();
    nameQuery.setFieldName("name");
    nameQuery.setText("Shen's");

    SearchQuery searchQuery = new SearchQuery();
    searchQuery.setQuery(nameQuery);
    searchQuery.setGetTotalCount(true);
    searchQuery.setLimit(5);
    searchQuery.setSort(new Sort(Arrays.asList(new FieldSort("score", SortOrder.DESC))));

    SearchRequest searchRequest = new SearchRequest("shop", "shop_index", searchQuery);
    SearchRequest.ColumnsToGet columnsToGet = new SearchRequest.ColumnsToGet();
    columnsToGet.setReturnAll(true);
    searchRequest.setColumnsToGet(columnsToGet);

    SearchResponse response = client.search(searchRequest);
    System.out.println("Total matches: " + response.getTotalCount());
    for (Row row : response.getRows()) {
        System.out.println(row);
    }
}

Python

def query_by_name(client):
    result = client.search("shop", "shop_index",
        tablestore.SearchQuery(tablestore.MatchQuery("name", "Shen's"),
            get_total_count=True, limit=5,
            sort=tablestore.Sort(sorters=[tablestore.FieldSort("score", tablestore.SortOrder.DESC)])),
        tablestore.ColumnsToGet(return_type=tablestore.ColumnReturnType.ALL))

    print(f"Total matches: {result.total_count}")
    for row in result.rows:
        print(row)

Use case 3: Aggregate statistics by store type

Use GroupByField to group stores by type, with Count and Avg sub-aggregations to calculate the number of stores and average rating for each type.

Java

public static void groupByType(SyncClient client) {
    SearchRequest searchRequest = SearchRequest.newBuilder()
        .tableName("shop").indexName("shop_index")
        .searchQuery(SearchQuery.newBuilder()
            .query(QueryBuilders.matchAll())
            .limit(0)
            .addGroupBy(GroupByBuilders.groupByField("shopType", "type")
                .size(20)
                .addSubAggregation(AggregationBuilders.count("shopCount", "shop_id"))
                .addSubAggregation(AggregationBuilders.avg("avgScore", "score"))
                .addGroupBySorter(GroupBySorter.subAggSortInDesc("shopCount"))
                .build())
            .build())
        .build();

    SearchResponse resp = client.search(searchRequest);
    GroupByFieldResult results = resp.getGroupByResults().getAsGroupByFieldResult("shopType");
    for (GroupByFieldResultItem item : results.getGroupByFieldResultItems()) {
        System.out.println("Type: " + item.getKey()
            + "\tShop count: " + item.getSubAggregationResults().getAsCountAggregationResult("shopCount").getValue()
            + "\tAvg score: " + String.format("%.1f",
                item.getSubAggregationResults().getAsAvgAggregationResult("avgScore").getValue()));
    }
}

Python

def group_by_type(client):
    group_by = tablestore.GroupByField("type", size=20,
        sub_aggs=[
            tablestore.Count("shop_id", name="shopCount"),
            tablestore.Avg("score", name="avgScore"),
        ],
        group_by_sort=[tablestore.SubAggSort(tablestore.SortOrder.DESC, "shopCount")],
        name="shopType")

    result = client.search("shop", "shop_index",
        tablestore.SearchQuery(tablestore.MatchAllQuery(), limit=0, group_bys=[group_by]),
        tablestore.ColumnsToGet(return_type=tablestore.ColumnReturnType.NONE))

    for item in result.group_by_results[0].items:
        aggs = {a.name: a.value for a in item.sub_aggs}
        print(f"Type: {item.key}\tStore count: {int(aggs['shopCount'])}\tAverage rating: {aggs['avgScore']:.1f}")

Clean up resources

Note

If you no longer need the resources created in this solution, delete them to avoid unnecessary charges.

Delete the search index first, then the data table. If you created the instance specifically for this solution, release the instance last.

Java

private static void cleanup(SyncClient client) {
    // 1. Delete the search index
    DeleteSearchIndexRequest deleteIndexReq = new DeleteSearchIndexRequest();
    deleteIndexReq.setTableName("shop");
    deleteIndexReq.setIndexName("shop_index");
    client.deleteSearchIndex(deleteIndexReq);

    // 2. Delete the data table
    client.deleteTable(new DeleteTableRequest("shop"));
}

Python

def cleanup(client):
    # 1. Delete the search index
    client.delete_search_index("shop", "shop_index")
    # 2. Delete the data table
    client.delete_table("shop")