Skip to content

Commit 5570c23

Browse files
authored
Improve search filtering with server-side type aggregations and post_filter (#2305)
* Improve search filtering with server-side type aggregations and post_filter - Implement server-side type filtering using Elasticsearch `post_filter` for accurate aggregation counts - Add terms aggregation on `type` field to return doc/api counts from the server - Refactor search API types: rename `SearchRequest`/`SearchResponse` to `SearchApiRequest`/`SearchApiResponse` - Replace tuple return type in `ISearchGateway` with new `SearchResult` record - Return aggregations nested under `aggregations.type` for future extensibility - Update frontend to fetch filtered results from server instead of client-side filtering - Add `typeFilter` state to search store for consistent filter state management - Split `ElasticsearchIngestChannel` and `ElasticsearchMarkdownExporter` into partial files for better code organization - `ISearchGateway.SearchAsync` now returns `SearchResult` with `TotalHits`, `Results`, and `Aggregations` - Search endpoint accepts `type` query parameter (values: `doc`, `api`) - `post_filter` ensures aggregation counts reflect total matches regardless of active filter - New `SearchAggregations` record wraps type counts for clean JSON structure - Filter selection triggers new API request with `?type=` parameter - Aggregation counts from server displayed in filter buttons - Removed client-side result filtering (server handles it) - Query cache keys include `typeFilter` for proper result caching * fix formatting * remove log line
1 parent 92bc123 commit 5570c23

File tree

14 files changed

+227
-172
lines changed

14 files changed

+227
-172
lines changed

src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResults.tsx

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { SearchOrAskAiErrorCallout } from '../../SearchOrAskAiErrorCallout'
22
import { useSearchActions, useSearchTerm } from '../search.store'
3-
import { useSearchFilters, type FilterType } from '../useSearchFilters'
3+
import { useSearchFilters, type TypeFilter } from '../useSearchFilters'
44
import { useSearchQuery } from '../useSearchQuery'
55
import { SearchResultListItem } from './SearchResultsListItem'
66
import {
@@ -18,7 +18,6 @@ import {
1818
import { css } from '@emotion/react'
1919
import { useDebounce } from '@uidotdev/usehooks'
2020
import { useEffect, useRef, useCallback } from 'react'
21-
import type { MouseEvent } from 'react'
2221

2322
interface SearchResultsProps {
2423
onKeyDown?: (
@@ -61,9 +60,10 @@ export const SearchResults = ({
6160

6261
const { data, error, isLoading } = useSearchQuery()
6362

64-
const { selectedFilters, handleFilterClick, filteredResults, counts } =
63+
const { selectedFilter, handleFilterClick, filteredResults, counts } =
6564
useSearchFilters({
6665
results: data?.results ?? [],
66+
aggregations: data?.aggregations,
6767
})
6868

6969
const isInitialLoading = isLoading && !data
@@ -84,7 +84,7 @@ export const SearchResults = ({
8484
{!error && (
8585
<>
8686
<Filter
87-
selectedFilters={selectedFilters}
87+
selectedFilter={selectedFilter}
8888
onFilterClick={handleFilterClick}
8989
counts={counts}
9090
isLoading={isInitialLoading}
@@ -201,13 +201,13 @@ export const SearchResults = ({
201201
}
202202

203203
const Filter = ({
204-
selectedFilters,
204+
selectedFilter,
205205
onFilterClick,
206206
counts,
207207
isLoading,
208208
}: {
209-
selectedFilters: Set<FilterType>
210-
onFilterClick: (filter: FilterType, event?: MouseEvent) => void
209+
selectedFilter: TypeFilter
210+
onFilterClick: (filter: TypeFilter) => void
211211
counts: {
212212
apiResultsCount: number
213213
docsResultsCount: number
@@ -245,12 +245,12 @@ const Filter = ({
245245
color="text"
246246
// @ts-expect-error: xs is valid size according to EuiButton docs
247247
size="xs"
248-
fill={selectedFilters.has('all')}
248+
fill={selectedFilter === 'all'}
249249
isLoading={isLoading}
250-
onClick={(e: MouseEvent) => onFilterClick('all', e)}
250+
onClick={() => onFilterClick('all')}
251251
css={buttonStyle}
252252
aria-label={`Show all results, ${totalCount} total`}
253-
aria-pressed={selectedFilters.has('all')}
253+
aria-pressed={selectedFilter === 'all'}
254254
>
255255
{isLoading ? 'ALL' : `ALL (${totalCount})`}
256256
</EuiButton>
@@ -264,12 +264,12 @@ const Filter = ({
264264
color="text"
265265
// @ts-expect-error: xs is valid size according to EuiButton docs
266266
size="xs"
267-
fill={selectedFilters.has('doc')}
267+
fill={selectedFilter === 'doc'}
268268
isLoading={isLoading}
269-
onClick={(e: MouseEvent) => onFilterClick('doc', e)}
269+
onClick={() => onFilterClick('doc')}
270270
css={buttonStyle}
271271
aria-label={`Filter to documentation results, ${docsResultsCount} available`}
272-
aria-pressed={selectedFilters.has('doc')}
272+
aria-pressed={selectedFilter === 'doc'}
273273
>
274274
{isLoading ? 'DOCS' : `DOCS (${docsResultsCount})`}
275275
</EuiButton>
@@ -283,12 +283,12 @@ const Filter = ({
283283
color="text"
284284
// @ts-expect-error: xs is valid size according to EuiButton docs
285285
size="xs"
286-
fill={selectedFilters.has('api')}
286+
fill={selectedFilter === 'api'}
287287
isLoading={isLoading}
288-
onClick={(e: MouseEvent) => onFilterClick('api', e)}
288+
onClick={() => onFilterClick('api')}
289289
css={buttonStyle}
290290
aria-label={`Filter to API results, ${apiResultsCount} available`}
291-
aria-pressed={selectedFilters.has('api')}
291+
aria-pressed={selectedFilter === 'api'}
292292
>
293293
{isLoading ? 'API' : `API (${apiResultsCount})`}
294294
</EuiButton>
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,33 @@
11
import { create } from 'zustand/react'
22

3+
export type TypeFilter = 'all' | 'doc' | 'api'
4+
35
interface SearchState {
46
searchTerm: string
57
page: number
8+
typeFilter: TypeFilter
69
actions: {
710
setSearchTerm: (term: string) => void
811
setPageNumber: (page: number) => void
12+
setTypeFilter: (filter: TypeFilter) => void
913
clearSearchTerm: () => void
1014
}
1115
}
1216

1317
export const searchStore = create<SearchState>((set) => ({
1418
searchTerm: '',
1519
page: 1,
20+
typeFilter: 'all',
1621
actions: {
1722
setSearchTerm: (term: string) => set({ searchTerm: term }),
1823
setPageNumber: (page: number) => set({ page }),
19-
clearSearchTerm: () => set({ searchTerm: '' }),
24+
setTypeFilter: (filter: TypeFilter) =>
25+
set({ typeFilter: filter, page: 0 }),
26+
clearSearchTerm: () => set({ searchTerm: '', typeFilter: 'all' }),
2027
},
2128
}))
2229

2330
export const useSearchTerm = () => searchStore((state) => state.searchTerm)
2431
export const usePageNumber = () => searchStore((state) => state.page)
32+
export const useTypeFilter = () => searchStore((state) => state.typeFilter)
2533
export const useSearchActions = () => searchStore((state) => state.actions)
Lines changed: 25 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,42 @@
1+
import {
2+
useTypeFilter,
3+
useSearchActions,
4+
type TypeFilter,
5+
} from './search.store'
16
import { SearchResponse } from './useSearchQuery'
2-
import { useState, useMemo } from 'react'
3-
import type { MouseEvent } from 'react'
4-
5-
export type FilterType = 'all' | 'doc' | 'api'
67

78
interface UseSearchFiltersOptions {
89
results: SearchResponse['results']
10+
aggregations?: SearchResponse['aggregations']
911
}
1012

11-
export const useSearchFilters = ({ results }: UseSearchFiltersOptions) => {
12-
const [selectedFilters, setSelectedFilters] = useState<Set<FilterType>>(
13-
new Set(['all'])
14-
)
15-
16-
const isMultiSelectModifierPressed = (event?: MouseEvent): boolean => {
17-
return !!(event && (event.metaKey || event.altKey || event.ctrlKey))
18-
}
13+
export const useSearchFilters = ({
14+
results,
15+
aggregations,
16+
}: UseSearchFiltersOptions) => {
17+
const typeFilter = useTypeFilter()
18+
const { setTypeFilter } = useSearchActions()
1919

20-
const toggleFilter = (
21-
currentFilters: Set<FilterType>,
22-
filter: FilterType
23-
): Set<FilterType> => {
24-
const newFilters = new Set(currentFilters)
25-
newFilters.delete('all')
26-
if (newFilters.has(filter)) {
27-
newFilters.delete(filter)
28-
} else {
29-
newFilters.add(filter)
30-
}
31-
return newFilters.size === 0 ? new Set(['all']) : newFilters
20+
const handleFilterClick = (filter: TypeFilter) => {
21+
setTypeFilter(filter)
3222
}
3323

34-
const handleFilterClick = (filter: FilterType, event?: MouseEvent) => {
35-
if (filter === 'all') {
36-
setSelectedFilters(new Set(['all']))
37-
return
38-
}
24+
// Results come pre-filtered from the server, so we just return them directly
25+
const filteredResults = results
3926

40-
if (isMultiSelectModifierPressed(event)) {
41-
setSelectedFilters((prev) => toggleFilter(prev, filter))
42-
} else {
43-
setSelectedFilters(new Set([filter]))
44-
}
45-
}
46-
47-
const filteredResults = useMemo(() => {
48-
if (selectedFilters.has('all')) {
49-
return results
50-
}
51-
return results.filter((result) => selectedFilters.has(result.type))
52-
}, [results, selectedFilters])
53-
54-
const counts = useMemo(() => {
55-
const apiResultsCount = results.filter((r) => r.type === 'api').length
56-
const docsResultsCount = results.filter((r) => r.type === 'doc').length
57-
const totalCount = docsResultsCount + apiResultsCount
58-
return { apiResultsCount, docsResultsCount, totalCount }
59-
}, [results])
27+
const typeAggregations = aggregations?.type
28+
const apiResultsCount = typeAggregations?.['api'] ?? 0
29+
const docsResultsCount = typeAggregations?.['doc'] ?? 0
30+
const totalCount = docsResultsCount + apiResultsCount
31+
const counts = { apiResultsCount, docsResultsCount, totalCount }
6032

6133
return {
62-
selectedFilters,
34+
selectedFilter: typeFilter,
6335
handleFilterClick,
6436
filteredResults,
6537
counts,
6638
}
6739
}
40+
41+
// Re-export TypeFilter for convenience
42+
export type { TypeFilter }

src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchQuery.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
import { traceSpan } from '../../../telemetry/tracing'
99
import { createApiErrorFromResponse, shouldRetry } from '../errorHandling'
1010
import { ApiError } from '../errorHandling'
11-
import { usePageNumber, useSearchTerm } from './search.store'
11+
import { usePageNumber, useSearchTerm, useTypeFilter } from './search.store'
1212
import {
1313
useIsSearchAwaitingNewInput,
1414
useSearchCooldownActions,
@@ -41,19 +41,25 @@ const SearchResultItem = z.object({
4141

4242
export type SearchResultItem = z.infer<typeof SearchResultItem>
4343

44+
const SearchAggregations = z.object({
45+
type: z.record(z.string(), z.number()).optional(),
46+
})
47+
4448
const SearchResponse = z.object({
4549
results: z.array(SearchResultItem),
4650
totalResults: z.number(),
4751
pageCount: z.number(),
4852
pageNumber: z.number(),
4953
pageSize: z.number(),
54+
aggregations: SearchAggregations.optional(),
5055
})
5156

5257
export type SearchResponse = z.infer<typeof SearchResponse>
5358

5459
export const useSearchQuery = () => {
5560
const searchTerm = useSearchTerm()
5661
const pageNumber = usePageNumber() + 1
62+
const typeFilter = useTypeFilter()
5763
const trimmedSearchTerm = searchTerm.trim()
5864
const debouncedSearchTerm = useDebounce(trimmedSearchTerm, 300)
5965
const isCooldownActive = useIsSearchCooldownActive()
@@ -80,7 +86,11 @@ export const useSearchQuery = () => {
8086
const query = useQuery<SearchResponse, ApiError>({
8187
queryKey: [
8288
'search',
83-
{ searchTerm: debouncedSearchTerm.toLowerCase(), pageNumber },
89+
{
90+
searchTerm: debouncedSearchTerm.toLowerCase(),
91+
pageNumber,
92+
typeFilter,
93+
},
8494
],
8595
queryFn: async ({ signal }) => {
8696
// Don't create span for empty searches
@@ -101,6 +111,11 @@ export const useSearchQuery = () => {
101111
page: pageNumber.toString(),
102112
})
103113

114+
// Only add type filter if not 'all'
115+
if (typeFilter !== 'all') {
116+
params.set('type', typeFilter)
117+
}
118+
104119
const response = await fetch(
105120
'/docs/_api/v1/search?' + params.toString(),
106121
{ signal }
@@ -140,10 +155,14 @@ export const useSearchQuery = () => {
140155
queryClient.cancelQueries({
141156
queryKey: [
142157
'search',
143-
{ searchTerm: debouncedSearchTerm.toLowerCase(), pageNumber },
158+
{
159+
searchTerm: debouncedSearchTerm.toLowerCase(),
160+
pageNumber,
161+
typeFilter,
162+
},
144163
],
145164
})
146-
}, [queryClient, debouncedSearchTerm, pageNumber])
165+
}, [queryClient, debouncedSearchTerm, pageNumber, typeFilter])
147166

148167
return {
149168
...query,

src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchIngestChannel.Mapping.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ protected static string CreateMapping(string? inferenceId) =>
113113
$$"""
114114
{
115115
"properties": {
116-
"url" : {
116+
"type": { "type" : "keyword", "normalizer": "keyword_normalizer" },
117+
"url": {
117118
"type": "keyword",
118119
"fields": {
119120
"match": { "type": "text" },

src/api/Elastic.Documentation.Api.Core/Search/ISearchGateway.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,18 @@ namespace Elastic.Documentation.Api.Core.Search;
66

77
public interface ISearchGateway
88
{
9-
Task<(int TotalHits, List<SearchResultItem> Results)> SearchAsync(
9+
Task<SearchResult> SearchAsync(
1010
string query,
1111
int pageNumber,
1212
int pageSize,
13+
string? filter = null,
1314
Cancel ctx = default
1415
);
1516
}
17+
18+
public record SearchResult
19+
{
20+
public required int TotalHits { get; init; }
21+
public required List<SearchResultItem> Results { get; init; }
22+
public IReadOnlyDictionary<string, long> Aggregations { get; init; } = new Dictionary<string, long>();
23+
}

0 commit comments

Comments
 (0)