ktsn-ud commited on
Commit
881407b
·
1 Parent(s): 41e01be

codex生成: bm35fとword_simのバランスを調整

Browse files
Files changed (3) hide show
  1. api/main.py +6 -3
  2. api/search/engine.py +83 -18
  3. config/search_model.json +3 -2
api/main.py CHANGED
@@ -100,12 +100,15 @@ def search(request: SearchRequest):
100
  if not request.query:
101
  raise HTTPException(status_code=400, detail="Query cannot be empty")
102
 
103
- pairs = engine.search(request.query, debug=request.debug)
104
- ids = [pid for pid, _ in pairs]
105
  if request.debug:
106
- # Return scores as well for tuning. Bypass response_model filtering.
 
107
  return JSONResponse(content={
108
  "projectIds": ids,
109
  "scores": [{"projectId": pid, "score": float(score)} for pid, score in pairs],
 
110
  })
 
 
111
  return schema_projects.ProjectIds(projectIds=ids)
 
100
  if not request.query:
101
  raise HTTPException(status_code=400, detail="Query cannot be empty")
102
 
103
+ result = engine.search(request.query, debug=request.debug)
 
104
  if request.debug:
105
+ pairs, diag = result # type: ignore
106
+ ids = [pid for pid, _ in pairs]
107
  return JSONResponse(content={
108
  "projectIds": ids,
109
  "scores": [{"projectId": pid, "score": float(score)} for pid, score in pairs],
110
+ "details": diag.get("details", []),
111
  })
112
+ pairs = result # type: ignore
113
+ ids = [pid for pid, _ in pairs]
114
  return schema_projects.ProjectIds(projectIds=ids)
api/search/engine.py CHANGED
@@ -25,6 +25,7 @@ class SearchConfig:
25
  word_sim_enable: bool
26
  word_sim_alpha: float
27
  word_sim_topk_k: int
 
28
  query_subword_enable: bool
29
  query_subword_path: str
30
  query_subword_oov_weight: float
@@ -95,6 +96,7 @@ class SearchEngine:
95
  word_sim_enable=bool(search("word_sim.enable")),
96
  word_sim_alpha=float(search("word_sim.alpha")),
97
  word_sim_topk_k=int(search("word_sim.topk_k", 3)),
 
98
  query_subword_enable=bool(search("query_subword.enable")),
99
  query_subword_path=search("query_subword.path"),
100
  query_subword_oov_weight=float(search("query_subword.oov_weight")),
@@ -275,7 +277,7 @@ class SearchEngine:
275
  return None, True
276
  return None, True
277
 
278
- def _word_sim_scores(self, terms: List[str]) -> Optional[np.ndarray]:
279
  if not self.cfg.word_sim_enable:
280
  return None
281
  if self.word_vectors is None:
@@ -347,28 +349,68 @@ class SearchEngine:
347
  sims_all[i] = float(top_vals.mean()) if top_vals.size else 0.0
348
  return sims_all
349
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
  # ----- Public API -----
351
  def search(
352
  self,
353
  query: str,
354
  debug: bool = False,
355
- ) -> List[Tuple[str, float]]:
356
  terms = self._tokenize(query)
357
  if self.cfg.synonyms_enable:
358
  terms = self._expand_synonyms(terms)
359
 
360
  # BM25F
361
  bm25 = self._bm25f_scores(terms)
362
- score = bm25.copy()
363
 
364
- # word sim
365
- ws = self._word_sim_scores(terms)
366
- if ws is not None:
367
- a = float(self.cfg.word_sim_alpha)
368
- score = a * score + (1.0 - a) * ws
369
- else:
370
- # use zero vector for filtering logic
371
- ws = np.zeros_like(score)
372
 
373
  # Organization/reading auto-boost based on raw query substring match
374
  qn = normalize_text_for_org(query)
@@ -392,17 +434,18 @@ class SearchEngine:
392
  + prefix.astype(np.float32) * float(self.cfg.org_boost_prefix)
393
  + substr.astype(np.float32) * float(self.cfg.org_boost_substring)
394
  )
395
- score = score + boost
396
 
397
  # collect results
398
  ids = [d.get("projectId") for d in self.projects]
399
  # Filtering to reduce false positives while keeping recall
400
  # Relative threshold anchored to the top fused score
401
- top = float(np.max(score)) if score.size > 0 else 0.0
 
402
  rel_cut = top * float(self.cfg.fused_rel_top_ratio) if top > 0 else self.cfg.fused_min
