Faceted Search

search

Definition

Faceted search (also known as faceted navigation or guided navigation) is a search and filtering technique that allows users to narrow search results by applying multiple independent filters (facets) simultaneously. Each facet represents a dimension of the data -- such as category, date range, author, status, or geographic region -- and displays the available values along with the count of matching results for each value. This combination of full-text search with multi-dimensional filtering provides a powerful exploration interface for large, heterogeneous datasets.

The key insight of faceted search is that users often do not know the exact query to type but do know the characteristics of what they are looking for. Rather than requiring users to formulate precise search queries, faceted search presents the structure of available data and lets users progressively refine results by selecting relevant attributes. This approach reduces the cognitive load of searching and virtually eliminates "zero results" scenarios by showing only filter values that produce results.

Faceted search originated in library science and information architecture, where faceted classification systems (developed by S.R. Ranganathan in the 1930s) organize knowledge along multiple independent dimensions rather than a single hierarchy. The technique was popularized in e-commerce (Amazon, eBay) and is now standard in any application that exposes large catalogs to users, including search engines, document management systems, and OSINT platforms.

The mathematical foundation of faceted search rests on set theory and Boolean algebra. Each facet filter defines a set of matching documents; combining facets computes the intersection (AND) or union (OR) of these sets. The challenge lies in computing these set operations efficiently while simultaneously counting remaining facet values -- a problem that modern search engines solve through specialized data structures like inverted indexes, bitmap indexes, and columnar doc-value stores.

Core Concepts

ConceptDescriptionRole in Faceted Search
FacetA single dimension of data used for filtering (e.g., "category", "date")Primary filtering axis
Facet ValueA specific option within a facet (e.g., "Security" within "category")Individual filter selection
Facet CountThe number of documents matching a specific facet valueGuides user decisions, prevents dead ends
Conjunctive Filter (AND)All selected facets must match simultaneouslyCross-facet narrowing
Disjunctive Filter (OR)Any selected value within a facet matchesWithin-facet broadening
Hierarchical FacetNested facet with parent-child relationships (e.g., Region > Country > City)Multi-level drill-down
Facet DistributionThe complete set of values and counts for a facetDrives UI rendering
Post-Filter CountFacet count computed after applying other facetsShows remaining possibilities
Facet IsolationCounting a facet's values without applying that facet's own filterEnables multi-select within facet
Dynamic FacetingAutomatically discovering facets from data schemaReduces manual configuration

Facet Types

Facet TypeDescriptionExampleUI PatternMeilisearch Support
Value facetEnumerated values with countsCategory: Security (45), OSINT (38)Checkbox listNative
Range facetNumeric or date rangesDate: Last 7 days, Last 30 daysSlider or radio buttonsFilter expressions
Hierarchical facetNested categoriesLocation > Europe > Czech RepublicTree navigationCustom implementation
Boolean facetBinary toggle"Has API key required"Toggle switchFilter expressions
Tag facetMulti-value attributeTags: ["api", "free", "real-time"]Tag cloudNative (array fields)
Numeric facetContinuous numeric values bucketed into rangesScore: 0-25, 26-50, 51-75, 76-100Histogram / sliderFilter expressions
Date facetTemporal bucketingCreated: This week, This month, This yearCalendar / radioFilter expressions

Filtering Logic

LogicBehaviorSQL EquivalentUse CasePerformance Impact
Conjunctive (AND)All selected facets must matchWHERE a AND bCross-facet filteringLinear in facet count
Disjunctive (OR)Any selected value within facet matchesWHERE a OR bWithin-facet multi-selectRequires union computation
MixedAND between facets, OR within facetsWHERE (a1 OR a2) AND (b1 OR b2)Standard e-commerce patternMost common, well-optimized
NegationExclude specific facet valuesWHERE NOT aExclusion filteringRequires complement set
NestedHierarchical AND/OR combinationsWHERE (a AND (b1 OR b2)) OR cAdvanced filteringQuery complexity scales

Technical Deep Dive

Architecture Layers

Faceted search architecture consists of three layers: the indexing layer that extracts and stores facet values during document ingestion, the query layer that combines full-text search with facet filtering, and the presentation layer that renders facet values with counts in the user interface.

Indexing Layer: The indexing layer must identify which document fields serve as facets and ensure they are stored in structures that support efficient counting and filtering. Search engines like Meilisearch and Elasticsearch use inverted indexes for full-text search and columnar doc-value stores for facet operations. The doc-value store enables O(1) access to a document's facet values, while posting lists enable efficient counting of documents matching each facet value.

