Download Spec — Machine-readable spec artifact for this endpoint.
Concepts covered: autocomplete-patterns prefix-matching sai-text-search type-ahead-search

GET /api/v1/search/tags/suggest - Tag Autocomplete

Overview

This endpoint provides type-ahead suggestions for tag names as the user types. When a user types "cas" in a tag input field, they should see suggestions like "cassandra", "cassandra5", "caching". It is backed by SAI text search or a dedicated tags collection, enabling fast prefix matching against the corpus of known tags.

Why it exists: Users can't effectively browse or search by tags they don't know exist. Type-ahead suggestions lower the barrier to tag discovery and improve tag quality by guiding users toward established tags rather than creating many near-duplicate variants.

HTTP Details

  • Method: GET
  • Path: /api/v1/search/tags/suggest
  • Auth Required: No (public endpoint)
  • Success Status: 200 OK

Query Parameters

Parameter Type Required Default Constraints
query string Yes Minimum 1 character
limit integer No 10 Maximum 25

Request

GET /api/v1/search/tags/suggest?query=cas&limit=10

Response Body

[
  { "tag": "cassandra" },
  { "tag": "cassandra5" },
  { "tag": "caching" },
  { "tag": "case-studies" }
]

The array is ordered by relevance or alphabetically, with the most commonly used tags first.

Cassandra Concepts Explained

The Autocomplete Challenge

Autocomplete requires finding all tags that start with the user's query string. In SQL this is:

SELECT DISTINCT tag FROM tags WHERE tag LIKE 'cas%' ORDER BY usage_count DESC LIMIT 10;

In Cassandra, LIKE queries are not supported out of the box. We need a different strategy.

Maintain a separate table of all unique tags, and use SAI's MATCHES or BM25 text search capability:

CREATE TABLE killrvideo.tags (
    tag      text PRIMARY KEY,
    usage_count counter
);

-- SAI text index for prefix/substring matching
CREATE CUSTOM INDEX tags_text_idx
ON killrvideo.tags(tag)
USING 'StorageAttachedIndex'
WITH OPTIONS = {'index_analyzer': 'standard'};

Query:

SELECT tag FROM killrvideo.tags
WHERE tag : 'cas*'  -- BM25 / text search prefix
LIMIT 10;

Advantage: Dedicated table with usage counts enables sorting by popularity.

Approach 2: In-Memory or Redis Tag Set

Because the total number of unique tags is bounded (perhaps tens of thousands), a simple approach is to:

  1. Maintain a Redis sorted set: ZADD tags <usage_count> "cassandra"
  2. On autocomplete request: use Redis ZRANGEBYLEX for prefix matching
ZRANGEBYLEX tags "[cas" "[cas\xff" LIMIT 0 10

This returns all members between "cas" and "cas" + any suffix — effectively a prefix search.

Advantage: Extremely fast (sub-millisecond), independent of Cassandra load.

Approach 3: Materialized Tags from the videos Table

Tags are stored as set<text> in the videos table. There is no separate tags table maintained by default. Tags suggestions can be generated by:

  1. Fetching all distinct tags from the videos table (expensive full scan)
  2. Or, denormalizing tags into a dedicated table at write time

Trade-off: Full scans are impractical for autocomplete. Denormalization is the right approach for production.

SAI Text Search (Cassandra 5 / Astra DB)

Astra DB supports full-text search capabilities through SAI with text analyzers. The standard analyzer tokenizes text, enables case-insensitive matching, and supports prefix queries:

-- Create table
CREATE TABLE killrvideo.tags (
    tag text PRIMARY KEY,
    usage_count bigint,
    last_used timestamp
);

-- SAI with text analysis
CREATE CUSTOM INDEX tags_text_idx
ON killrvideo.tags(tag)
USING 'StorageAttachedIndex'
WITH OPTIONS = {
    'index_analyzer': '{
        "tokenizer": {"name": "standard"},
        "filters": [{"name": "lowercase"}]
    }'
};

Query with BM25 / text search:

SELECT tag, usage_count
FROM killrvideo.tags
WHERE tag : 'cas'
ORDER BY usage_count DESC
LIMIT 10;

Type-Ahead Search UX Patterns

For a good user experience with type-ahead search:

  1. Debounce client requests: Don't fire a request on every keystroke. Wait 150–300ms after the user stops typing.

  2. Minimum query length: Start suggesting after 1–2 characters (this endpoint requires query length >= 1).

  3. Show results immediately: Display suggestions within 50ms of the debounce threshold.

  4. Cancel in-flight requests: If the user types more characters before the previous request returns, cancel the older request.

// Client-side debounce pattern
let debounceTimer;
tagInput.addEventListener('input', (e) => {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => {
        fetchSuggestions(e.target.value);
    }, 200);
});

Data Model

Table: tags (for dedicated tag autocomplete)

CREATE TABLE killrvideo.tags (
    tag          text PRIMARY KEY,  -- The tag string
    usage_count  bigint,            -- Number of videos with this tag
    last_used    timestamp          -- When last applied to a video
);

-- SAI text index for prefix matching
CREATE CUSTOM INDEX tags_tag_text_idx
ON killrvideo.tags(tag)
USING 'StorageAttachedIndex'
WITH OPTIONS = {'index_analyzer': 'standard'};

-- SAI index on usage_count for popularity sorting
CREATE CUSTOM INDEX tags_usage_idx
ON killrvideo.tags(usage_count)
USING 'StorageAttachedIndex';

