yukikase's picture
feat: サークル検索API実装(BM25F + fastText対応)
568bbca
Raw
History Blame Contribute Delete
24.3 kB
import json
import os
import sys
import unicodedata
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Set, Tuple
import numpy as np
from sudachipy import dictionary, tokenizer
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
from utils.json import field_getter
from utils.logger import setup_logger
log = setup_logger(__name__)
@dataclass
class SearchConfig:
target_pos_l1: List[str]
target_fields: List[str]
k1: float
b: float
field_weights: Dict[str, float]
synonyms_enable: bool
syn_limits: Dict[str, int]
banlist: List[str]
word_sim_enable: bool
word_sim_alpha: float
word_sim_topk_k: int
word_sim_rerank: str
query_subword_enable: bool
query_subword_path: str
query_subword_oov_weight: float
org_boost_exact: float
org_boost_prefix: float
org_boost_substring: float
org_boost_min_len: int
# filter
min_results: int
max_results: int
bm25_min: float
word_sim_min: float
fused_min: float
fused_rel_top_ratio: float
def normalize_text_for_org(s: str) -> str:
try:
import unicodedata
s = unicodedata.normalize("NFKC", s)
except Exception:
pass
s = " ".join(s.split())
return s
class SearchEngine:
def __init__(self):
self.cfg: Optional[SearchConfig] = None
self.tokenizer = None
self.mode = None
self.stopwords: set[str] = set()
self.custom_synonyms: Dict[str, List[str]] = {}
self.synonyms_cache: Dict[str, List[str]] = {}
# Data (project -> circle)
self.circles: List[Dict[str, Any]] = []
self.circle_map: Dict[str, Dict[str, Any]] = {}
self.circle_idx: Dict[str, int] = {}
self.org_norms: Dict[str, str] = {}
self.reading_norms: Dict[str, str] = {}
self.substring_index: Dict[str, List[str]] = {}
# BM25F assets
self.idf: Dict[str, float] = {}
self.avg_len: Dict[str, float] = {}
self.tf_token_docs: List[Dict[str, Any]] = []
# Vectors
self.word_vocab: Dict[str, int] = {}
self.word_vectors: Optional[np.ndarray] = None
self.ft_model = None
# ----- Init / Load -----
def initialize(self):
files = field_getter("config/files.json")
search = field_getter("config/search_model.json")
# Config
self.cfg = SearchConfig(
target_pos_l1=search("target_pos_l1"),
target_fields=search("target_fields"),
k1=float(search("bm25f.k1")),
b=float(search("bm25f.b")),
field_weights=search("bm25f.field_weights"),
synonyms_enable=bool(search("synonyms.enable")),
syn_limits=search("synonyms.limits"),
banlist=search("synonyms.banlist"),
word_sim_enable=bool(search("word_sim.enable")),
word_sim_alpha=float(search("word_sim.alpha")),
word_sim_topk_k=int(search("word_sim.topk_k", 3)),
word_sim_rerank=(
search("word_sim.rerank", "pair_avg") or "pair_avg"
).lower(),
query_subword_enable=bool(search("query_subword.enable")),
query_subword_path=files("embeddings.fasttext_bin"),
query_subword_oov_weight=float(search("query_subword.oov_weight")),
org_boost_exact=float(search("org_boost.exact", 1.5)),
org_boost_prefix=float(search("org_boost.prefix", 0.9)),
org_boost_substring=float(search("org_boost.substring", 0.6)),
org_boost_min_len=int(search("org_boost.min_len", 2)),
min_results=int(search("filter.min_results", 20)),
max_results=int(search("filter.max_results", 100)),
bm25_min=float(search("filter.bm25_min", 0.5)),
word_sim_min=float(search("filter.word_sim_min", 0.3)),
fused_min=float(search("filter.fused_min", 0.4)),
fused_rel_top_ratio=float(search("filter.fused_rel_top_ratio", 0.7)),
)
# Tokenizer
sudachi_config_path = files("sudachi.sudachi_config")
tok = dictionary.Dictionary(config_path=sudachi_config_path).create()
self.tokenizer = tok
self.mode = tokenizer.Tokenizer.SplitMode.A
# Stopwords
with open(files("sudachi.stopwords"), encoding="utf-8") as f:
self.stopwords = set(json.load(f))
# Synonyms assets
try:
syn_cache_path = files("sudachi.synonyms_cache")
if os.path.exists(syn_cache_path):
with open(syn_cache_path, encoding="utf-8") as f:
self.synonyms_cache = json.load(f)
except Exception as e:
log.warning(f"failed to load synonyms_cache: {e}")
try:
custom_path = field_getter("config/search_model.json")(
"synonyms.sources.custom_json"
)
if custom_path and os.path.exists(custom_path):
with open(custom_path, encoding="utf-8") as f:
self.custom_synonyms = json.load(f)
except Exception:
pass
# Substring index for organization substring lookup
substring_index_path = files("substring.substring_index")
if os.path.exists(substring_index_path):
try:
with open(substring_index_path, encoding="utf-8") as f:
self.substring_index = json.load(f)
except Exception as e:
log.warning(f"failed to load substring_index: {e}")
else:
self.substring_index = {}
# Circles (projects -> circles)
with open(files("circles.circles_json"), encoding="utf-8") as f:
self.circles = json.load(f)
self.circle_map = {c["circleId"]: c for c in self.circles}
self.circle_idx = {c["circleId"]: idx for idx, c in enumerate(self.circles)}
self.org_norms = {
c["circleId"]: normalize_text_for_org(c.get("circleName") or "")
for c in self.circles
}
self.reading_norms = {
c["circleId"]: normalize_text_for_org(c.get("circleNameKana") or "")
for c in self.circles
}
# BM25F assets
with open(files("bm25.bm25_meta"), encoding="utf-8") as f:
meta = json.load(f)
self.idf = meta.get("idf", {})
self.avg_len = meta.get("avg_len", {})
with open(files("bm25.tf_token"), encoding="utf-8") as f:
self.tf_token_docs = json.load(f)
# Vectors
try:
vocab_path = files("embeddings.word_vocab")
vec_path = files("embeddings.word_vectors")
if os.path.exists(vocab_path) and os.path.exists(vec_path):
with open(vocab_path, encoding="utf-8") as f:
self.word_vocab = {k: int(v) for k, v in json.load(f).items()}
self.word_vectors = np.load(vec_path)["vectors"]
except Exception as e:
log.warning(f"word vectors not ready: {e}")
# doc_vectors.npy は topk 方式では不要
# fastText OOV
if (
self.cfg.query_subword_enable
and self.cfg.query_subword_path
and os.path.exists(self.cfg.query_subword_path)
):
try:
import fasttext
self.ft_model = fasttext.load_model(self.cfg.query_subword_path)
log.info("fastText .bin loaded for OOV")
except Exception as e:
log.warning(f"failed to load fastText .bin: {e}")
# ----- Tokenize / Synonyms -----
def _tokenize(self, text: str) -> List[str]:
if not text:
return []
out: List[str] = []
for m in self.tokenizer.tokenize(text, self.mode):
base = m.normalized_form().lower().strip()
if not base:
continue
pos = m.part_of_speech()
if pos[0] not in self.cfg.target_pos_l1:
continue
if base in self.stopwords or base in self.cfg.banlist:
continue
out.append(base)
return out
def _expand_synonyms(self, terms: List[str]) -> List[str]:
if not self.cfg.synonyms_enable:
return terms
max_exp = int(self.cfg.syn_limits.get("max_expansions_per_term", 4))
min_len = int(self.cfg.syn_limits.get("min_char_len", 2))
expanded: List[str] = []
for t in terms:
expanded.append(t)
cands = []
cands.extend(self.synonyms_cache.get(t, []))
cands.extend(self.custom_synonyms.get(t, []))
# filter/unique
uniq = []
seen = set()
for c in cands:
if c in seen or len(c) < min_len or c in self.cfg.banlist:
continue
seen.add(c)
uniq.append(c)
if len(uniq) >= max_exp:
break
expanded.extend(uniq)
# overall limit
max_q = int(self.cfg.syn_limits.get("max_query_variants", 5))
return expanded[: max_q * max_exp + len(terms)]
@staticmethod
def _katakana_to_hiragana(text: str) -> str:
if not text:
return ""
chars: List[str] = []
for ch in text:
code = ord(ch)
if 0x30A1 <= code <= 0x30F6:
chars.append(chr(code - 0x60))
else:
chars.append(ch)
return "".join(chars)
def _normalize_substring_token(self, token: str) -> str:
if not token:
return ""
try:
token_nfkc = unicodedata.normalize("NFKC", token)
except Exception:
token_nfkc = token
readings: List[str] = []
if self.tokenizer is not None:
try:
for m in self.tokenizer.tokenize(
token_nfkc, tokenizer.Tokenizer.SplitMode.C
):
reading = m.reading_form()
if not reading or reading == "*":
reading = m.normalized_form()
if reading:
readings.append(reading)
except Exception:
readings = []
reading = "".join(readings) if readings else token_nfkc
lowered = reading.lower()
hira = self._katakana_to_hiragana(lowered)
normalized_chars: List[str] = []
for ch in hira:
if ch in ("\u0020", "\u3000"):
continue
category = unicodedata.category(ch)
if category.startswith("P") or category.startswith("S"):
if ch != "ー":
continue
normalized_chars.append(ch)
return "".join(normalized_chars)
def _normalize_substring_terms(self, query: str) -> List[str]:
if not query:
return []
try:
normalized_query = unicodedata.normalize("NFKC", query)
except Exception:
normalized_query = query
out: List[str] = []
for raw in normalized_query.split():
term = self._normalize_substring_token(raw)
if term:
out.append(term)
return out
def _substring_match_circle_ids(self, query: str) -> Set[str]:
if not self.substring_index:
return set()
terms = self._normalize_substring_terms(query)
matches: Set[str] = set()
for term in terms:
if len(term) < 2:
continue
matches.update(self.substring_index.get(term, []))
return matches
# ----- BM25F -----
def _bm25f_scores(self, terms: List[str]) -> np.ndarray:
N = len(self.tf_token_docs)
if N == 0:
return np.zeros((0,), dtype=np.float32)
k1 = self.cfg.k1
b = self.cfg.b
fw = self.cfg.field_weights
scores = np.zeros((N,), dtype=np.float32)
idf = self.idf
avg_len = self.avg_len
# For quick access, build list of per-doc per-field structures
for i, d in enumerate(self.tf_token_docs):
fields = d.get("fields") or {}
s = 0.0
for t in terms:
idf_t = float(idf.get(t, 0.0))
if idf_t <= 0.0:
continue
denom_sum = 0.0
num_sum = 0.0
for field, weight in fw.items():
fobj = fields.get(field) or {}
tf = float((fobj.get("tf") or {}).get(t, 0))
if tf <= 0.0:
continue
len_f = float(fobj.get("len", 0))
avg_f = float(avg_len.get(field, 0.0)) or 1.0
norm = k1 * (1 - b + b * (len_f / avg_f))
num_sum += weight * tf * (k1 + 1.0)
denom_sum += weight * (tf + norm)
if denom_sum > 0:
s += idf_t * (num_sum / denom_sum)
scores[i] = s
return scores
# ----- Word similarity -----
def _get_token_vector(self, t: str) -> Tuple[Optional[np.ndarray], bool]:
if self.word_vectors is not None and t in self.word_vocab:
v = self.word_vectors[self.word_vocab[t]]
return v, False
if self.ft_model is not None:
try:
v = self.ft_model.get_word_vector(t)
v = v.astype(np.float32)
n = np.linalg.norm(v)
if n > 0:
v = v / n
return v, True
except Exception:
return None, True
return None, True
def _word_sim_scores_topk(self, terms: List[str]) -> Optional[np.ndarray]:
if not self.cfg.word_sim_enable:
return None
if self.word_vectors is None:
return None
# Build per-term vectors with weights (IDF; OOV down-weighted)
weights = []
vecs = []
for t in terms:
v, oov = self._get_token_vector(t)
if v is None:
continue
w = float(self.idf.get(t, 0.0))
if oov:
w *= float(self.cfg.query_subword_oov_weight)
if w <= 0:
continue
vecs.append(v)
weights.append(w)
if not vecs:
return None
V = np.stack(vecs).astype(np.float32) # T x D
W = np.asarray(weights, dtype=np.float32) # T
# top-k pooling over term-term cosine contributions (query terms x document terms)
k = max(1, int(self.cfg.word_sim_topk_k))
n_docs = len(self.tf_token_docs)
sims_all = np.zeros((n_docs,), dtype=np.float32)
Vq = V # Tq x D (normalized)
Wq = W # Tq
for i, d in enumerate(self.tf_token_docs):
fields = d.get("fields") or {}
doc_terms = set()
for fname in self.cfg.target_fields:
fobj = fields.get(fname) or {}
tf = fobj.get("tf") or {}
doc_terms.update(tf.keys())
if not doc_terms:
sims_all[i] = 0.0
continue
Vd_list = []
for t in doc_terms:
idx = self.word_vocab.get(t)
if idx is None:
continue
Vd_list.append(self.word_vectors[idx])
if not Vd_list:
sims_all[i] = 0.0
continue
Vd = np.stack(Vd_list).astype(np.float32) # Td x D
M = Vd @ Vq.T # Td x Tq
if Wq.size:
M = M * Wq[None, :]
M = np.maximum(M, 0.0)
Td, Tq = M.shape
total = Td * Tq
kk = min(k, total) if total > 0 else 0
if kk == 0:
sims_all[i] = 0.0
continue
flat = M.reshape(-1)
if kk == total:
top_vals = flat
else:
idxk = np.argpartition(flat, -kk)[-kk:]
top_vals = flat[idxk]
sims_all[i] = float(top_vals.mean()) if top_vals.size else 0.0
return sims_all
def _word_sim_scores_pairavg(self, terms: List[str]) -> Optional[np.ndarray]:
if not self.cfg.word_sim_enable:
return None
if self.word_vectors is None:
return None
# Build query term vectors (no weighting for pair-avg, simple mean over all pairs)
vecs = []
for t in terms:
v, _ = self._get_token_vector(t)
if v is None:
continue
vecs.append(v)
if not vecs:
return None
Vq = np.stack(vecs).astype(np.float32) # Tq x D
n_docs = len(self.tf_token_docs)
sims_all = np.zeros((n_docs,), dtype=np.float32)
for i, d in enumerate(self.tf_token_docs):
fields = d.get("fields") or {}
doc_terms = set()
for fname in self.cfg.target_fields:
fobj = fields.get(fname) or {}
tf = fobj.get("tf") or {}
doc_terms.update(tf.keys())
if not doc_terms:
sims_all[i] = 0.0
continue
Vd_list = []
for t in doc_terms:
idx = self.word_vocab.get(t)
if idx is None:
continue
Vd_list.append(self.word_vectors[idx])
if not Vd_list:
sims_all[i] = 0.0
continue
Vd = np.stack(Vd_list).astype(np.float32) # Td x D
M = Vd @ Vq.T # Td x Tq
M = np.maximum(M, 0.0)
sims_all[i] = float(M.mean()) if M.size else 0.0
return sims_all
# ----- Public API -----
def search(
self,
query: str,
debug: bool = False,
) -> List[Tuple[str, float]] | Tuple[List[Tuple[str, float]], Dict[str, Any]]:
terms = self._tokenize(query)
if self.cfg.synonyms_enable:
terms = self._expand_synonyms(terms)
substring_hits = self._substring_match_circle_ids(query)
substring_idx_set: Set[int] = set()
substring_mask = np.zeros((len(self.circles),), dtype=bool)
if substring_hits:
for cid in substring_hits:
idx = self.circle_idx.get(cid)
if idx is None:
continue
substring_idx_set.add(idx)
substring_mask[idx] = True
# BM25F
bm25 = self._bm25f_scores(terms)
# word sim (filtering): top-k pooling
ws_filter = self._word_sim_scores_topk(terms)
if ws_filter is None:
ws_filter = np.zeros_like(bm25)
a = float(self.cfg.word_sim_alpha)
fused_filter = a * bm25 + (1.0 - a) * ws_filter
# circleName/circleNameKana auto-boost based on raw query substring match
qn = normalize_text_for_org(query)
boost_enabled = len(qn) >= int(self.cfg.org_boost_min_len)
boost = np.zeros((len(self.circles),), dtype=np.float32)
if boost_enabled:
exact = np.zeros((len(self.circles),), dtype=bool)
prefix = np.zeros_like(exact)
substr = np.zeros_like(exact)
for i, d in enumerate(self.circles):
cid = d.get("circleId")
on = self.org_norms.get(cid, "")
rn = self.reading_norms.get(cid, "")
if qn and (qn == on or (rn and qn == rn)):
exact[i] = True
elif qn and (on.startswith(qn) or (rn and rn.startswith(qn))):
prefix[i] = True
elif qn and ((qn in on) or (rn and qn in rn)):
substr[i] = True
boost = (
exact.astype(np.float32) * float(self.cfg.org_boost_exact)
+ prefix.astype(np.float32) * float(self.cfg.org_boost_prefix)
+ substr.astype(np.float32) * float(self.cfg.org_boost_substring)
)
# collect results
ids = [d.get("circleId") for d in self.circles]
# Filtering to reduce false positives while keeping recall
# Relative threshold anchored to the top fused score
if boost_enabled:
score_with_boost = fused_filter + boost
else:
score_with_boost = fused_filter
top = float(np.max(score_with_boost)) if score_with_boost.size > 0 else 0.0
rel_cut = (
top * float(self.cfg.fused_rel_top_ratio) if top > 0 else self.cfg.fused_min
)
fused_cut = max(float(self.cfg.fused_min), rel_cut)
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)
if substring_idx_set:
keep = keep | substring_mask
order = np.argsort(-score_with_boost) # descending by fused
selected_idx: List[int] = []
selected_idx_set: Set[int] = set()
substring_sorted = sorted(substring_idx_set, key=lambda i: -score_with_boost[i])
for idx in substring_sorted:
selected_idx.append(int(idx))
selected_idx_set.add(int(idx))
non_sub_count = 0
for i in order:
idx = int(i)
if idx in selected_idx_set:
continue
if keep[idx]:
selected_idx.append(idx)
selected_idx_set.add(idx)
non_sub_count += 1
if non_sub_count >= self.cfg.max_results:
break
# Single-step fallback: if zero, relax the relative cut and use absolute thresholds only
if not selected_idx_set:
keep2 = (
(bm25 >= self.cfg.bm25_min)
| (ws_filter >= self.cfg.word_sim_min)
| (score_with_boost >= self.cfg.fused_min)
)
for i in order:
idx = int(i)
if idx in selected_idx_set:
continue
if keep2[idx]:
selected_idx.append(idx)
selected_idx_set.add(idx)
non_sub_count += 1
if non_sub_count >= self.cfg.max_results:
break
# Rerank with pair-avg word similarity (if enabled)
ws_rerank = None
if self.cfg.word_sim_rerank == "pair_avg":
ws_rerank = self._word_sim_scores_pairavg(terms)
if ws_rerank is None:
ws_rerank = ws_filter
fused_rerank = a * bm25 + (1.0 - a) * ws_rerank
if boost_enabled:
final_scores = fused_rerank + boost
else:
final_scores = fused_rerank
pairs = [(ids[i], float(final_scores[i])) for i in selected_idx]
# sort
pairs.sort(key=lambda x: (-x[1], x[0]))
if not debug:
return pairs
# build debug details for all docs sorted by score
ranked_indices = sorted(
range(len(self.circles)),
key=lambda idx: (-float(final_scores[idx]), ids[idx]),
)
details = []
for idx in ranked_indices:
circle = self.circles[idx]
details.append(
{
"circleId": ids[idx],
"circleName": circle.get("circleName"),
"bm25": float(bm25[idx]),
"ws_filter_topk": float(ws_filter[idx]),
"ws_rerank_pairavg": float(ws_rerank[idx])
if ws_rerank is not None
else None,
"org_boost": float(boost[idx]) if boost_enabled else None,
"matched_substring": bool(substring_mask[idx]),
"fused_filter": float(fused_filter[idx]),
"fused_final": float(final_scores[idx]),
}
)
return pairs, {"details": details}
def get_circles(self) -> List[Dict[str, Any]]:
return self.circles
def get_circle_map(self) -> Dict[str, Dict[str, Any]]:
return self.circle_map