View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.rank.fusion;
17  
18  import java.util.HashMap;
19  import java.util.Map;
20  
21  import org.apache.logging.log4j.LogManager;
22  import org.apache.logging.log4j.Logger;
23  import org.codelibs.core.collection.ArrayUtil;
24  import org.codelibs.core.stream.StreamUtil;
25  import org.codelibs.fess.Constants;
26  import org.codelibs.fess.entity.SearchRequestParams;
27  import org.codelibs.fess.helper.ViewHelper;
28  import org.codelibs.fess.mylasta.action.FessUserBean;
29  import org.codelibs.fess.mylasta.direction.FessConfig;
30  import org.codelibs.fess.opensearch.client.SearchEngineClient.SearchCondition;
31  import org.codelibs.fess.opensearch.client.SearchEngineClient.SearchConditionBuilder;
32  import org.codelibs.fess.rank.fusion.SearchResult.SearchResultBuilder;
33  import org.codelibs.fess.util.ComponentUtil;
34  import org.codelibs.fess.util.DocumentUtil;
35  import org.codelibs.fess.util.FacetResponse;
36  import org.dbflute.optional.OptionalEntity;
37  import org.dbflute.optional.OptionalThing;
38  import org.lastaflute.web.util.LaRequestUtil;
39  import org.opensearch.action.search.SearchRequestBuilder;
40  import org.opensearch.action.search.SearchResponse;
41  import org.opensearch.common.document.DocumentField;
42  import org.opensearch.search.SearchHit;
43  import org.opensearch.search.SearchHits;
44  import org.opensearch.search.aggregations.Aggregations;
45  import org.opensearch.search.fetch.subphase.highlight.HighlightField;
46  
47  /**
48   * Default implementation of RankFusionSearcher that performs standard OpenSearch queries.
49   * This searcher handles query execution, response processing, and document highlighting.
50   */
51  public class DefaultSearcher extends RankFusionSearcher {
52  
53      /** Logger for this class. */
54      private static final Logger logger = LogManager.getLogger(DefaultSearcher.class);
55  
56      /**
57       * Creates a new instance of DefaultSearcher.
58       * This constructor initializes the default rank fusion searcher for performing
59       * standard OpenSearch queries with response processing and document highlighting.
60       */
61      public DefaultSearcher() {
62          super();
63      }
64  
65      /**
66       * Performs a search operation using the specified query and parameters.
67       *
68       * @param query the search query string
69       * @param params the search request parameters
70       * @param userBean the optional user bean for access control
71       * @return the search result containing documents and metadata
72       */
73      @Override
74      protected SearchResult search(final String query, final SearchRequestParams params, final OptionalThing<FessUserBean> userBean) {
75          final int pageSize = params.getPageSize();
76          LaRequestUtil.getOptionalRequest().ifPresent(request -> {
77              request.setAttribute(Constants.REQUEST_PAGE_SIZE, pageSize);
78          });
79          final OptionalEntity<SearchResponse> searchResponseOpt = sendRequest(query, params, userBean);
80          return processResponse(searchResponseOpt);
81      }
82  
83      /**
84       * Processes the OpenSearch response and converts it to a SearchResult.
85       *
86       * @param searchResponseOpt the optional search response from OpenSearch
87       * @return the processed search result
88       */
89      protected SearchResult processResponse(final OptionalEntity<SearchResponse> searchResponseOpt) {
90          final FessConfig fessConfig = ComponentUtil.getFessConfig();
91          final SearchResultBuilder builder = SearchResult.create();
92          searchResponseOpt.ifPresent(searchResponse -> {
93              final SearchHits searchHits = searchResponse.getHits();
94              builder.allRecordCount(searchHits.getTotalHits().value());
95              builder.allRecordCountRelation(searchHits.getTotalHits().relation().toString());
96              builder.queryTime(searchResponse.getTook().millis());
97  
98              if (searchResponse.getTotalShards() != searchResponse.getSuccessfulShards()) {
99                  builder.partialResults(true);
100             }
101 
102             // build highlighting fields
103             final String hlPrefix = ComponentUtil.getQueryHelper().getHighlightPrefix();
104             for (final SearchHit searchHit : searchHits.getHits()) {
105                 final Map<String, Object> docMap = parseSearchHit(fessConfig, hlPrefix, searchHit);
106 
107                 if (fessConfig.isResultCollapsed()) {
108                     final Map<String, SearchHits> innerHits = searchHit.getInnerHits();
109                     if (innerHits != null) {
110                         final SearchHits innerSearchHits = innerHits.get(fessConfig.getQueryCollapseInnerHitsName());
111                         if (innerSearchHits != null) {
112                             final long totalHits = innerSearchHits.getTotalHits().value();
113                             if (totalHits > 1) {
114                                 docMap.put(fessConfig.getQueryCollapseInnerHitsName() + "_count", totalHits);
115                                 final DocumentField bitsField = searchHit.getFields().get(fessConfig.getIndexFieldContentMinhashBits());
116                                 if (bitsField != null && !bitsField.getValues().isEmpty()) {
117                                     docMap.put(fessConfig.getQueryCollapseInnerHitsName() + "_hash", bitsField.getValues().get(0));
118                                 }
119                                 docMap.put(fessConfig.getQueryCollapseInnerHitsName(), StreamUtil.stream(innerSearchHits.getHits())
120                                         .get(stream -> stream.map(hit -> parseSearchHit(fessConfig, hlPrefix, hit)).toArray(Map[]::new)));
121                             }
122                         }
123                     }
124                 }
125 
126                 builder.addDocument(docMap);
127             }
128 
129             // facet
130             final Aggregations aggregations = searchResponse.getAggregations();
131             if (aggregations != null) {
132                 builder.facetResponse(new FacetResponse(aggregations));
133             }
134 
135         });
136         return builder.build();
137     }
138 
139     /**
140      * Sends a search request to OpenSearch with the specified parameters.
141      *
142      * @param query the search query string
143      * @param params the search request parameters
144      * @param userBean the optional user bean for access control
145      * @return the optional search response from OpenSearch
146      */
147     protected OptionalEntity<SearchResponse> sendRequest(final String query, final SearchRequestParams params,
148             final OptionalThing<FessUserBean> userBean) {
149         final FessConfig fessConfig = ComponentUtil.getFessConfig();
150         return ComponentUtil.getSearchEngineClient()
151                 .search(fessConfig.getIndexDocumentSearchIndex(), createSearchCondition(query, params, userBean),
152                         (searchRequestBuilder, execTime, searchResponse) -> {
153                             searchResponse.ifPresent(r -> {
154                                 if (r.getTotalShards() != r.getSuccessfulShards() && fessConfig.isQueryTimeoutLogging()) {
155                                     // partial results
156                                     final StringBuilder buf = new StringBuilder(1000);
157                                     buf.append("[SEARCH TIMEOUT] {\"exec_time\":")
158                                             .append(execTime)//
159                                             .append(",\"request\":")
160                                             .append(searchRequestBuilder.toString())//
161                                             .append(",\"response\":")
162                                             .append(r.toString())
163                                             .append('}');
164                                     logger.warn(buf.toString());
165                                 }
166                             });
167                             return searchResponse;
168                         });
169     }
170 
171     /**
172      * Creates a search condition for the OpenSearch request.
173      *
174      * @param query the search query string
175      * @param params the search request parameters
176      * @param userBean the optional user bean for access control
177      * @return the search condition for the request
178      */
179     protected SearchCondition<SearchRequestBuilder> createSearchCondition(final String query, final SearchRequestParams params,
180             final OptionalThing<FessUserBean> userBean) {
181         return searchRequestBuilder -> {
182             ComponentUtil.getQueryHelper().processSearchPreference(searchRequestBuilder, userBean, query);
183             return SearchConditionBuilder.builder(searchRequestBuilder)
184                     .query(query)
185                     .offset(params.getStartPosition())
186                     .size(params.getPageSize())
187                     .facetInfo(params.getFacetInfo())
188                     .geoInfo(params.getGeoInfo())
189                     .highlightInfo(params.getHighlightInfo())
190                     .similarDocHash(params.getSimilarDocHash())
191                     .responseFields(params.getResponseFields())
192                     .searchRequestType(params.getType())
193                     .trackTotalHits(params.getTrackTotalHits())
194                     .minScore(params.getMinScore())
195                     .build();
196         };
197     }
198 
199     /**
200      * Parses a search hit from OpenSearch and converts it to a document map.
201      *
202      * @param fessConfig the Fess configuration
203      * @param hlPrefix the highlight prefix for field names
204      * @param searchHit the search hit to parse
205      * @return the parsed document as a map
206      */
207     protected Map<String, Object> parseSearchHit(final FessConfig fessConfig, final String hlPrefix, final SearchHit searchHit) {
208         final Map<String, Object> docMap = new HashMap<>(32);
209         if (searchHit.getSourceAsMap() == null) {
210             searchHit.getFields().forEach((key, value) -> {
211                 docMap.put(key, value.getValue());
212             });
213         } else {
214             docMap.putAll(searchHit.getSourceAsMap());
215         }
216 
217         final ViewHelper viewHelper = ComponentUtil.getViewHelper();
218 
219         final Map<String, HighlightField> highlightFields = searchHit.getHighlightFields();
220         try {
221             if (highlightFields != null) {
222                 highlightFields.values().stream().forEach(highlightField -> {
223                     final String text = viewHelper.createHighlightText(highlightField);
224                     if (text != null) {
225                         docMap.put(hlPrefix + highlightField.getName(), text);
226                     }
227                 });
228                 if (Constants.TEXT_FRAGMENT_TYPE_HIGHLIGHT.equals(fessConfig.getQueryHighlightTextFragmentType())) {
229                     docMap.put(Constants.TEXT_FRAGMENTS,
230                             viewHelper.createTextFragmentsByHighlight(highlightFields.values().toArray(HighlightField[]::new)));
231                 }
232             }
233         } catch (final Exception e) {
234             if (logger.isDebugEnabled()) {
235                 logger.debug("Could not create a highlighting value: {}", docMap, e);
236             }
237         }
238 
239         if (Constants.TEXT_FRAGMENT_TYPE_QUERY.equals(fessConfig.getQueryHighlightTextFragmentType())) {
240             docMap.put(Constants.TEXT_FRAGMENTS, viewHelper.createTextFragmentsByQuery());
241         }
242 
243         // ContentTitle
244         if (viewHelper != null) {
245             docMap.put(fessConfig.getResponseFieldContentTitle(), viewHelper.getContentTitle(docMap));
246             docMap.put(fessConfig.getResponseFieldContentDescription(), viewHelper.getContentDescription(docMap));
247             docMap.put(fessConfig.getResponseFieldUrlLink(), viewHelper.getUrlLink(docMap));
248             docMap.put(fessConfig.getResponseFieldSitePath(), viewHelper.getSitePath(docMap));
249         }
250 
251         if (!docMap.containsKey(Constants.SCORE)) {
252             final float score = searchHit.getScore();
253             if (Float.isFinite(score)) {
254                 docMap.put(Constants.SCORE, score);
255             }
256         }
257 
258         if (!docMap.containsKey(fessConfig.getIndexFieldId())) {
259             docMap.put(fessConfig.getIndexFieldId(), searchHit.getId());
260         }
261 
262         final String[] searchers = DocumentUtil.getValue(docMap, Constants.SEARCHER, String[].class);
263         if (searchers != null) {
264             docMap.put(Constants.SEARCHER, ArrayUtil.add(searchers, getName()));
265         } else {
266             docMap.put(Constants.SEARCHER, new String[] { getName() });
267         }
268 
269         return docMap;
270     }
271 
272 }