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.ArrayList;
19  import java.util.Collections;
20  import java.util.HashMap;
21  import java.util.HashSet;
22  import java.util.List;
23  import java.util.Locale;
24  import java.util.Map;
25  import java.util.Set;
26  import java.util.concurrent.CopyOnWriteArrayList;
27  import java.util.concurrent.ExecutionException;
28  import java.util.concurrent.ExecutorService;
29  import java.util.concurrent.Executors;
30  import java.util.concurrent.Future;
31  import java.util.concurrent.TimeUnit;
32  import java.util.stream.Collectors;
33  
34  import org.apache.logging.log4j.LogManager;
35  import org.apache.logging.log4j.Logger;
36  import org.apache.lucene.search.TotalHits.Relation;
37  import org.codelibs.core.collection.ArrayUtil;
38  import org.codelibs.core.concurrent.CommonPoolUtil;
39  import org.codelibs.core.lang.StringUtil;
40  import org.codelibs.core.stream.StreamUtil;
41  import org.codelibs.fess.Constants;
42  import org.codelibs.fess.entity.FacetInfo;
43  import org.codelibs.fess.entity.GeoInfo;
44  import org.codelibs.fess.entity.HighlightInfo;
45  import org.codelibs.fess.entity.SearchRequestParams;
46  import org.codelibs.fess.exception.InvalidQueryException;
47  import org.codelibs.fess.exception.ResultOffsetExceededException;
48  import org.codelibs.fess.mylasta.action.FessUserBean;
49  import org.codelibs.fess.mylasta.direction.FessConfig;
50  import org.codelibs.fess.util.ComponentUtil;
51  import org.codelibs.fess.util.DocumentUtil;
52  import org.codelibs.fess.util.FacetResponse;
53  import org.codelibs.fess.util.QueryResponseList;
54  import org.dbflute.optional.OptionalThing;
55  import org.lastaflute.di.core.ExternalContext;
56  import org.lastaflute.di.core.factory.SingletonLaContainerFactory;
57  import org.lastaflute.web.util.LaRequestUtil;
58  import org.lastaflute.web.util.LaResponseUtil;
59  
60  import jakarta.annotation.PostConstruct;
61  import jakarta.annotation.PreDestroy;
62  import jakarta.servlet.http.HttpServletRequest;
63  import jakarta.servlet.http.HttpServletResponse;
64  
65  /**
66   * RankFusionProcessor manages multiple search engines and combines their results using rank fusion algorithms.
67   * This processor supports searching with multiple searchers concurrently and merging their results based on
68   * ranking scores to provide more comprehensive and accurate search results.
69   *
70   * The processor maintains a pool of searchers and an executor service for concurrent operations.
71   * It implements rank fusion techniques to combine results from different search engines
72   * and provides a unified search interface.
73   */
74  public class RankFusionProcessor implements AutoCloseable {
75  
76      private static final Logger logger = LogManager.getLogger(RankFusionProcessor.class);
77  
78      /** Thread-safe list of rank fusion searchers available for processing search requests */
79      protected final List<RankFusionSearcher> searchers = new CopyOnWriteArrayList<>();
80  
81      /** Executor service for concurrent search operations across multiple searchers */
82      protected ExecutorService executorService;
83  
84      /** Size of the window for rank fusion processing, determines how many results to consider */
85      protected int windowSize;
86  
87      /** Set of available searcher names that can be used for search processing */
88      protected Set<String> availableSearcherNameSet;
89  
90      /**
91       * Default constructor for RankFusionProcessor.
92       * Initializes the processor with default values. The actual initialization
93       * is performed by the init() method which is called after construction.
94       */
95      public RankFusionProcessor() {
96          // Default constructor - initialization is done in init() method
97      }
98  
99      /**
100      * Initializes the rank fusion processor after construction.
101      * Sets up the window size based on configuration and loads available searchers.
102      * This method is called automatically after the bean is constructed.
103      */
104     @PostConstruct
105     public void init() {
106         final FessConfig fessConfig = ComponentUtil.getFessConfig();
107         final int maxPageSize = fessConfig.getPagingSearchPageMaxSizeAsInteger();
108         final int configuredWindowSize = fessConfig.getRankFusionWindowSizeAsInteger();
109         final int minimumWindowSize = maxPageSize * 2;
110 
111         if (configuredWindowSize < minimumWindowSize) {
112             logger.warn("Configured rank.fusion.window_size ({}) is less than required minimum size ({}). " + "Using minimum size instead.",
113                     configuredWindowSize, minimumWindowSize);
114             this.windowSize = minimumWindowSize;
115         } else {
116             this.windowSize = configuredWindowSize;
117         }
118 
119         if (logger.isDebugEnabled()) {
120             logger.debug("Initialized RankFusionProcessor with windowSize={}", this.windowSize);
121         }
122         load();
123     }
124 
125     /**
126      * Updates the processor configuration by reloading available searchers.
127      * This method executes the load operation asynchronously in a separate thread.
128      */
129     public void update() {
130         CommonPoolUtil.execute(this::load);
131     }
132 
133     /**
134      * Loads the available searcher names from system properties.
135      * Parses the "rank.fusion.searchers" system property to determine which searchers
136      * are available for use in rank fusion processing.
137      */
138     protected void load() {
139         final String value = System.getProperty("rank.fusion.searchers");
140         if (StringUtil.isBlank(value)) {
141             availableSearcherNameSet = Collections.emptySet();
142         } else {
143             availableSearcherNameSet = StreamUtil.split(value, ",")
144                     .get(stream -> stream.map(String::trim).filter(StringUtil::isNotBlank).collect(Collectors.toUnmodifiableSet()));
145         }
146         if (logger.isDebugEnabled()) {
147             logger.debug("Available searchers: names={}", availableSearcherNameSet);
148         }
149     }
150 
151     @Override
152     @PreDestroy
153     public void close() throws Exception {
154         if (executorService != null) {
155             try {
156                 executorService.shutdown();
157                 executorService.awaitTermination(60, TimeUnit.SECONDS);
158             } catch (final InterruptedException e) {
159                 if (logger.isDebugEnabled()) {
160                     logger.debug("Executor shutdown interrupted", e);
161                 }
162             } finally {
163                 executorService.shutdownNow();
164             }
165         }
166     }
167 
168     /**
169      * Performs a search operation using rank fusion across available searchers.
170      * If only one searcher is available, uses the main searcher. Otherwise, performs
171      * concurrent searches across multiple searchers and fuses the results.
172      *
173      * @param query the search query string
174      * @param params search request parameters including pagination and filters
175      * @param userBean optional user information for personalized search
176      * @return list of search result documents with fused ranking scores
177      */
178     public List<Map<String, Object>> search(final String query, final SearchRequestParams params,
179             final OptionalThing<FessUserBean> userBean) {
180         final RankFusionSearcher[] availableSearchers = getAvailableSearchers();
181         if (logger.isDebugEnabled()) {
182             logger.debug("Searching with {} available searchers for query={}", availableSearchers.length, query);
183         }
184         if (availableSearchers.length == 0) {
185             logger.warn("No searchers available for query: {}", query);
186             return createResponseList(Collections.emptyList(), 0, Relation.EQUAL_TO.toString(), 0, false, null, params.getStartPosition(),
187                     params.getPageSize(), 0);
188         }
189         if (availableSearchers.length == 1) {
190             return searchWithMainSearcher(availableSearchers[0], query, params, userBean);
191         }
192         return searchWithMultipleSearchers(availableSearchers, query, params, userBean);
193     }
194 
195     /**
196      * Gets the array of available searchers based on configuration.
197      * Filters the searchers list to include only those specified in the available searcher name set.
198      * If no specific searchers are configured, returns all searchers.
199      *
200      * @return array of available RankFusionSearcher instances
201      */
202     protected RankFusionSearcher[] getAvailableSearchers() {
203         if (searchers.isEmpty()) {
204             logger.warn("No searchers registered");
205             return new RankFusionSearcher[0];
206         }
207         if (availableSearcherNameSet.isEmpty()) {
208             return searchers.toArray(new RankFusionSearcher[0]);
209         }
210         final RankFusionSearcher[] availableSearchers = searchers.stream()
211                 .filter(searcher -> availableSearcherNameSet.contains(searcher.getName()))
212                 .toArray(RankFusionSearcher[]::new);
213         if (availableSearchers.length == 0) {
214             if (logger.isDebugEnabled()) {
215                 logger.debug("No available searchers from {}, falling back to default searcher", availableSearcherNameSet);
216             }
217             return new RankFusionSearcher[] { searchers.get(0) };
218         }
219         return availableSearchers;
220     }
221 
222     /**
223      * Performs concurrent searches using multiple searchers and fuses the results.
224      * Executes searches in parallel across all provided searchers, then combines the results
225      * using rank fusion algorithms to produce a unified result set.
226      *
227      * @param searchers array of searchers to use for concurrent searching
228      * @param query the search query string
229      * @param params search request parameters including pagination and filters
230      * @param userBean optional user information for personalized search
231      * @return list of search result documents with fused ranking scores
232      */
233     protected List<Map<String, Object>> searchWithMultipleSearchers(final RankFusionSearcher[] searchers, final String query,
234             final SearchRequestParams params, final OptionalThing<FessUserBean> userBean) {
235         if (logger.isDebugEnabled()) {
236             logger.debug("Sending query to searchers: query={}", query);
237         }
238         final int pageSize = params.getPageSize();
239         final int startPosition = params.getStartPosition();
240         if (startPosition * 2 >= windowSize) {
241             if (logger.isDebugEnabled()) {
242                 logger.debug("Deep pagination detected: startPosition={}, windowSize={}, falling back to main searcher", startPosition,
243                         windowSize);
244             }
245             int offset = params.getOffset();
246             if (offset < 0) {
247                 offset = 0;
248             } else if (offset > windowSize / 2) {
249                 offset = windowSize / 2;
250             }
251             int start = startPosition - offset;
252             if (start < 0) {
253                 start = 0;
254             }
255             if (logger.isDebugEnabled()) {
256                 logger.debug("Adjusted start position: original={}, adjusted={}, offset={}", startPosition, start, offset);
257             }
258             final SearchRequestParams reqParams = new SearchRequestParamsWrapper(params, start, pageSize);
259             final SearchResult searchResult = searchers[0].search(query, reqParams, userBean);
260             long allRecordCount = searchResult.getAllRecordCount();
261             if (Relation.EQUAL_TO.toString().equals(searchResult.getAllRecordCountRelation())) {
262                 allRecordCount += offset;
263             }
264             return createResponseList(searchResult.getDocumentList(), allRecordCount, searchResult.getAllRecordCountRelation(),
265                     searchResult.getQueryTime(), searchResult.isPartialResults(), searchResult.getFacetResponse(),
266                     params.getStartPosition(), pageSize, offset);
267         }
268 
269         final ExternalContext externalContext = SingletonLaContainerFactory.getExternalContext();
270         final OptionalThing<HttpServletRequest> requestOpt = LaRequestUtil.getOptionalRequest();
271         final OptionalThing<HttpServletResponse> responseOpt = LaResponseUtil.getOptionalResponse();
272         final FessConfig fessConfig = ComponentUtil.getFessConfig();
273         final int rankConstant = fessConfig.getRankFusionRankConstantAsInteger();
274         if (searchers.length == 0) {
275             logger.warn("searchWithMultipleSearchers called with empty searcher array");
276             return createResponseList(Collections.emptyList(), 0, Relation.EQUAL_TO.toString(), 0, false, null, params.getStartPosition(),
277                     params.getPageSize(), 0);
278         }
279         final int size = windowSize / searchers.length;
280         if (logger.isDebugEnabled()) {
281             logger.debug("Search parameters: windowSize={}, sizePerSearcher={}, rankConstant={}", windowSize, size, rankConstant);
282         }
283         final List<Future<SearchResult>> resultList = new ArrayList<>();
284         for (int i = 0; i < searchers.length; i++) {
285             final SearchRequestParams reqParams = new SearchRequestParamsWrapper(params, 0, i == 0 ? windowSize : size);
286             final RankFusionSearcher searcher = searchers[i];
287             resultList.add(executorService.submit(() -> {
288                 try {
289                     if (externalContext != null) {
290                         requestOpt.ifPresent(externalContext::setRequest);
291                         responseOpt.ifPresent(externalContext::setResponse);
292                     }
293                     return searcher.search(query, reqParams, userBean);
294                 } finally {
295                     if (externalContext != null) {
296                         externalContext.setRequest(null);
297                         externalContext.setResponse(null);
298                     }
299                 }
300             }));
301         }
302         final SearchResult[] results = resultList.stream().map(future -> {
303             try {
304                 return future.get();
305             } catch (final InterruptedException e) {
306                 logger.warn("Search operation was interrupted", e);
307                 Thread.currentThread().interrupt(); // Restore interrupt status
308                 return SearchResult.create().build();
309             } catch (final ExecutionException e) {
310                 if (e.getCause() instanceof final InvalidQueryException iqe) {
311                     throw iqe;
312                 }
313                 if (e.getCause() instanceof final ResultOffsetExceededException roee) {
314                     throw roee;
315                 }
316                 logger.warn("Search operation failed with exception", e.getCause());
317                 return SearchResult.create().build();
318             }
319         }).toArray(SearchResult[]::new);
320 
321         final String scoreField = fessConfig.getRankFusionScoreField();
322         final Map<String, Map<String, Object>> documentsByIdMap = new HashMap<>();
323         final String idField = fessConfig.getIndexFieldId();
324         final Set<Object> mainSearcherIdSet = new HashSet<>();
325         for (int searcherIndex = 0; searcherIndex < results.length; searcherIndex++) {
326             final List<Map<String, Object>> docList = results[searcherIndex].getDocumentList();
327             if (logger.isDebugEnabled()) {
328                 logger.debug("Searcher[{}]: retrieved {} documents / {} total documents", searcherIndex, docList.size(),
329                         results[searcherIndex].getAllRecordCount());
330             }
331             for (int docRank = 0; docRank < docList.size(); docRank++) {
332                 final Map<String, Object> doc = docList.get(docRank);
333                 if (doc != null && doc.get(idField) instanceof final String id) {
334                     // Calculate RRF score: 1 / (rank_constant + rank)
335                     final float rrfScore = 1.0f / (rankConstant + docRank);
336                     if (documentsByIdMap.containsKey(id)) {
337                         final Map<String, Object> existingDoc = documentsByIdMap.get(id);
338                         final float currentScore = toFloat(existingDoc.get(scoreField));
339                         existingDoc.put(scoreField, currentScore + rrfScore);
340                         // Merge searcher names
341                         final String[] searcherNames = DocumentUtil.getValue(doc, Constants.SEARCHER, String[].class);
342                         if (searcherNames != null) {
343                             final String[] existingSearchers = DocumentUtil.getValue(existingDoc, Constants.SEARCHER, String[].class);
344                             if (existingSearchers != null) {
345                                 existingDoc.put(Constants.SEARCHER, ArrayUtil.addAll(existingSearchers, searcherNames));
346                             } else {
347                                 existingDoc.put(Constants.SEARCHER, searcherNames);
348                             }
349                         }
350                     } else {
351                         doc.put(scoreField, Float.valueOf(rrfScore));
352                         documentsByIdMap.put(id, doc);
353                     }
354                     // Track documents from main searcher (index 0) within window size
355                     if (searcherIndex == 0 && docRank < windowSize / 2) {
356                         mainSearcherIdSet.add(id);
357                     }
358                 }
359             }
360         }
361 
362         // Sort all documents by fused RRF score (descending)
363         final var fusedDocs = documentsByIdMap.values()
364                 .stream()
365                 .sorted((doc1, doc2) -> Float.compare(toFloat(doc2.get(scoreField)), toFloat(doc1.get(scoreField))))
366                 .toList();
367 
368         // Calculate offset based on documents not in main searcher's top results
369         int offset = 0;
370         for (int i = 0; i < windowSize / 2 && i < fusedDocs.size(); i++) {
371             if (!mainSearcherIdSet.contains(fusedDocs.get(i).get(idField))) {
372                 offset++;
373             }
374         }
375         if (logger.isDebugEnabled()) {
376             logger.debug("Calculated offset: {}, total fused documents: {}", offset, fusedDocs.size());
377             final int logLimit = Math.min(10, fusedDocs.size());
378             for (int i = 0; i < logLimit; i++) {
379                 final Map<String, Object> doc = fusedDocs.get(i);
380                 logger.debug("Fused rank[{}]: id={}, score={}", i, doc.get(idField), doc.get(scoreField));
381             }
382         }
383         final SearchResult mainResult = results[0];
384         long allRecordCount = mainResult.getAllRecordCount();
385         if (Relation.EQUAL_TO.toString().equals(mainResult.getAllRecordCountRelation())) {
386             allRecordCount += offset;
387         }
388         return createResponseList(extractList(fusedDocs, pageSize, startPosition), allRecordCount, mainResult.getAllRecordCountRelation(),
389                 mainResult.getQueryTime(), mainResult.isPartialResults(), mainResult.getFacetResponse(), startPosition, pageSize, offset);
390     }
391 
392     /**
393      * Extracts a subset of documents from the full result list based on pagination parameters.
394      * Applies proper bounds checking to ensure the extracted range is within the document list size.
395      *
396      * @param docs the full list of search result documents
397      * @param pageSize the number of documents to include in the page
398      * @param startPosition the starting position for pagination
399      * @return sublist of documents for the requested page
400      */
401     protected List<Map<String, Object>> extractList(final List<Map<String, Object>> docs, final int pageSize, final int startPosition) {
402         final int size = docs.size();
403         if (size == 0 || startPosition >= size) {
404             return Collections.emptyList();
405         }
406         int fromIndex = Math.max(0, startPosition);
407         int toIndex = fromIndex + pageSize;
408         if (toIndex >= size) {
409             toIndex = size;
410         }
411         return docs.subList(fromIndex, toIndex);
412     }
413 
414     /**
415      * Performs a search using only the main searcher without rank fusion.
416      * This method is used when only one searcher is available or configured.
417      *
418      * @param searcher the main searcher to use for the search operation
419      * @param query the search query string
420      * @param params search request parameters including pagination and filters
421      * @param userBean optional user information for personalized search
422      * @return list of search result documents from the main searcher
423      */
424     protected List<Map<String, Object>> searchWithMainSearcher(final RankFusionSearcher searcher, final String query,
425             final SearchRequestParams params, final OptionalThing<FessUserBean> userBean) {
426         if (logger.isDebugEnabled()) {
427             logger.debug("Sending query to main searcher: query={}", query);
428         }
429         final int pageSize = params.getPageSize();
430         try {
431             final SearchResult searchResult = searcher.search(query, params, userBean);
432             return createResponseList(searchResult.getDocumentList(), searchResult.getAllRecordCount(),
433                     searchResult.getAllRecordCountRelation(), searchResult.getQueryTime(), searchResult.isPartialResults(),
434                     searchResult.getFacetResponse(), params.getStartPosition(), pageSize, 0);
435         } catch (final InvalidQueryException | ResultOffsetExceededException e) {
436             throw e;
437         } catch (final Exception e) {
438             logger.warn("Main searcher failed to execute search for query: {}", query, e);
439             return createResponseList(Collections.emptyList(), 0, Relation.EQUAL_TO.toString(), 0, false, null, params.getStartPosition(),
440                     pageSize, 0);
441         }
442     }
443 
444     /**
445      * Creates a QueryResponseList containing the search results and metadata.
446      * Wraps the document list with additional information about the search operation
447      * including record counts, timing, and pagination details.
448      *
449      * @param documentList the list of search result documents
450      * @param allRecordCount the total number of records found
451      * @param allRecordCountRelation the relationship of the record count (exact, approximate, etc.)
452      * @param queryTime the time taken to execute the search query
453      * @param partialResults whether the results are partial due to timeout or other constraints
454      * @param facetResponse the facet information for the search results
455      * @param start the starting position for pagination
456      * @param pageSize the size of the current page
457      * @param offset the offset applied to the results
458      * @return QueryResponseList containing the search results and metadata
459      */
460     protected QueryResponseList createResponseList(final List<Map<String, Object>> documentList, final long allRecordCount,
461             final String allRecordCountRelation, final long queryTime, final boolean partialResults, final FacetResponse facetResponse,
462             final int start, final int pageSize, final int offset) {
463         return new QueryResponseList(documentList, allRecordCount, allRecordCountRelation, queryTime, partialResults, facetResponse, start,
464                 pageSize, offset);
465     }
466 
467     /**
468      * Converts an object value to a float for score calculations.
469      * Handles Float and String types, returning 0.0f for unsupported types.
470      *
471      * @param value the object to convert to float
472      * @return float representation of the value, or 0.0f if conversion fails
473      */
474     protected float toFloat(final Object value) {
475         if (value instanceof final Number n) {
476             return n.floatValue();
477         }
478         if (value instanceof final String s) {
479             try {
480                 return Float.parseFloat(s);
481             } catch (final NumberFormatException e) {
482                 if (logger.isDebugEnabled()) {
483                     logger.debug("Failed to parse float value: {}", s);
484                 }
485                 return 0.0f;
486             }
487         }
488         return 0.0f;
489     }
490 
491     /**
492      * Wrapper class for SearchRequestParams that allows overriding specific parameters.
493      * This wrapper is used to modify pagination parameters while preserving all other
494      * search request parameters from the parent object.
495      */
496     protected static class SearchRequestParamsWrapper extends SearchRequestParams {
497         private final SearchRequestParams parent;
498         private final int startPosition;
499         private final int pageSize;
500 
501         SearchRequestParamsWrapper(final SearchRequestParams parent, final int startPosition, final int pageSize) {
502             this.parent = parent;
503             this.startPosition = startPosition;
504             this.pageSize = pageSize;
505         }
506 
507         /**
508          * Gets the parent SearchRequestParams object that this wrapper delegates to.
509          *
510          * @return the parent SearchRequestParams instance
511          */
512         public SearchRequestParams getParent() {
513             return parent;
514         }
515 
516         @Override
517         public String getQuery() {
518             return parent.getQuery();
519         }
520 
521         @Override
522         public Map<String, String[]> getFields() {
523             return parent.getFields();
524         }
525 
526         @Override
527         public Map<String, String[]> getConditions() {
528             return parent.getConditions();
529         }
530 
531         @Override
532         public String[] getLanguages() {
533             return parent.getLanguages();
534         }
535 
536         @Override
537         public GeoInfo getGeoInfo() {
538             return parent.getGeoInfo();
539         }
540 
541         @Override
542         public FacetInfo getFacetInfo() {
543             return parent.getFacetInfo();
544         }
545 
546         @Override
547         public HighlightInfo getHighlightInfo() {
548             return parent.getHighlightInfo();
549         }
550 
551         @Override
552         public String getSort() {
553             return parent.getSort();
554         }
555 
556         @Override
557         public int getStartPosition() {
558             return startPosition;
559         }
560 
561         @Override
562         public int getOffset() {
563             return 0;
564         }
565 
566         @Override
567         public int getPageSize() {
568             return pageSize;
569         }
570 
571         @Override
572         public String[] getExtraQueries() {
573             return parent.getExtraQueries();
574         }
575 
576         @Override
577         public Object getAttribute(final String name) {
578             return parent.getAttribute(name);
579         }
580 
581         @Override
582         public Locale getLocale() {
583             return parent.getLocale();
584         }
585 
586         @Override
587         public SearchRequestType getType() {
588             return parent.getType();
589         }
590 
591         @Override
592         public String getSimilarDocHash() {
593             return parent.getSimilarDocHash();
594         }
595 
596         @Override
597         public String getTrackTotalHits() {
598             return parent.getTrackTotalHits();
599         }
600 
601         @Override
602         public Float getMinScore() {
603             return parent.getMinScore();
604         }
605 
606         @Override
607         public boolean hasConditionQuery() {
608             return parent.hasConditionQuery();
609         }
610 
611         @Override
612         public String[] getResponseFields() {
613             return parent.getResponseFields();
614         }
615 
616         @Override
617         public int hashCode() {
618             return parent.hashCode();
619         }
620 
621         @Override
622         public boolean equals(final Object obj) {
623             return parent.equals(obj);
624         }
625 
626         @Override
627         public String toString() {
628             return parent.toString();
629         }
630     }
631 
632     /**
633      * Sets the main searcher at index 0 of the searchers list.
634      * This method is used to configure the primary searcher for rank fusion processing.
635      * If searchers list is empty, adds the searcher; otherwise, replaces the first searcher.
636      *
637      * @param searcher the RankFusionSearcher to set as the main searcher
638      */
639     public void setSearcher(final RankFusionSearcher searcher) {
640         if (searchers.isEmpty()) {
641             searchers.add(searcher);
642         } else {
643             searchers.set(0, searcher);
644         }
645     }
646 
647     /**
648      * Registers a new searcher with the rank fusion processor.
649      * Adds the searcher to the searchers list and initializes the executor service
650      * if it hasn't been created yet. The executor service is created with a thread pool
651      * sized based on configuration or system capabilities.
652      *
653      * @param searcher the RankFusionSearcher to register
654      */
655     public void register(final RankFusionSearcher searcher) {
656         if (logger.isDebugEnabled()) {
657             logger.debug("Registering searcher: class={}, name={}", searcher.getClass().getSimpleName(), searcher.getName());
658         }
659         searchers.add(searcher);
660         synchronized (this) {
661             if (executorService == null) {
662                 int numThreads = ComponentUtil.getFessConfig().getRankFusionThreadsAsInteger();
663                 if (numThreads <= 0) {
664                     numThreads = Runtime.getRuntime().availableProcessors() * 3 / 2 + 1;
665                 }
666                 if (logger.isDebugEnabled()) {
667                     logger.debug("Initializing executor service with {} threads", numThreads);
668                 }
669                 executorService = Executors.newFixedThreadPool(numThreads);
670             }
671         }
672     }
673 }