Query Layer: The query layer processes a combined query consisting of a text query (optional) and zero or more facet filters. The engine first evaluates the text query to produce a candidate result set, then applies facet filters to narrow results, and finally computes facet counts over the filtered result set. Facet counts must be computed considering all filters except the current facet (to enable multi-select within a facet without reducing that facet's own visible options).

Presentation Layer: The presentation layer renders facet values with their counts, sorted by relevance or count. It must handle dynamic updates as users select facets, showing updated counts for all other facets. In LiveView applications, this update happens server-side with minimal latency.

Meilisearch Faceted Search Internals

Meilisearch implements faceted search through a combination of its core search engine and a specialized facet computation pipeline:

ComponentFunctionPerformance Characteristic
Filterable AttributesFields declared as filterable in index settingsO(1) lookup per document
Sortable AttributesFields available for result orderingPre-computed sort indexes
Facet Distribution APIReturns value counts for specified facetsSub-10ms for typical datasets
Filter ExpressionsSQL-like syntax for combining facet conditionsParsed and optimized at query time
Ranking RulesConfigurable relevance scoring with facet awarenessAffects result ordering, not facet counts

Performance Considerations

TechniquePurposeImpactImplementation
Pre-computed facet countsAvoid full recalculation on each query~10x faster facet renderingCached in ETS after first computation
Facet cachingCache facet values for common queriesSub-millisecond facet displayHierarchicalCache (ETS > Cachex > Meilisearch)
Partial facet refreshOnly recount affected facets on filter changeSmoother UX, less computationSelective invalidation on filter events
Facet samplingApproximate counts for very large datasetsTrades accuracy for speedConfigurable sampling threshold
Filtered facet countsShow counts considering current filtersPrevents selecting zero-result combinationsComputed in query layer
Batch facet requestsCombine multiple facet queries into single requestReduces network overheadBulkOperations utility
Facet result limitsCap maximum facet values returnedPrevents memory bloat on high-cardinality facetsConfigurable per-facet limit

Cardinality Management

High-cardinality facets (facets with many unique values, such as "author" in a large document collection) present specific challenges:

Cardinality LevelExampleChallengeStrategy
Low (< 20)Category, StatusNo issuesDisplay all values
Medium (20-100)Country, YearUI clutterShow top N with "Show more"
High (100-10K)Author, CompanyPerformance, UXSearch-within-facet, lazy loading
Very High (10K+)Tag, KeywordMemory, latencySampling, aggregation, type-ahead

Usage in Prismatic Platform

The Prismatic Platform implements faceted search across three primary domains: the OSINT toolbox (filtering 157 tools by category, API style, authentication requirements), the DD entity browser (filtering entities by type, jurisdiction, risk level), and the Academy topic dashboard (filtering topics by domain, difficulty, prerequisites).

OSINT Toolbox Faceted Interface

The OSINT toolbox LiveView at /hub/osint/tools provides a faceted interface with category chips (Czech, Global, Sanctions, EU, UK, US, Universal), API style filters, and authentication requirement toggles. As users select facets, the UI updates in real-time via LiveView's server-side rendering, with Meilisearch providing sub-10ms faceted search responses.

FacetTypeValuesUI Pattern
CategoryValueCzech, Global, Sanctions, EU, UK, US, UniversalChip/pill selector
API StyleValueREST, GraphQL, SOAP, Scraping, ProviderCheckbox list
Requires AuthBooleanYes, NoToggle switch
Input TypeValueDomain, IP, Email, Hash, URL, TextMulti-select
StatusValueActive, Deprecated, BetaRadio buttons

DD Entity Browser Facets

FacetTypeValuesPurpose
Entity TypeValuePerson, Company, Domain, AssetPrimary classification
JurisdictionHierarchicalRegion > Country > CityGeographic filtering
Risk LevelRangeLow, Medium, High, CriticalRisk-based prioritization
Case AssociationValueCase IDsCross-reference filtering
Last UpdatedDateTime rangesFreshness filtering

Code Examples

defmodule Prismatic.Search.FacetedQuery do
  @moduledoc """
  Faceted search query builder for Meilisearch integration.
  Supports the OSINT toolbox, DD entity browser, and
  Academy topic discovery with multi-dimensional filtering.

  Implements the mixed filtering logic pattern: AND between
  facets, OR within facets. Supports all facet types including
  value, range, boolean, hierarchical, and tag facets.
  """

  require Logger

  @type facet_filter :: {String.t(), list(String.t())}
  @type facet_config :: %{
    attribute: String.t(),
    type: :value | :range | :boolean | :hierarchical | :tag,
    label: String.t(),
    max_values: pos_integer()
  }

  @type search_params :: %{
    query: String.t(),
    facet_filters: list(facet_filter()),
    facets_to_retrieve: list(String.t()),
    page: pos_integer(),
    per_page: pos_integer()
  }

  @type facet_distribution :: %{String.t() => %{String.t() => non_neg_integer()}}

  @type search_result :: %{
    hits: list(map()),
    facet_distribution: facet_distribution(),
    total_hits: non_neg_integer(),
    processing_time_ms: non_neg_integer()
  }

  @doc """
  Builds a Meilisearch-compatible query map from search parameters.
  Converts facet filters into Meilisearch filter expression syntax
  and includes facet distribution requests.

  ## Examples

      iex> params = %{query: "security", facet_filters: [{"category", ["czech"]}], facets_to_retrieve: ["category"], page: 1, per_page: 20}
      iex> query = Prismatic.Search.FacetedQuery.build_meilisearch_query(params)
      iex> query.q
      "security"

  """
  @spec build_meilisearch_query(search_params()) :: map()
  def build_meilisearch_query(params) do
    %{
      q: params.query,
      facets: params.facets_to_retrieve,
      filter: build_filter_expression(params.facet_filters),
      offset: (params.page - 1) * params.per_page,
      limit: params.per_page
    }
  end

  @doc """
  Converts a list of facet filters into Meilisearch filter expression
  format. Uses mixed logic: AND between different facets, OR within
  the same facet (disjunctive within, conjunctive between).

  ## Examples

      iex> filters = [{"category", ["czech", "global"]}, {"requires_auth", ["true"]}]
      iex> expr = Prismatic.Search.FacetedQuery.build_filter_expression(filters)
      iex> is_list(expr)
      true

  """
  @spec build_filter_expression(list(facet_filter())) :: list(list(String.t()))
  def build_filter_expression(facet_filters) do
    Enum.map(facet_filters, fn {attribute, values} ->
      Enum.map(values, fn value ->
        "#{attribute} = \"#{value}\""
      end)
    end)
  end

  @doc """
  Searches the OSINT toolbox with faceted filtering.
  Supports filtering by category, API style, and auth requirement.
  Results include facet distributions for all configured facets.

  ## Examples

      iex> {:ok, result} = Prismatic.Search.FacetedQuery.search_osint_tools("domain lookup")
      iex> is_map(result)
      true

  """
  @spec search_osint_tools(String.t(), keyword()) :: {:ok, search_result()} | {:error, term()}
  def search_osint_tools(query, opts \\ []) do
    params = %{
      query: query,
      facet_filters: Keyword.get(opts, :filters, []),
      facets_to_retrieve: ["category", "api_style", "requires_auth", "input_type"],
      page: Keyword.get(opts, :page, 1),
      per_page: Keyword.get(opts, :per_page, 20)
    }

    meilisearch_query = build_meilisearch_query(params)

    Logger.info("OSINT faceted search",
      query: query,
      facet_count: length(params.facet_filters),
      page: params.page
    )

    PrismaticStorageMeilisearch.search("osint_tools", meilisearch_query)
  end

  @doc """
  Searches DD entities with multi-dimensional faceted filtering.
  Supports entity type, jurisdiction, risk level, and case association.
  """
  @spec search_dd_entities(String.t(), keyword()) :: {:ok, search_result()} | {:error, term()}
  def search_dd_entities(query, opts \\ []) do
    params = %{
      query: query,
      facet_filters: Keyword.get(opts, :filters, []),
      facets_to_retrieve: ["entity_type", "jurisdiction", "risk_level", "case_id"],
      page: Keyword.get(opts, :page, 1),
      per_page: Keyword.get(opts, :per_page, 20)
    }

    meilisearch_query = build_meilisearch_query(params)
    PrismaticStorageMeilisearch.search("dd_entities", meilisearch_query)
  end
end
defmodule Prismatic.Search.FacetCache do
  @moduledoc """
  ETS-backed cache for facet distributions. Stores computed facet
  counts with configurable TTL to avoid redundant Meilisearch queries.
  Implements the HierarchicalCache pattern: ETS (L1) -> Cachex (L2)
  -> Meilisearch (L3) with automatic promotion on cache miss.
  """

  use GenServer

  require Logger

  @type cache_key :: {String.t(), list(String.t())}
  @type cache_entry :: %{
    distribution: map(),
    computed_at: DateTime.t(),
    ttl_seconds: pos_integer()
  }

  @table_name :facet_cache
  @default_ttl_seconds 60

  @doc """
  Starts the facet cache GenServer and initializes the ETS table.

  ## Examples

      iex> {:ok, pid} = Prismatic.Search.FacetCache.start_link([])
      iex> is_pid(pid)
      true

  """
  @spec start_link(keyword()) :: GenServer.on_start()
  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @doc """
  Retrieves cached facet distribution or computes and caches it.
  Returns the facet distribution map with value counts.
  """
  @spec get_or_compute(String.t(), list(String.t()), (-> map())) :: {:ok, map()}
  def get_or_compute(index, facets, compute_fn) do
    key = {index, Enum.sort(facets)}

    case lookup(key) do
      {:hit, distribution} ->
        Logger.debug("Facet cache hit", index: index)
        {:ok, distribution}

      :miss ->
        distribution = compute_fn.()
        store(key, distribution)
        Logger.debug("Facet cache miss, computed", index: index)
        {:ok, distribution}
    end
  end

  @impl GenServer
  def init(_opts) do
    table = :ets.new(@table_name, [:named_table, :set, :public, read_concurrency: true])
    schedule_cleanup()
    {:ok, %{table: table}}
  end

  @impl GenServer
  def handle_info(:cleanup, state) do
    now = DateTime.utc_now()

    :ets.foldl(
      fn {key, entry}, acc ->
        age = DateTime.diff(now, entry.computed_at)

        if age > entry.ttl_seconds do
          :ets.delete(@table_name, key)
        end

        acc
      end,
      :ok,
      @table_name
    )

    schedule_cleanup()
    {:noreply, state}
  end

  @spec lookup(cache_key()) :: {:hit, map()} | :miss
  defp lookup(key) do
    case :ets.lookup(@table_name, key) do
      [{^key, entry}] ->
        age = DateTime.diff(DateTime.utc_now(), entry.computed_at)

        if age <= entry.ttl_seconds do
          {:hit, entry.distribution}
        else
          :ets.delete(@table_name, key)
          :miss
        end

      [] ->
        :miss
    end
  end

  @spec store(cache_key(), map()) :: true
  defp store(key, distribution) do
    entry = %{
      distribution: distribution,
      computed_at: DateTime.utc_now(),
      ttl_seconds: @default_ttl_seconds
    }

    :ets.insert(@table_name, {key, entry})
  end

  @spec schedule_cleanup() :: reference()
  defp schedule_cleanup do
    Process.send_after(self(), :cleanup, 30_000)
  end
end
defmodule PrismaticWebWeb.OsintToolboxLive.FacetComponent do
  @moduledoc """
  LiveView component rendering facet filters for the OSINT toolbox.
  Handles facet selection, count display, and real-time updates
  as users interact with filter controls. Uses PubSub for
  cross-component communication when facets change.
  """

  use PrismaticWebWeb, :live_component

  @doc """
  Renders a single facet group with its values and counts.
  Supports value, boolean, and tag facet types with appropriate
  UI patterns for each.
  """
  @spec render(map()) :: Phoenix.LiveView.Rendered.t()
  def render(assigns) do
    ~H"""
    <div class="mb-4">
      <h4 class="text-sm font-semibold text-gray-300 mb-2"><%= @facet.label %></h4>
      <div class="space-y-1">
        <%= for {value, count} <- @distribution do %>
          <label class="flex items-center justify-between cursor-pointer p-1 rounded hover:bg-gray-700">
            <span class="flex items-center">
              <input
                type="checkbox"
                checked={value in @selected}
                phx-click="toggle_facet"
                phx-value-facet={@facet.attribute}
                phx-value-value={value}
                phx-target={@myself}
                class="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500"
              />
              <span class="ml-2 text-sm text-gray-300"><%= value %></span>
            </span>
            <span class="text-xs text-gray-500">(<%= count %>)</span>
          </label>
        <% end %>
      </div>
    </div>
    """
  end

  @doc false
  def handle_event("toggle_facet", %{"facet" => facet, "value" => value}, socket) do
    send(self(), {:facet_toggled, facet, value})
    {:noreply, socket}
  end
end

Common Pitfalls

PitfallDescriptionConsequencePrevention
Zero-Result Dead EndsShowing facet values that produce empty results when combined with current filtersUser frustration, wasted clicksCompute post-filter counts; hide zero-count values
Over-FacetingExposing too many facets simultaneouslyCognitive overload, slow UILimit to 5-7 primary facets; group secondary facets
High-Cardinality ExplosionUsing a field with thousands of unique values as a facetMemory bloat, slow renderingCap facet values; use search-within-facet for high cardinality
Missing Facet IsolationApplying a facet's own filter when computing its countMulti-select within facet shows only selected valuesExclude current facet from its own count computation
Stale Facet CountsCaching facet distributions too aggressivelyCounts don't match actual resultsTTL-based cache with invalidation on index updates
N+1 Facet QueriesIssuing separate queries for each facetMultiplied latencyRequest all facets in single Meilisearch query
Ignoring URL StateNot reflecting facet selections in the URLFacets lost on page refresh, not shareableEncode facet state in query parameters
Synchronous Facet ComputationBlocking the main thread while computing facet distributionsUI freeze on large datasetsAsync computation with loading indicators
Inconsistent Facet OrderingFacet values jumping around as counts changeDisorienting user experienceStable sort: alphabetical or by original order, not by count
Missing Mobile AdaptationRendering full facet panel on mobile screensUnusable on small screensCollapsible facet panel, bottom sheet on mobile

Best Practices

  1. Use mixed filtering logic (AND between facets, OR within) -- this is the standard e-commerce pattern that provides the most intuitive user experience. Cross-facet AND narrows results while within-facet OR broadens selection.

  2. Always show post-filter counts -- facet values should display counts that reflect currently active filters on other facets. This prevents users from selecting combinations that produce zero results.

  3. Encode facet state in URL query parameters -- this enables deep linking, browser back/forward navigation, and shareable filtered views. LiveView's handle_params/3 makes this straightforward.

  4. Implement facet caching with short TTL -- use ETS-backed caching (via HierarchicalCache) with 30-60 second TTL to avoid redundant Meilisearch queries while keeping counts reasonably fresh.

  5. Limit displayed facet values with "Show more" -- for facets with more than 10-15 values, show the top N by count and provide an expansion control. This keeps the UI clean without hiding options.

  6. Request all facets in a single query -- Meilisearch supports returning multiple facet distributions in one request. Never issue N separate queries for N facets.

  7. Provide a "Clear all filters" action -- users who have applied multiple facets need a quick way to reset. Place this prominently near the active filter indicators.

  8. Sort facet values consistently -- prefer alphabetical order or a predefined display order over sorting by count. Count-based sorting causes values to jump around as filters change.

  9. Use appropriate UI patterns per facet type -- checkboxes for multi-select value facets, toggles for boolean facets, sliders for range facets, tree views for hierarchical facets. Do not use a single pattern for all types.

  10. Implement real-time facet updates via LiveView -- when a user selects a facet, update all other facet counts immediately via server-side rendering. Avoid full page reloads or client-side JavaScript computation.

  • Full-Text Index -- Index structures supporting faceted search
  • Meilisearch -- Search engine powering platform faceted search
  • Elasticsearch -- Alternative search engine with faceted search support
  • Taxonomy -- Classification systems providing facet hierarchies
  • Inverted Index -- Core data structure enabling facet counting
  • Pagination -- Result pagination combined with faceted filtering
  • LiveView -- Real-time UI framework rendering faceted interfaces
  • ETS -- In-memory storage backing facet cache
  • GenServer -- Process model for facet cache management
  • PubSub -- Event system for cross-component facet updates
  • Data Quality -- Input quality affecting facet accuracy
  • Search -- Broader search capabilities encompassing faceted search

See Also

  • Capabilities -- Platform capability catalog
  • Architecture -- Platform architecture overview
  • OSINT Tools -- Platform OSINT tool registry with faceted interface
  • Technologies -- Technology stack including Meilisearch
  • Livebooks: api_integration/ notebooks demonstrate Meilisearch faceted queries
  • Academy: Topics on information retrieval cover faceted search theory

Connect & Contribute

Created by Tomas Korcak (korczis) | Open Source under GHL