1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
67
68
69
70
71
72
73
74 public class RankFusionProcessor implements AutoCloseable {
75
76 private static final Logger logger = LogManager.getLogger(RankFusionProcessor.class);
77
78
79 protected final List<RankFusionSearcher> searchers = new CopyOnWriteArrayList<>();
80
81
82 protected ExecutorService executorService;
83
84
85 protected int windowSize;
86
87
88 protected Set<String> availableSearcherNameSet;
89
90
91
92
93
94
95 public RankFusionProcessor() {
96
97 }
98
99
100
101
102
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
127
128
129 public void update() {
130 CommonPoolUtil.execute(this::load);
131 }
132
133
134
135
136
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
170
171
172
173
174
175
176
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
197
198
199
200
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
224
225
226
227
228
229
230
231
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();
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
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
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
355 if (searcherIndex == 0 && docRank < windowSize / 2) {
356 mainSearcherIdSet.add(id);
357 }
358 }
359 }
360 }
361
362
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
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
394
395
396
397
398
399
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
416
417
418
419
420
421
422
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
446
447
448
449
450
451
452
453
454
455
456
457
458
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
469
470
471
472
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
493
494
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
509
510
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
634
635
636
637
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
649
650
651
652
653
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 }