403
  fused_cut = max(float(self.cfg.fused_min), rel_cut)
404
- keep = ((bm25 >= self.cfg.bm25_min) | (ws >= self.cfg.word_sim_min) | (score >= self.cfg.fused_min)) & (score >= fused_cut)
405
- order = np.argsort(-score) # descending by fused
406
  selected_idx: List[int] = []
407
  for i in order:
408
  if keep[i]:
@@ -411,17 +454,39 @@ class SearchEngine:
411
  break
412
  # Single-step fallback: if zero, relax the relative cut and use absolute thresholds only
413
  if len(selected_idx) == 0:
414
- keep2 = (bm25 >= self.cfg.bm25_min) | (ws >= self.cfg.word_sim_min) | (score >= self.cfg.fused_min)
415
  for i in order:
416
  if keep2[i]:
417
  selected_idx.append(int(i))
418
  if len(selected_idx) >= self.cfg.max_results:
419
  break
420
- pairs = [(ids[i], float(score[i])) for i in selected_idx]
 
 
 
 
 
 
 
 
421
 
422
  # sort
423
  pairs.sort(key=lambda x: (-x[1], x[0]))
424
- return pairs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
425
 
426
  def get_projects(self) -> List[Dict[str, Any]]:
427
  return self.projects
 
25
  word_sim_enable: bool
26
  word_sim_alpha: float
27
  word_sim_topk_k: int
28
+ word_sim_rerank: str
29
  query_subword_enable: bool
30
  query_subword_path: str
31
  query_subword_oov_weight: float
 
96
  word_sim_enable=bool(search("word_sim.enable")),
97
  word_sim_alpha=float(search("word_sim.alpha")),
98
  word_sim_topk_k=int(search("word_sim.topk_k", 3)),
99
+ word_sim_rerank=(search("word_sim.rerank", "pair_avg") or "pair_avg").lower(),
100
  query_subword_enable=bool(search("query_subword.enable")),
101
  query_subword_path=search("query_subword.path"),
102
  query_subword_oov_weight=float(search("query_subword.oov_weight")),
 
277
  return None, True
278
  return None, True
279
 
280
+ def _word_sim_scores_topk(self, terms: List[str]) -> Optional[np.ndarray]:
281
  if not self.cfg.word_sim_enable:
282
  return None
283
  if self.word_vectors is None:
 
349
  sims_all[i] = float(top_vals.mean()) if top_vals.size else 0.0
350
  return sims_all
351
 
352
+ def _word_sim_scores_pairavg(self, terms: List[str]) -> Optional[np.ndarray]:
353
+ if not self.cfg.word_sim_enable:
354
+ return None
355
+ if self.word_vectors is None:
356
+ return None
357
+ # Build query term vectors (no weighting for pair-avg, simple mean over all pairs)
358
+ vecs = []
359
+ for t in terms:
360
+ v, _ = self._get_token_vector(t)
361
+ if v is None:
362
+ continue
363
+ vecs.append(v)
364
+ if not vecs:
365
+ return None
366
+ Vq = np.stack(vecs).astype(np.float32) # Tq x D
367
+
368
+ n_docs = len(self.tf_token_docs)
369
+ sims_all = np.zeros((n_docs,), dtype=np.float32)
370
+ for i, d in enumerate(self.tf_token_docs):
371
+ fields = d.get("fields") or {}
372
+ doc_terms = set()
373
+ for fname in self.cfg.target_fields:
374
+ fobj = (fields.get(fname) or {})
375
+ tf = (fobj.get("tf") or {})
376
+ doc_terms.update(tf.keys())
377
+ if not doc_terms:
378
+ sims_all[i] = 0.0
379
+ continue
380
+ Vd_list = []
381
+ for t in doc_terms:
382
+ idx = self.word_vocab.get(t)
383
+ if idx is None:
384
+ continue
385
+ Vd_list.append(self.word_vectors[idx])
386
+ if not Vd_list:
387
+ sims_all[i] = 0.0
388
+ continue
389
+ Vd = np.stack(Vd_list).astype(np.float32) # Td x D
390
+ M = Vd @ Vq.T # Td x Tq
391
+ M = np.maximum(M, 0.0)
392
+ sims_all[i] = float(M.mean()) if M.size else 0.0
393
+ return sims_all
394
+
395
  # ----- Public API -----
