davidtran999 commited on
Commit
f81cf0b
·
verified ·
1 Parent(s): 3c4706a

Upload backend/core/hybrid_search.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. backend/core/hybrid_search.py +593 -0
backend/core/hybrid_search.py ADDED
@@ -0,0 +1,593 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Hybrid search combining BM25 and vector similarity.
3
+ """
4
+ from typing import List, Tuple, Optional, Dict, Any
5
+ import numpy as np
6
+ from django.db import connection
7
+ from django.db.models import QuerySet, F
8
+ from django.contrib.postgres.search import SearchQuery, SearchRank
9
+
10
+ from .embeddings import (
11
+ get_embedding_model,
12
+ generate_embedding,
13
+ cosine_similarity
14
+ )
15
+ from .embedding_utils import load_embedding
16
+ from .search_ml import expand_query_with_synonyms
17
+
18
+
19
+ # Default weights for hybrid search
20
+ DEFAULT_BM25_WEIGHT = 0.4
21
+ DEFAULT_VECTOR_WEIGHT = 0.6
22
+
23
+ # Minimum scores
24
+ DEFAULT_MIN_BM25_SCORE = 0.0
25
+ DEFAULT_MIN_VECTOR_SCORE = 0.1
26
+
27
+
28
+ def calculate_exact_match_boost(obj: Any, query: str, text_fields: List[str]) -> float:
29
+ """
30
+ Calculate boost score for exact keyword matches in title/name fields.
31
+
32
+ Args:
33
+ obj: Django model instance.
34
+ query: Search query string.
35
+ text_fields: List of field names to check (first 2 are usually title/name).
36
+
37
+ Returns:
38
+ Boost score (0.0 to 1.0).
39
+ """
40
+ if not query or not text_fields:
41
+ return 0.0
42
+
43
+ query_lower = query.lower().strip()
44
+ # Extract key phrases (2-3 word combinations) from query
45
+ query_words = query_lower.split()
46
+ key_phrases = []
47
+ for i in range(len(query_words) - 1):
48
+ phrase = " ".join(query_words[i:i+2])
49
+ if len(phrase) > 3:
50
+ key_phrases.append(phrase)
51
+ for i in range(len(query_words) - 2):
52
+ phrase = " ".join(query_words[i:i+3])
53
+ if len(phrase) > 5:
54
+ key_phrases.append(phrase)
55
+
56
+ # Also add individual words (longer than 2 chars)
57
+ query_words_set = set(word for word in query_words if len(word) > 2)
58
+
59
+ boost = 0.0
60
+
61
+ # Check primary fields (title, name) for exact matches
62
+ # First 2 fields are usually title/name
63
+ for field in text_fields[:2]:
64
+ if hasattr(obj, field):
65
+ field_value = str(getattr(obj, field, "")).lower()
66
+ if field_value:
67
+ # Check for key phrases first (highest priority)
68
+ for phrase in key_phrases:
69
+ if phrase in field_value:
70
+ # Major boost for phrase match
71
+ boost += 0.5
72
+ # Extra boost if it's the exact field value
73
+ if field_value.strip() == phrase.strip():
74
+ boost += 0.3
75
+
76
+ # Check for full query match
77
+ if query_lower in field_value:
78
+ boost += 0.4
79
+
80
+ # Count matched individual words
81
+ matched_words = sum(1 for word in query_words_set if word in field_value)
82
+ if matched_words > 0:
83
+ # Moderate boost for word matches
84
+ boost += 0.1 * min(matched_words, 3) # Cap at 3 words
85
+
86
+ return min(boost, 1.0) # Cap at 1.0 for very strong matches
87
+
88
+
89
+ def get_bm25_scores(
90
+ queryset: QuerySet,
91
+ query: str,
92
+ top_k: int = 20
93
+ ) -> List[Tuple[Any, float]]:
94
+ """
95
+ Get BM25 scores for queryset.
96
+
97
+ Args:
98
+ queryset: Django QuerySet to search.
99
+ query: Search query string.
100
+ top_k: Maximum number of results.
101
+
102
+ Returns:
103
+ List of (object, bm25_score) tuples.
104
+ """
105
+ if not query or connection.vendor != "postgresql":
106
+ return []
107
+
108
+ if not hasattr(queryset.model, "tsv_body"):
109
+ return []
110
+
111
+ try:
112
+ expanded_queries = expand_query_with_synonyms(query)
113
+ combined_query = None
114
+ for q_variant in expanded_queries:
115
+ variant_query = SearchQuery(q_variant, config="simple")
116
+ combined_query = variant_query if combined_query is None else combined_query | variant_query
117
+
118
+ if combined_query is not None:
119
+ ranked_qs = (
120
+ queryset
121
+ .annotate(rank=SearchRank(F("tsv_body"), combined_query))
122
+ .filter(rank__gt=DEFAULT_MIN_BM25_SCORE)
123
+ .order_by("-rank")
124
+ )
125
+ results = list(ranked_qs[:top_k * 2]) # Get more for hybrid ranking
126
+ return [(obj, float(getattr(obj, "rank", 0.0))) for obj in results]
127
+ except Exception as e:
128
+ print(f"Error in BM25 search: {e}")
129
+
130
+ return []
131
+
132
+
133
+ def get_vector_scores(
134
+ queryset: QuerySet,
135
+ query: str,
136
+ top_k: int = 20
137
+ ) -> List[Tuple[Any, float]]:
138
+ """
139
+ Get vector similarity scores for queryset.
140
+
141
+ Args:
142
+ queryset: Django QuerySet to search.
143
+ query: Search query string.
144
+ top_k: Maximum number of results.
145
+
146
+ Returns:
147
+ List of (object, vector_score) tuples.
148
+ """
149
+ if not query:
150
+ return []
151
+
152
+ # Generate query embedding
153
+ model = get_embedding_model()
154
+ if model is None:
155
+ return []
156
+
157
+ query_embedding = generate_embedding(query, model=model)
158
+ if query_embedding is None:
159
+ return []
160
+
161
+ # Get all objects with embeddings
162
+ all_objects = list(queryset)
163
+ if not all_objects:
164
+ return []
165
+
166
+ # Check dimension compatibility first
167
+ query_dim = len(query_embedding)
168
+ dimension_mismatch = False
169
+
170
+ # Calculate similarities
171
+ scores = []
172
+ for obj in all_objects:
173
+ obj_embedding = load_embedding(obj)
174
+ if obj_embedding is not None:
175
+ obj_dim = len(obj_embedding)
176
+ if obj_dim != query_dim:
177
+ # Dimension mismatch - skip vector search for this object
178
+ if not dimension_mismatch:
179
+ print(f"⚠️ Dimension mismatch: query={query_dim}, stored={obj_dim}. Skipping vector search.")
180
+ dimension_mismatch = True
181
+ continue
182
+ similarity = cosine_similarity(query_embedding, obj_embedding)
183
+ if similarity >= DEFAULT_MIN_VECTOR_SCORE:
184
+ scores.append((obj, similarity))
185
+
186
+ # If dimension mismatch detected, return empty to fall back to BM25 + exact match
187
+ if dimension_mismatch and not scores:
188
+ return []
189
+
190
+ # Sort by score descending
191
+ scores.sort(key=lambda x: x[1], reverse=True)
192
+ return scores[:top_k * 2] # Get more for hybrid ranking
193
+
194
+
195
+ def normalize_scores(scores: List[Tuple[Any, float]]) -> Dict[Any, float]:
196
+ """
197
+ Normalize scores to 0-1 range.
198
+
199
+ Args:
200
+ scores: List of (object, score) tuples.
201
+
202
+ Returns:
203
+ Dictionary mapping object to normalized score.
204
+ """
205
+ if not scores:
206
+ return {}
207
+
208
+ max_score = max(score for _, score in scores) if scores else 1.0
209
+ min_score = min(score for _, score in scores) if scores else 0.0
210
+
211
+ if max_score == min_score:
212
+ # All scores are the same, return uniform distribution
213
+ return {obj: 1.0 for obj, _ in scores}
214
+
215
+ # Normalize to 0-1
216
+ normalized = {}
217
+ for obj, score in scores:
218
+ normalized[obj] = (score - min_score) / (max_score - min_score)
219
+
220
+ return normalized
221
+
222
+
223
+ def hybrid_search(
224
+ queryset: QuerySet,
225
+ query: str,
226
+ top_k: int = 20,
227
+ bm25_weight: float = DEFAULT_BM25_WEIGHT,
228
+ vector_weight: float = DEFAULT_VECTOR_WEIGHT,
229
+ min_hybrid_score: float = 0.1,
230
+ text_fields: Optional[List[str]] = None
231
+ ) -> List[Any]:
232
+ """
233
+ Perform hybrid search combining BM25 and vector similarity.
234
+
235
+ Args:
236
+ queryset: Django QuerySet to search.
237
+ query: Search query string.
238
+ top_k: Maximum number of results.
239
+ bm25_weight: Weight for BM25 score (0-1).
240
+ vector_weight: Weight for vector score (0-1).
241
+ min_hybrid_score: Minimum combined score threshold.
242
+ text_fields: List of field names for exact match boost (optional).
243
+
244
+ Returns:
245
+ List of objects sorted by hybrid score.
246
+ """
247
+ if not query:
248
+ return list(queryset[:top_k])
249
+
250
+ # Normalize weights
251
+ total_weight = bm25_weight + vector_weight
252
+ if total_weight > 0:
253
+ bm25_weight = bm25_weight / total_weight
254
+ vector_weight = vector_weight / total_weight
255
+ else:
256
+ bm25_weight = 0.5
257
+ vector_weight = 0.5
258
+
259
+ # Get BM25 scores
260
+ bm25_results = get_bm25_scores(queryset, query, top_k=top_k)
261
+ bm25_scores = normalize_scores(bm25_results)
262
+
263
+ # Get vector scores
264
+ vector_results = get_vector_scores(queryset, query, top_k=top_k)
265
+ vector_scores = normalize_scores(vector_results)
266
+
267
+ # Combine scores
268
+ combined_scores = {}
269
+ all_objects = set()
270
+
271
+ # Add BM25 objects
272
+ for obj, _ in bm25_results:
273
+ all_objects.add(obj)
274
+ combined_scores[obj] = bm25_scores.get(obj, 0.0) * bm25_weight
275
+
276
+ # Add vector objects
277
+ for obj, _ in vector_results:
278
+ all_objects.add(obj)
279
+ if obj in combined_scores:
280
+ combined_scores[obj] += vector_scores.get(obj, 0.0) * vector_weight
281
+ else:
282
+ combined_scores[obj] = vector_scores.get(obj, 0.0) * vector_weight
283
+
284
+ # CRITICAL: Find exact matches FIRST using icontains, then apply boost
285
+ # This ensures exact matches are always found and prioritized
286
+ if text_fields:
287
+ query_lower = query.lower()
288
+ # Extract key phrases (2-word and 3-word) from query
289
+ query_words = query_lower.split()
290
+ key_phrases = []
291
+ # 2-word phrases
292
+ for i in range(len(query_words) - 1):
293
+ phrase = " ".join(query_words[i:i+2])
294
+ if len(phrase) > 3:
295
+ key_phrases.append(phrase)
296
+ # 3-word phrases
297
+ for i in range(len(query_words) - 2):
298
+ phrase = " ".join(query_words[i:i+3])
299
+ if len(phrase) > 5:
300
+ key_phrases.append(phrase)
301
+
302
+ # Find potential exact matches using icontains on name/title field
303
+ # This ensures we don't miss exact matches even if BM25/vector don't find them
304
+ exact_match_candidates = set()
305
+ primary_field = text_fields[0] if text_fields else "name"
306
+ if hasattr(queryset.model, primary_field):
307
+ # Search for key phrases in the primary field
308
+ for phrase in key_phrases:
309
+ filter_kwargs = {f"{primary_field}__icontains": phrase}
310
+ candidates = queryset.filter(**filter_kwargs)[:top_k * 2]
311
+ exact_match_candidates.update(candidates)
312
+
313
+ # Apply exact match boost to all candidates
314
+ for obj in exact_match_candidates:
315
+ if obj not in all_objects:
316
+ all_objects.add(obj)
317
+ combined_scores[obj] = 0.0
318
+
319
+ # Apply exact match boost (this should dominate)
320
+ boost = calculate_exact_match_boost(obj, query, text_fields)
321
+ if boost > 0:
322
+ # Exact match boost should dominate - set it high
323
+ combined_scores[obj] = max(combined_scores.get(obj, 0.0), boost)
324
+
325
+ # Also check objects already in results for exact matches
326
+ for obj in list(all_objects):
327
+ boost = calculate_exact_match_boost(obj, query, text_fields)
328
+ if boost > 0:
329
+ # Boost existing scores
330
+ combined_scores[obj] = max(combined_scores.get(obj, 0.0), boost)
331
+
332
+ # Filter by minimum score and sort
333
+ filtered_scores = [
334
+ (obj, score) for obj, score in combined_scores.items()
335
+ if score >= min_hybrid_score
336
+ ]
337
+ filtered_scores.sort(key=lambda x: x[1], reverse=True)
338
+
339
+ # Return top k
340
+ results = [obj for obj, _ in filtered_scores[:top_k]]
341
+
342
+ # Store hybrid score on objects for reference
343
+ for obj, score in filtered_scores[:top_k]:
344
+ obj._hybrid_score = score
345
+ obj._bm25_score = bm25_scores.get(obj, 0.0)
346
+ obj._vector_score = vector_scores.get(obj, 0.0)
347
+ # Store exact match boost if applied
348
+ if text_fields:
349
+ obj._exact_match_boost = calculate_exact_match_boost(obj, query, text_fields)
350
+ else:
351
+ obj._exact_match_boost = 0.0
352
+
353
+ return results
354
+
355
+
356
+ def semantic_query_expansion(query: str, top_n: int = 3) -> List[str]:
357
+ """
358
+ Expand query with semantically similar terms using embeddings.
359
+
360
+ Args:
361
+ query: Original query string.
362
+ top_n: Number of similar terms to add.
363
+
364
+ Returns:
365
+ List of expanded query variations.
366
+ """
367
+ try:
368
+ from hue_portal.chatbot.query_expansion import expand_query_semantically
369
+ return expand_query_semantically(query, context=None)
370
+ except Exception:
371
+ # Fallback to basic synonym expansion
372
+ return expand_query_with_synonyms(query)
373
+
374
+
375
+ def rerank_results(query: str, results: List[Any], text_fields: List[str], top_k: int = 5) -> List[Any]:
376
+ """
377
+ Rerank results using cross-encoder approach (recalculate similarity with query).
378
+
379
+ Args:
380
+ query: Search query.
381
+ results: List of result objects.
382
+ text_fields: List of field names to use for reranking.
383
+ top_k: Number of top results to return.
384
+
385
+ Returns:
386
+ Reranked list of results.
387
+ """
388
+ if not results or not query:
389
+ return results[:top_k]
390
+
391
+ try:
392
+ # Generate query embedding
393
+ model = get_embedding_model()
394
+ if model is None:
395
+ return results[:top_k]
396
+
397
+ query_embedding = generate_embedding(query, model=model)
398
+ if query_embedding is None:
399
+ return results[:top_k]
400
+
401
+ # Calculate similarity for each result
402
+ scored_results = []
403
+ for obj in results:
404
+ # Create text representation from text_fields
405
+ text_parts = []
406
+ for field in text_fields:
407
+ if hasattr(obj, field):
408
+ value = getattr(obj, field, "")
409
+ if value:
410
+ text_parts.append(str(value))
411
+
412
+ if not text_parts:
413
+ continue
414
+
415
+ obj_text = " ".join(text_parts)
416
+ obj_embedding = generate_embedding(obj_text, model=model)
417
+
418
+ if obj_embedding is not None:
419
+ similarity = cosine_similarity(query_embedding, obj_embedding)
420
+ scored_results.append((obj, similarity))
421
+
422
+ # Sort by similarity and return top_k
423
+ scored_results.sort(key=lambda x: x[1], reverse=True)
424
+ return [obj for obj, _ in scored_results[:top_k]]
425
+ except Exception as e:
426
+ print(f"Error in reranking: {e}")
427
+ return results[:top_k]
428
+
429
+
430
+ def diversify_results(results: List[Any], top_k: int = 5, similarity_threshold: float = 0.8) -> List[Any]:
431
+ """
432
+ Ensure diversity in results by removing very similar items.
433
+
434
+ Args:
435
+ results: List of result objects.
436
+ top_k: Number of results to return.
437
+ similarity_threshold: Maximum similarity allowed between results.
438
+
439
+ Returns:
440
+ Diversified list of results.
441
+ """
442
+ if len(results) <= top_k:
443
+ return results
444
+
445
+ try:
446
+ model = get_embedding_model()
447
+ if model is None:
448
+ return results[:top_k]
449
+
450
+ # Generate embeddings for all results
451
+ result_embeddings = []
452
+ valid_results = []
453
+
454
+ for obj in results:
455
+ # Try to get embedding from object
456
+ obj_embedding = load_embedding(obj)
457
+ if obj_embedding is not None:
458
+ result_embeddings.append(obj_embedding)
459
+ valid_results.append(obj)
460
+
461
+ if len(valid_results) <= top_k:
462
+ return valid_results
463
+
464
+ # Select diverse results using Maximal Marginal Relevance (MMR)
465
+ selected = [valid_results[0]] # Always include first (highest score)
466
+ selected_indices = {0}
467
+ selected_embeddings = [result_embeddings[0]]
468
+
469
+ for _ in range(min(top_k - 1, len(valid_results) - 1)):
470
+ best_score = -1
471
+ best_idx = -1
472
+
473
+ for i, (obj, emb) in enumerate(zip(valid_results, result_embeddings)):
474
+ if i in selected_indices:
475
+ continue
476
+
477
+ # Calculate max similarity to already selected results
478
+ max_sim = 0.0
479
+ for sel_emb in selected_embeddings:
480
+ sim = cosine_similarity(emb, sel_emb)
481
+ max_sim = max(max_sim, sim)
482
+
483
+ # Score: prefer results with lower similarity to selected ones
484
+ score = 1.0 - max_sim
485
+
486
+ if score > best_score:
487
+ best_score = score
488
+ best_idx = i
489
+
490
+ if best_idx >= 0:
491
+ selected.append(valid_results[best_idx])
492
+ selected_indices.add(best_idx)
493
+ selected_embeddings.append(result_embeddings[best_idx])
494
+
495
+ return selected
496
+ except Exception as e:
497
+ print(f"Error in diversifying results: {e}")
498
+ return results[:top_k]
499
+
500
+
501
+ def search_with_hybrid(
502
+ queryset: QuerySet,
503
+ query: str,
504
+ text_fields: List[str],
505
+ top_k: int = 20,
506
+ min_score: float = 0.1,
507
+ use_hybrid: bool = True,
508
+ bm25_weight: float = DEFAULT_BM25_WEIGHT,
509
+ vector_weight: float = DEFAULT_VECTOR_WEIGHT,
510
+ use_reranking: bool = False,
511
+ use_diversification: bool = False
512
+ ) -> QuerySet:
513
+ """
514
+ Search with hybrid BM25 + vector, with fallback to BM25-only or TF-IDF.
515
+
516
+ Args:
517
+ queryset: Django QuerySet to search.
518
+ query: Search query string.
519
+ text_fields: List of field names (for fallback).
520
+ top_k: Maximum number of results.
521
+ min_score: Minimum score threshold.
522
+ use_hybrid: Whether to use hybrid search.
523
+ bm25_weight: Weight for BM25 in hybrid search.
524
+ vector_weight: Weight for vector in hybrid search.
525
+
526
+ Returns:
527
+ Filtered and ranked QuerySet.
528
+ """
529
+ if not query:
530
+ return queryset[:top_k]
531
+
532
+ # Try hybrid search if enabled
533
+ if use_hybrid:
534
+ try:
535
+ hybrid_results = hybrid_search(
536
+ queryset,
537
+ query,
538
+ top_k=top_k,
539
+ bm25_weight=bm25_weight,
540
+ vector_weight=vector_weight,
541
+ min_hybrid_score=min_score,
542
+ text_fields=text_fields
543
+ )
544
+
545
+ if hybrid_results:
546
+ # Apply reranking if enabled
547
+ if use_reranking and len(hybrid_results) > top_k:
548
+ hybrid_results = rerank_results(query, hybrid_results, text_fields, top_k=top_k * 2)
549
+
550
+ # Apply diversification if enabled
551
+ if use_diversification:
552
+ hybrid_results = diversify_results(hybrid_results, top_k=top_k)
553
+
554
+ # Convert to QuerySet with preserved order
555
+ result_ids = [obj.id for obj in hybrid_results[:top_k]]
556
+ if result_ids:
557
+ from django.db.models import Case, When, IntegerField
558
+ preserved = Case(
559
+ *[When(pk=pk, then=pos) for pos, pk in enumerate(result_ids)],
560
+ output_field=IntegerField()
561
+ )
562
+ return queryset.filter(id__in=result_ids).order_by(preserved)
563
+ except Exception as e:
564
+ print(f"Hybrid search failed, falling back: {e}")
565
+
566
+ # Fallback to BM25-only
567
+ if connection.vendor == "postgresql" and hasattr(queryset.model, "tsv_body"):
568
+ try:
569
+ expanded_queries = expand_query_with_synonyms(query)
570
+ combined_query = None
571
+ for q_variant in expanded_queries:
572
+ variant_query = SearchQuery(q_variant, config="simple")
573
+ combined_query = variant_query if combined_query is None else combined_query | variant_query
574
+
575
+ if combined_query is not None:
576
+ ranked_qs = (
577
+ queryset
578
+ .annotate(rank=SearchRank(F("tsv_body"), combined_query))
579
+ .filter(rank__gt=0)
580
+ .order_by("-rank")
581
+ )
582
+ results = list(ranked_qs[:top_k])
583
+ if results:
584
+ for obj in results:
585
+ obj._ml_score = getattr(obj, "rank", 0.0)
586
+ return results
587
+ except Exception:
588
+ pass
589
+
590
+ # Final fallback: import and use original search_with_ml
591
+ from .search_ml import search_with_ml
592
+ return search_with_ml(queryset, query, text_fields, top_k=top_k, min_score=min_score)
593
+