How Tags Table Is Maintained

Every time a video is saved with tags, the tags table is updated:

-- When tag is used:
UPDATE killrvideo.tags
SET usage_count = usage_count + 1,
    last_used = '2025-10-31T10:30:00Z'
WHERE tag = 'cassandra';

-- Or insert if new:
INSERT INTO killrvideo.tags (tag, usage_count, last_used)
VALUES ('cassandra', 1, '2025-10-31T10:30:00Z')
IF NOT EXISTS;

Database Queries

Query: Find Tags Matching Prefix

With SAI text search:

SELECT tag, usage_count
FROM killrvideo.tags
WHERE tag : 'cas'
ORDER BY usage_count DESC
LIMIT 10;

Equivalent with simple prefix filter (if text search unavailable):

-- Less efficient: requires ALLOW FILTERING
SELECT tag FROM killrvideo.tags
WHERE tag >= 'cas' AND tag < 'cat'
LIMIT 10
ALLOW FILTERING;

Alternative: fetch all and filter in application:

all_tags = await get_all_tags()  # Cached in memory
matching = [t for t in all_tags if t.tag.startswith(query.lower())]
return sorted(matching, key=lambda t: t.usage_count, reverse=True)[:limit]

The application-layer filter works well when the total tag count is small (< 10,000).

Implementation Flow

┌──────────────────────────────────────────────────────────┐
│ 1. Client sends GET /api/v1/search/tags/suggest          │
│    ?query=cas&limit=10                                   │
└────────────────────┬─────────────────────────────────────┘
                     │
                     ▼
┌──────────────────────────────────────────────────────────┐
│ 2. Validate parameters                                   │
│    ├─ query present and length >= 1? → proceed           │
│    ├─ limit in [1..25]? → proceed                        │
│    └─ Invalid? → 422 Validation Error                    │
└────────────────────┬─────────────────────────────────────┘
                     │
                     ▼
┌──────────────────────────────────────────────────────────┐
│ 3. Normalize query to lowercase                          │
│    "CAS" → "cas"                                         │
└────────────────────┬─────────────────────────────────────┘
                     │
                     ▼
┌──────────────────────────────────────────────────────────┐
│ 4. Query tags WHERE tag STARTS WITH query                │
│    ORDER BY usage_count DESC                             │
│    LIMIT limit                                           │
└────────────────────┬─────────────────────────────────────┘
                     │
                     ▼
┌──────────────────────────────────────────────────────────┐
│ 5. Map to TagSuggestion array [{ "tag": "cassandra" }]   │
└────────────────────┬─────────────────────────────────────┘
                     │
                     ▼
┌──────────────────────────────────────────────────────────┐
│ 6. Return 200 OK with array                              │
└──────────────────────────────────────────────────────────┘

Expected latency: 5–20ms (or < 1ms with in-memory cache)

Special Notes

1. Empty Query Returns Error

The query parameter is required and must be at least 1 character. An empty string or missing query should return a 422 Validation Error, not an empty results array.

2. No Results Is Valid

If no tags match the prefix, return an empty array — not a 404:

[]

3. Case Normalization

Tags are stored lowercase. Normalize the query before searching:

  • "CAS" → "cas"
  • "Cassandra" → "cassandra"

This ensures GET /suggest?query=CAS returns the same results as GET /suggest?query=cas.

4. Response Is an Array, Not Paginated

Unlike most list endpoints, this returns a bare array (not wrapped in a PaginatedResponse). The maximum limit of 25 is low enough that pagination is unnecessary.

5. Usage Count Is Optional in Response

The response includes only { "tag": "..." } — the usage count is an internal ranking signal, not exposed to the client. This keeps the autocomplete response minimal.

6. Punctuation and Special Characters

Tags should not contain special characters. The suggestion engine may need to strip punctuation from the query string:

clean_query = re.sub(r'[^a-z0-9-]', '', query.lower())

If after normalization the query is empty, return an empty array.

Developer Tips

Common Pitfalls

  1. No debouncing: Firing a request on every keystroke (without debouncing) can overwhelm the server. Implement debouncing on the client side.

  2. Missing lowercase normalization: "CAS" won't match "cassandra" without lowercasing.

  3. No minimum length enforcement: Returning all tags for a single space character is wasteful.

  4. Forgetting to populate the tags table: If you don't write to a tags table when videos are saved, there's nothing to search.

  5. Returning all fields in autocomplete: Keep the response minimal — just { "tag": string }. Autocomplete dropdowns need tag names, not full metadata.

Best Practices

  1. Cache the tag list in memory: If total tags are < 100,000, load them all into an in-process sorted list at startup. Autocomplete becomes a binary search — sub-millisecond.

  2. Warm the cache on startup: Don't cold-start with an empty cache. Load tags during server initialization.

  3. Update the cache incrementally: When a new tag is used on a video, add it to the in-memory structure without reloading everything.

  4. Expose popular tags in a separate endpoint: A GET /search/tags/popular?limit=20 endpoint to show popular tags without typing is a useful companion.

  5. Implement client-side caching: The browser can cache autocomplete results per-prefix with a short TTL.

Performance Expectations

Approach Latency Notes
In-memory prefix search < 1ms Best for small-medium tag sets
Redis ZRANGEBYLEX 1–2ms Good for larger tag sets
SAI text search (Cassandra) 5–20ms Good for very large tag sets
Cached HTTP response < 1ms Browser/CDN caching

Further Learning