396
  def search(
397
  self,
398
  query: str,
399
  debug: bool = False,
400
+ ) -> List[Tuple[str, float]] | Tuple[List[Tuple[str, float]], Dict[str, Any]]:
401
  terms = self._tokenize(query)
402
  if self.cfg.synonyms_enable:
403
  terms = self._expand_synonyms(terms)
404
 
405
  # BM25F
406
  bm25 = self._bm25f_scores(terms)
 
407
 
408
+ # word sim (filtering): top-k pooling
409
+ ws_filter = self._word_sim_scores_topk(terms)
410
+ if ws_filter is None:
411
+ ws_filter = np.zeros_like(bm25)
412
+ a = float(self.cfg.word_sim_alpha)
413
+ fused_filter = a * bm25 + (1.0 - a) * ws_filter
 
 
414
 
415
  # Organization/reading auto-boost based on raw query substring match
416
  qn = normalize_text_for_org(query)
 
434
  + prefix.astype(np.float32) * float(self.cfg.org_boost_prefix)
435
  + substr.astype(np.float32) * float(self.cfg.org_boost_substring)
436
  )
437
+ pass
438
 
439
  # collect results
440
  ids = [d.get("projectId") for d in self.projects]
441
  # Filtering to reduce false positives while keeping recall
442
  # Relative threshold anchored to the top fused score
443
+ score_with_boost = fused_filter + boost
444
+ top = float(np.max(score_with_boost)) if score_with_boost.size > 0 else 0.0
445
  rel_cut = top * float(self.cfg.fused_rel_top_ratio) if top > 0 else self.cfg.fused_min
446
  fused_cut = max(float(self.cfg.fused_min), rel_cut)
447
+ keep = ((bm25 >= self.cfg.bm25_min) | (ws_filter >= self.cfg.word_sim_min) | (score_with_boost >= self.cfg.fused_min)) & (score_with_boost >= fused_cut)
448
+ order = np.argsort(-score_with_boost) # descending by fused
449
  selected_idx: List[int] = []
450
  for i in order:
451
  if keep[i]:
 
454
  break
455
  # Single-step fallback: if zero, relax the relative cut and use absolute thresholds only
456
  if len(selected_idx) == 0:
457
+ keep2 = (bm25 >= self.cfg.bm25_min) | (ws_filter >= self.cfg.word_sim_min) | (score_with_boost >= self.cfg.fused_min)
458
  for i in order:
459
  if keep2[i]:
460
  selected_idx.append(int(i))
461
  if len(selected_idx) >= self.cfg.max_results:
462
  break
463
+ # Rerank with pair-avg word similarity (if enabled)
464
+ ws_rerank = None
465
+ if self.cfg.word_sim_rerank == "pair_avg":
466
+ ws_rerank = self._word_sim_scores_pairavg(terms)
467
+ if ws_rerank is None:
468
+ ws_rerank = ws_filter
469
+ fused_rerank = a * bm25 + (1.0 - a) * ws_rerank
470
+ final_scores = fused_rerank + boost
471
+ pairs = [(ids[i], float(final_scores[i])) for i in selected_idx]
472
 
473
  # sort
474
  pairs.sort(key=lambda x: (-x[1], x[0]))
475
+ if not debug:
476
+ return pairs
477
+ # build debug details for selected docs
478
+ details = []
479
+ for i in selected_idx:
480
+ details.append({
481
+ "projectId": ids[i],
482
+ "bm25": float(bm25[i]),
483
+ "ws_filter_topk": float(ws_filter[i]),
484
+ "ws_rerank_pairavg": float(ws_rerank[i]) if ws_rerank is not None else None,
485
+ "org_boost": float(boost[i]),
486
+ "fused_filter": float(fused_filter[i]),
487
+ "fused_final": float(final_scores[i]),
488
+ })
489
+ return pairs, {"details": details}
490
 
491
  def get_projects(self) -> List[Dict[str, Any]]:
492
  return self.projects
config/search_model.json CHANGED
@@ -45,8 +45,9 @@
45
  "word_sim": {
46
  "enable": true,
47
  "mode": "topk",
48
- "alpha": 0.7,
49
- "topk_k": 3
 
50
  },
51
  "query_subword": {
52
  "enable": true,
 
45
  "word_sim": {
46
  "enable": true,
47
  "mode": "topk",
48
+ "alpha": 0.3,
49
+ "topk_k": 3,
50
+ "rerank": "pair_avg"
51
  },
52
  "query_subword": {
53
  "enable": true,