Skip to main content

Search UX Patterns

Most user-facing search combines two things:

  • Structured filtering — only show results matching the user's explicit constraints (role, date range, status)
  • Relevance ranking — sort by how closely each result matches the user's intent in natural language

RushDB provides both surfaces: records.find() for structured queries and ai.search() for semantic ranking. The patterns below show how to combine them, surface enough context for explainable results, and paginate reliably.


For search over categorical or exact fields, records.find() with a where clause is immediate.

from rushdb import RushDB
import os

db = RushDB(os.environ["RUSHDB_API_KEY"], base_url="https://api.rushdb.com/api/v1")

def search_articles(params: dict) -> dict:
where = {"status": "published"}

if params.get("category"):
where["category"] = params["category"]
if params.get("authorId"):
where["AUTHOR"] = {
"$relation": {"type": "AUTHORED_BY", "direction": "out"},
"authorId": params["authorId"]
}
if params.get("publishedAfter"):
where["publishedAt"] = {"$gte": params["publishedAfter"]}

result = db.records.find({
"labels": ["ARTICLE"],
"where": where,
"orderBy": {"publishedAt": "desc"},
"limit": params.get("limit", 20),
"skip": params.get("skip", 0)
})

return {
"items": [{"id": a.id, "title": a.get("title"), "category": a.get("category")} for a in result.data],
"total": result.total
}

Pattern 2: Semantic search with structured post-filter

Use ai.search() when the user types a free-text query. The where clause filters before semantic ranking, so only records matching all structural constraints are scored.

def semantic_article_search(params: dict) -> list[dict]:
search_params = {
"query": params["query"],
"propertyName": "content",
"labels": ["ARTICLE"],
"limit": params.get("limit", 10)
}

where = {"status": "published"}
if params.get("category"):
where["category"] = params["category"]
if params.get("publishedAfter"):
where["publishedAt"] = {"$gte": params["publishedAfter"]}
if len(where) > 1:
search_params["where"] = where

result = db.ai.search(search_params)

return [
{"id": a.id, "title": a.get("title"), "score": a.score, "category": a.get("category")}
for a in result.data
]

Pattern 3: Contextual result enrichment

Raw records often lack display context. Enrich results with related data after retrieval — author name, category breadcrumb, or related tags.

from concurrent.futures import ThreadPoolExecutor

def enriched_search(user_query: str) -> list[dict]:
results = db.ai.search({
"query": user_query,
"propertyName": "content",
"labels": ["ARTICLE"],
"limit": 10
})

def enrich(article):
author_result = db.records.find({
"labels": ["AUTHOR"],
"where": {
"ARTICLE": {
"$relation": {"type": "AUTHORED_BY", "direction": "in"},
"__id": article.id
}
}
})
return {
"id": article.id,
"title": article.get("title"),
"score": article.score,
"author": author_result.data[0].get("name") if author_result.data else None
}

with ThreadPoolExecutor(max_workers=5) as pool:
return list(pool.map(enrich, results.data))
Enrichment at result time vs query time

Per-result enrichment (a sub-query per item) multiplies network calls. For 10 results, that is 10 additional queries. Prefer storing denormalized display fields on the primary record where possible, and reserve traversal enrichment for fields that must be kept in sync with the related record.


Pattern 4: Pagination with stable ordering

Pagination is only stable when results are ordered by an immutable field. Avoid paginating over semantic search — scores change with model updates. Paginate structured queries instead.

def paginated_articles(category: str, page: int, page_size: int = 20) -> dict:
result = db.records.find({
"labels": ["ARTICLE"],
"where": {"status": "published", "category": category},
"orderBy": {"publishedAt": "desc"},
"limit": page_size,
"skip": page * page_size
})

return {
"items": [{"id": a.id, "title": a.get("title")} for a in result.data],
"total": result.total,
"hasNext": (page + 1) * page_size < result.total
}

Pattern 5: Zero-results fallback with semantic widening

If a structured query returns nothing, fall back to semantic search without the structural constraints — then surface a "Did you mean?" style hint.

def search_with_fallback(query: str, category: str) -> dict:
structured = db.records.find({
"labels": ["ARTICLE"],
"where": {"status": "published", "category": category, "title": {"$contains": query}},
"limit": 10
})

if structured.total > 0:
return {"results": structured.data, "mode": "exact"}

semantic = db.ai.search({
"query": query,
"propertyName": "content",
"labels": ["ARTICLE"],
"where": {"status": "published"},
"limit": 5
})

return {"results": semantic.data, "mode": "semantic"}

SituationUse
User selects filters from UI facetsrecords.find() with where
User types a free-text query boxai.search()
User types AND applies filtersai.search() with where
Result count matters (pagination)records.find()ai.search() does not return total
Query is empty / filters onlyrecords.find() — skip semantic scoring
Exact match requiredrecords.find()

Next steps