Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| """ | |
| core/engine.py | |
| ────────────── | |
| DanbooruTagger 核心引擎 | |
| 缓存格式(存于 cache_dir/ 目录): | |
| embeddings.safetensors — 四路向量矩阵(FP16),行顺序与 metadata.parquet 完全对齐 | |
| metadata.parquet — DataFrame(name/cn_name/cn_core/wiki/nsfw/category/post_count) | |
| meta.json — 标量元数据(max_log_count、schema_version) | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import json | |
| import os | |
| import re | |
| import time | |
| from collections import OrderedDict | |
| from pathlib import Path | |
| from datetime import datetime | |
| from typing import Any, Optional | |
| import jieba | |
| import numpy as np | |
| import pandas as pd | |
| import torch | |
| from safetensors.torch import save_file as st_save | |
| from safetensors import safe_open | |
| from sentence_transformers import SentenceTransformer | |
| from .models import SearchRequest, SearchResponse, TagResult | |
| from platform_utils import ( | |
| PLATFORM, | |
| is_cloud, | |
| download_file, | |
| resolve_model_path, | |
| ) | |
| # 限制 PyTorch CPU 线程数,给 asyncio 事件循环留出至少一个核心。 | |
| torch.set_num_threads(max(1, (os.cpu_count() or 2) - 1)) | |
| # LRU 缓存 | |
| class LRUCache: | |
| def __init__(self, maxsize: int): | |
| self._cache: OrderedDict[Any, Any] = OrderedDict() | |
| self._maxsize = maxsize | |
| def get(self, key: Any) -> Any: | |
| if key not in self._cache: | |
| return None | |
| self._cache.move_to_end(key) | |
| return self._cache[key] | |
| def put(self, key: Any, value: Any) -> None: | |
| if key in self._cache: | |
| self._cache.move_to_end(key) | |
| else: | |
| if len(self._cache) >= self._maxsize: | |
| self._cache.popitem(last=False) | |
| self._cache[key] = value | |
| def __len__(self) -> int: | |
| return len(self._cache) | |
| # ────────────────────────────────────────────── | |
| # 常量 | |
| # ────────────────────────────────────────────── | |
| STOP_WORDS: frozenset[str] = frozenset({ | |
| ',', '.', ':', ';', '?', '!', '"', "'", '`', | |
| '(', ')', '[', ']', '{', '}', '<', '>', | |
| '-', '_', '=', '+', '/', '\\', '|', '@', '#', '$', '%', '^', '&', '*', '~', | |
| ',', '。', ':', ';', '?', '!', '\u201c', '\u201d', '\u2018', '\u2019', | |
| '(', ')', '【', '】', '《', '》', '、', '…', '—', '·', | |
| ' ', '\t', '\n', '\r', | |
| '的', '地', '得', '了', '着', '过', | |
| '是', '为', '被', '给', '把', '让', '由', | |
| '在', '从', '自', '向', '往', '对', '于', | |
| '和', '与', '及', '或', '且', '而', '但', '并', '即', '又', '也', | |
| '啊', '吗', '吧', '呢', '噢', '哦', '哈', '呀', '哇', | |
| '我', '你', '他', '她', '它', '我们', '你们', '他们', | |
| '这', '那', '此', '其', '谁', '啥', '某', '每', | |
| '这个', '那个', '这些', '那些', '这里', '那里', | |
| '个', '位', '只', '条', '张', '幅', '件', '套', '双', '对', '副', | |
| '种', '类', '群', '些', '点', '份', '部', '名', | |
| '很', '太', '更', '最', '挺', '特', '好', '真', | |
| '一', '一个', '一种', '一下', '一点', '一些', | |
| '有', '无', '非', '没', '不', | |
| '正在', '已经', '正', '刚', '开始', '继续', '一直', '不断', | |
| '穿着', '戴着', '穿', '戴', | |
| '带有', '具有', '拥有', | |
| '看起来', '看上去', '显得', '仿佛', '似乎', | |
| '十分', '非常', '特别', '比较', | |
| '图片', '画面', '图像', | |
| '位于', '处于', | |
| '许多', '大量', '各种', '所有', '其他', '其它', | |
| # ── 英文停用词 ── | |
| 'a', 'an', 'the', | |
| 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'as', 'into', | |
| 'about', 'between', 'through', 'after', 'before', 'above', 'below', | |
| 'and', 'or', 'but', 'nor', 'so', 'yet', | |
| 'is', 'are', 'was', 'were', 'be', 'been', 'being', | |
| 'do', 'does', 'did', 'done', | |
| 'have', 'has', 'had', 'having', | |
| 'will', 'would', 'shall', 'should', 'can', 'could', 'may', 'might', 'must', | |
| 'not', 'no', 'very', 'too', 'also', 'just', 'only', 'even', 'still', | |
| 'i', 'me', 'my', 'we', 'our', 'you', 'your', 'he', 'him', 'his', | |
| 'she', 'her', 'it', 'its', 'they', 'them', 'their', | |
| 'this', 'that', 'these', 'those', 'which', 'who', 'whom', 'what', | |
| 'there', 'here', 'where', 'when', 'how', 'all', 'each', 'every', | |
| 'some', 'any', 'few', 'more', 'most', 'other', 'such', | |
| 'than', 'up', 'out', 'if', 'then', 'else', 'while', 'during', | |
| 'both', 'same', 'own', 'now', | |
| }) | |
| CAT_MAP: dict[str, str] = { | |
| '0': 'General', '1': 'Artist', '3': 'Copyright', '4': 'Character', '5': 'Meta', | |
| } | |
| SCHEMA_VERSION = 3 # 升级此值将自动触发全量重建,用于破坏性格式变更 | |
| # 用户显式分隔后,纯 CJK 片段超过此长度仍用 jieba 切分(避免长句被当作原子概念) | |
| _ATOMIC_CJK_MAX_LEN = 7 | |
| # 四路 embedding 层配置: (层名, tensor 属性名, DataFrame 列名) | |
| _LAYER_SPEC: list[tuple[str, str, str]] = [ | |
| ('英文', 'emb_en', 'name'), | |
| ('中文扩展词', 'emb_cn', 'cn_name'), | |
| ('释义', 'emb_wiki', 'wiki'), | |
| ('中文核心词', 'emb_cn_core', 'cn_core'), | |
| ] | |
| _ALL_LAYER_NAMES = [ln for ln, _, _ in _LAYER_SPEC] | |
| # ────────────────────────────────────────────── | |
| # 缓存路径助手 | |
| # ────────────────────────────────────────────── | |
| class _CachePaths: | |
| def __init__(self, cache_dir: str | Path): | |
| self.dir = Path(cache_dir) | |
| self.embeddings = self.dir / 'danbooru_multiview_embeddings.safetensors' | |
| self.metadata = self.dir / 'tags_metadata.parquet' | |
| self.meta_json = self.dir / 'version_data.json' | |
| def exists(self) -> bool: | |
| return ( | |
| self.embeddings.is_file() | |
| and self.metadata.is_file() | |
| and self.meta_json.is_file() | |
| ) | |
| def ensure_dir(self): | |
| self.dir.mkdir(parents=True, exist_ok=True) | |
| class DanbooruTagger: | |
| """核心搜索引擎(单例)""" | |
| _instance: Optional['DanbooruTagger'] = None | |
| _lock: Optional[asyncio.Lock] = None | |
| # 进程级 CPU 并发闸门:串行化所有 CPU 密集型操作(search / get_related / | |
| # get_group_candidates),避免并发抢占 CPU 而拖垮 asyncio 事件循环。 | |
| _cpu_sem: Optional[asyncio.Semaphore] = None | |
| def is_ready(cls) -> bool: | |
| return cls._instance is not None and cls._instance.is_loaded | |
| async def get_instance(cls, **kwargs) -> 'DanbooruTagger': | |
| if cls._lock is None: | |
| cls._lock = asyncio.Lock() | |
| async with cls._lock: | |
| if cls._instance is None: | |
| inst = cls(**kwargs) | |
| await asyncio.to_thread(inst.load) | |
| cls._instance = inst | |
| return cls._instance | |
| def __init__( | |
| self, | |
| model_path: Optional[str] = None, | |
| csv_file: str = 'origin_database/tags_enhanced.csv', | |
| cache_dir: str = 'tags_embedding', | |
| cooc_file: str = 'origin_database/cooccurrence_clean.csv', | |
| group_file: str = 'origin_database/tag_groups.json', | |
| tag_artist_file: str = 'origin_database/tag_artist_cooc.parquet', | |
| ): | |
| # 模型路径:优先使用显式传入,否则交由 platform_utils 解析 | |
| self.model_path = model_path or resolve_model_path() | |
| self.csv_path = csv_file | |
| self.device = 'cuda' if torch.cuda.is_available() else 'cpu' | |
| self.paths = _CachePaths(cache_dir) | |
| self.cooc_file = cooc_file | |
| self.group_file = group_file | |
| self.tag_artist_file = tag_artist_file | |
| self.model: Optional[SentenceTransformer] = None | |
| self.df: Optional[pd.DataFrame] = None | |
| self.emb_en: Optional[torch.Tensor] = None | |
| self.emb_cn: Optional[torch.Tensor] = None | |
| self.emb_wiki: Optional[torch.Tensor] = None | |
| self.emb_cn_core: Optional[torch.Tensor] = None | |
| self.max_log_count: float = 15.0 | |
| self.cooc: dict[str, list[tuple[str, int]]] = {} | |
| self._name_to_idx: dict[str, int] = {} | |
| self._tag_to_groups: dict[str, set[str]] = {} | |
| self._group_to_tags_idx: dict[str, np.ndarray] = {} | |
| self._group_cn_names: dict[str, str] = {} | |
| self._tag_artist_index: dict[str, list[tuple]] = {} | |
| self._artist_top_tags: dict[str, list[tuple]] = {} # artist → [(tag, npmi, cn_short), ...] | |
| self.is_loaded: bool = False | |
| # 预提取的列数组,避免热点路径上反复执行 df.iloc[idx] | |
| self._arr_name: Optional[np.ndarray] = None | |
| self._arr_cn_name: Optional[np.ndarray] = None | |
| self._arr_category: Optional[np.ndarray] = None | |
| self._arr_nsfw: Optional[np.ndarray] = None | |
| self._arr_wiki: Optional[np.ndarray] = None | |
| self._arr_post_count: Optional[np.ndarray] = None | |
| self._arr_pop_score: Optional[np.ndarray] = None | |
| # 三层 LRU 缓存(纯内存,重启后自动重热) | |
| # embedding 缓存:key=文本, value=归一化后的 1-D Tensor (D,),约 40 MB | |
| self._emb_cache: LRUCache = LRUCache(maxsize=10_000) | |
| # 搜索结果缓存:key=请求参数 tuple, value=SearchResponse,约 100 MB | |
| self._search_cache: LRUCache = LRUCache(maxsize=5_000) | |
| # 关联推荐缓存:key=(seed_tuple, limit, show_nsfw), value=list[RelatedTag],约 20 MB | |
| self._related_cache: LRUCache = LRUCache(maxsize=2_000) | |
| # ── 初始化 ──────────────────────────────────────────────────────────── | |
| def load(self) -> None: | |
| """同步加载,在线程池中调用。""" | |
| if self.is_loaded: | |
| return | |
| t0 = time.time() | |
| # ── 云端环境:从对应平台 Hub 拉取数据文件 ────────────────────────── | |
| if is_cloud(): | |
| self._pull_cloud_files() | |
| # ── 缓存校验与构建 ───────────────────────────────────────────────── | |
| if not self.paths.exists(): | |
| print('\n' + '=' * 50) | |
| print('[Engine] 未找到缓存,开始首次构建(约 1~3 分钟)...') | |
| print('=' * 50 + '\n') | |
| self._load_model() | |
| self._build_full() | |
| else: | |
| print(f'[Engine] 加载缓存 ({self.paths.dir}) ...') | |
| self._load_from_cache() | |
| if self._cached_schema_version() != SCHEMA_VERSION: | |
| print('[Engine] 缓存格式版本不符,触发全量重建...') | |
| self._load_model() | |
| self._build_full() | |
| elif os.path.exists(self.csv_path): | |
| self._load_model() | |
| self._smart_update() | |
| if self.model is None: | |
| self._load_model() | |
| self._setup_jieba_from_memory() | |
| self._load_cooc() | |
| self._load_tag_artist_cooc() | |
| self._name_to_idx = {n: i for i, n in enumerate(self.df['name'])} | |
| self._tag_names_set: set[str] = set(self._name_to_idx.keys()) | |
| self._rebuild_arrays_from_df() | |
| self._normalize_embeddings() | |
| self._load_groups() | |
| self.is_loaded = True | |
| print(f'[Engine] 初始化完成,耗时 {time.time() - t0:.2f}s') | |
| def _normalize_embeddings(self) -> None: | |
| """ | |
| 对四路 embedding 矩阵做 L2 归一化(in-place 替换 self.emb_*)。 | |
| 归一化后 search 阶段可直接用矩阵乘法得到 cosine similarity, | |
| 无需每次调用 util.semantic_search 内部再做一次归一化。 | |
| """ | |
| for _, attr, _ in _LAYER_SPEC: | |
| t = getattr(self, attr) | |
| if t is None: | |
| continue | |
| setattr(self, attr, torch.nn.functional.normalize(t, p=2, dim=1)) | |
| def _rebuild_arrays_from_df(self) -> None: | |
| """ | |
| 将 DataFrame 中搜索热点路径需要的列预提取为 numpy 数组。 | |
| 任何修改 self.df 行内容或行数的操作之后都必须调用此方法刷新。 | |
| """ | |
| if self.df is None: | |
| return | |
| self._arr_name = self.df['name'].to_numpy() | |
| self._arr_cn_name = self.df['cn_name'].to_numpy() | |
| self._arr_category = self.df['category'].astype(str).to_numpy() | |
| self._arr_nsfw = self.df['nsfw'].astype(str).to_numpy() | |
| self._arr_wiki = self.df['wiki'].astype(str).to_numpy() | |
| self._arr_post_count = self.df['post_count'].to_numpy() | |
| # 预算热度归一化分,避免 search 中每次 np.log1p | |
| max_log = self.max_log_count if self.max_log_count > 0 else 1.0 | |
| self._arr_pop_score = np.log1p(self._arr_post_count) / max_log | |
| def _pull_cloud_files(self) -> None: | |
| """ | |
| 从当前云平台拉取所有数据文件,并将路径写回实例属性。 | |
| HF / MS 的差异完全由 platform_utils.download_file() 屏蔽。 | |
| """ | |
| print(f'[Engine] 云端环境 ({PLATFORM}),开始拉取数据文件...') | |
| # ── HF 平台需要额外指定 repo_id(SPACE_ID)和 repo_type ────────── | |
| extra_hf_kwargs = {} | |
| if PLATFORM == 'hf': | |
| extra_hf_kwargs = { | |
| 'hf_repo_id': os.environ.get('SPACE_ID'), | |
| 'hf_repo_type': 'space', | |
| } | |
| def pull(filename: str) -> str: | |
| try: | |
| return download_file(filename, **extra_hf_kwargs) | |
| except Exception as e: | |
| print(f'[Engine] 拉取 {filename} 失败(非致命): {e}') | |
| return filename # 回退到原始路径,让后续逻辑决定是否重建 | |
| self.csv_path = pull('origin_database/tags_enhanced.csv') | |
| self.cooc_file = pull('origin_database/cooccurrence_clean.parquet') | |
| self.group_file = pull('origin_database/tag_groups.json') | |
| self.tag_artist_file = pull('origin_database/tag_artist_cooc.parquet') | |
| meta_path = pull('tags_embedding/tags_metadata.parquet') | |
| emb_path = pull('tags_embedding/danbooru_multiview_embeddings.safetensors') | |
| json_path = pull('tags_embedding/version_data.json') | |
| # 只有三个缓存文件都成功拉取才覆盖路径,防止部分失败导致 exists() 误判 | |
| if all( | |
| Path(p).is_file() | |
| for p in (meta_path, emb_path, json_path) | |
| ): | |
| self.paths.metadata = Path(meta_path) | |
| self.paths.embeddings = Path(emb_path) | |
| self.paths.meta_json = Path(json_path) | |
| print('[Engine] 云端数据文件拉取完毕。') | |
| else: | |
| print('[Engine] 部分缓存文件拉取失败,将触发本地重建。') | |
| # ── 搜索 ────────────────────────────────────────────────────────────── | |
| def _encode_queries(self, queries: list[str]) -> tuple[torch.Tensor, list[bool]]: | |
| """批量编码查询词,命中 embedding 缓存的跳过 model.encode。 | |
| Returns: | |
| (q_emb, hit_mask): 编码后的张量 (Q, D) 以及每个 query 是否命中缓存。 | |
| """ | |
| cached_vecs: list[Optional[torch.Tensor]] = [self._emb_cache.get(q) for q in queries] | |
| uncached_idx = [i for i, v in enumerate(cached_vecs) if v is None] | |
| hit_mask = [v is not None for v in cached_vecs] | |
| if uncached_idx: | |
| uncached_texts = [queries[i] for i in uncached_idx] | |
| new_embs = self.model.encode( | |
| uncached_texts, convert_to_tensor=True, show_progress_bar=False, | |
| ).float() | |
| new_embs = torch.nn.functional.normalize(new_embs, p=2, dim=1) | |
| for j, i in enumerate(uncached_idx): | |
| emb = new_embs[j] | |
| self._emb_cache.put(queries[i], emb) | |
| cached_vecs[i] = emb | |
| return torch.stack(cached_vecs), hit_mask # type: ignore[arg-type] | |
| def search(self, request: SearchRequest) -> SearchResponse: | |
| if not self.is_loaded: | |
| self.load() | |
| cache_key = ( | |
| request.query, | |
| request.top_k, | |
| request.limit, | |
| request.popularity_weight, | |
| request.use_segmentation, | |
| tuple(sorted(request.target_layers)), | |
| tuple(sorted(request.target_categories)), | |
| request.group_mode, | |
| request.max_per_group, | |
| ) | |
| cached = self._search_cache.get(cache_key) | |
| if cached is not None: | |
| return cached | |
| if request.use_segmentation: | |
| raw_kw, raw_segments = self._smart_split(request.query) | |
| keywords = [w.strip() for w in raw_kw if w.strip() and w.strip() not in STOP_WORDS] | |
| # raw_segments: 分隔符切分后的原始片段(未经 jieba),作为从句级查询插入。 | |
| # 排除与完整 query 相同、以及已出现在 keywords 中的片段,避免重复编码和权重膨胀。 | |
| keywords_set = set(keywords) | |
| extra_segments = [s for s in raw_segments if s != request.query and s not in keywords_set] | |
| queries = [request.query] + extra_segments + keywords | |
| else: | |
| keywords = [] | |
| extra_segments = [] | |
| queries = [request.query] | |
| q_emb, hit_mask = self._encode_queries(queries) | |
| tl = request.target_layers | |
| k = request.top_k | |
| # 每个查询词单独做意图识别,避免长句意图污染短分词 | |
| query_weights = [self._detect_intent(q) for q in queries] | |
| active_layers = [ln for ln in _ALL_LAYER_NAMES if ln in tl] | |
| # 预算每个 query × 每个 layer 的 top_k 配额 | |
| # cur_pvk_per_q[i][ln] = 第 i 个 query 在 layer ln 的配额 | |
| cur_pvk_per_q: list[dict[str, int]] = [] | |
| for cur_weights in query_weights: | |
| if active_layers: | |
| aw = {l: cur_weights.get(l, 1.0) for l in active_layers} | |
| total_aw = sum(aw.values()) | |
| cur_pvk = {l: max(1, round(k * aw[l] / total_aw)) for l in active_layers} | |
| else: | |
| cur_pvk = {} | |
| cur_pvk_per_q.append(cur_pvk) | |
| target_cats = request.target_categories | |
| w_pop = request.popularity_weight | |
| final: dict[str, TagResult] = {} | |
| # 按 layer 批量做矩阵乘 + topk,合并 Q×L=20 次小调用为 L=4 次大调用 | |
| for ln, attr, _ in _LAYER_SPEC: | |
| if ln not in tl: | |
| continue | |
| emb_matrix = getattr(self, attr) # (N, D),已归一化 | |
| if emb_matrix is None: | |
| continue | |
| # 该 layer 在所有 query 中的最大配额(少数 query 会算到多余的 hit,最后按各自配额截断) | |
| k_max = max((cur_pvk_per_q[i].get(ln, 1) for i in range(len(queries))), default=1) | |
| k_max = min(k_max, emb_matrix.shape[0]) | |
| scores = q_emb @ emb_matrix.T # (Q, N) | |
| top_v, top_i = scores.topk(k_max, dim=1) # (Q, k_max) | |
| top_v_list = top_v.tolist() | |
| top_i_list = top_i.tolist() | |
| for i, source_word in enumerate(queries): | |
| cur_weights = query_weights[i] | |
| kq = cur_pvk_per_q[i].get(ln, 1) | |
| layer_w = cur_weights.get(ln, 1.0) | |
| row_v = top_v_list[i] | |
| row_i = top_i_list[i] | |
| # 仅取该 query 自己的配额条数 | |
| for j in range(min(kq, len(row_v))): | |
| score = row_v[j] | |
| if score < 0.35: | |
| # topk 已按分数降序,后续都低于阈值,可提前结束 | |
| break | |
| idx = row_i[j] | |
| cat_text = CAT_MAP.get(self._arr_category[idx], 'Other') | |
| if cat_text not in target_cats: | |
| continue | |
| tag_name = self._arr_name[idx] | |
| count = self._arr_post_count[idx] | |
| pop_score = self._arr_pop_score[idx] | |
| final_score = score * layer_w * (1 - w_pop) + pop_score * w_pop | |
| if tag_name not in final or final_score > final[tag_name].final_score: | |
| final[tag_name] = TagResult( | |
| tag=tag_name, cn_name=self._arr_cn_name[idx], category=cat_text, | |
| nsfw=self._arr_nsfw[idx], | |
| final_score=round(float(final_score), 4), | |
| semantic_score=round(float(score), 4), | |
| count=int(count), source=source_word, layer=ln, | |
| wiki=self._arr_wiki[idx], | |
| ) | |
| # ── 全句语义一致性软重排 ────────────────────────────────────────── | |
| # 对每个候选标签,计算其与完整原始查询(而非分词片段)的语义相似度, | |
| # 将相似度作为软因子乘入 final_score,使仅由分词碎片匹配到的噪声 | |
| # 标签自然下沉,同时不硬过滤任何结果。 | |
| # 批量矩阵乘法替代逐条 torch.dot,O(R*L) 降为 O(L) + O(R)。 | |
| full_q = q_emb[0] # queries[0] 始终为完整原始查询 | |
| alpha = 0.3 if request.use_segmentation else 0 # 一致性调节强度(0=不调节, 1=完全按一致性重排),仅在启用分词时有意义 | |
| if alpha > 0 and final: | |
| tag_list = list(final.keys()) | |
| tag_indices = [self._name_to_idx[t] for t in tag_list] | |
| idx_tensor = torch.tensor(tag_indices, dtype=torch.long, device=full_q.device) | |
| max_co = torch.zeros(len(tag_indices), device=full_q.device) | |
| for ln, attr, _ in _LAYER_SPEC: | |
| if ln not in tl: | |
| continue | |
| emb_selected = getattr(self, attr)[idx_tensor] # (R, D) | |
| co = (full_q.unsqueeze(0) @ emb_selected.T).squeeze(0) # (R,) | |
| max_co = torch.maximum(max_co, co) | |
| for i, tag in enumerate(tag_list): | |
| r = final[tag] | |
| r.final_score = round(r.final_score * (1.0 - alpha + alpha * float(max_co[i])), 4) | |
| # Group expand 处理(在 guaranteed_tags 之前,因为会改分数) | |
| if request.group_mode == "expand" and self._tag_to_groups: | |
| self._apply_group_expand(final) | |
| # 收集每个查询源的 top-1 结果(高于阈值) | |
| guaranteed_tags: set[str] = set() | |
| for source_word in queries: | |
| best: TagResult | None = None | |
| for r in final.values(): | |
| if r.source == source_word and r.final_score > 0.45: | |
| if best is None or r.final_score > best.final_score: | |
| best = r | |
| if best is not None: | |
| guaranteed_tags.add(best.tag) | |
| # 对所有候选进行排序,然后在保留保证结果的同时截断至限制数量 | |
| sorted_results = sorted(final.values(), key=lambda r: r.final_score, reverse=True) | |
| valid: list[TagResult] = [] | |
| if request.group_mode == "diverse" and self._tag_to_groups: | |
| # diverse 模式:每个 group 最多保留 max_per_group 个标签 | |
| group_counter: dict[str, int] = {} | |
| max_per = request.max_per_group | |
| for r in sorted_results: | |
| if r.final_score <= 0.45: | |
| continue | |
| if r.tag in guaranteed_tags: | |
| # guaranteed_tags 豁免 group 上限 | |
| valid.append(r) | |
| continue | |
| groups = self._tag_to_groups.get(r.tag) | |
| if not groups: | |
| # 无 group 信息,不受限制 | |
| if len(valid) < request.limit: | |
| valid.append(r) | |
| continue | |
| # 检查是否有任一 group 达上限 | |
| if any(group_counter.get(g, 0) >= max_per for g in groups): | |
| continue | |
| if len(valid) < request.limit: | |
| valid.append(r) | |
| for g in groups: | |
| group_counter[g] = group_counter.get(g, 0) + 1 | |
| else: | |
| for r in sorted_results: | |
| if r.final_score <= 0.45: | |
| continue | |
| if len(valid) < request.limit or r.tag in guaranteed_tags: | |
| valid.append(r) | |
| tags_all = ', '.join(r.tag for r in valid) | |
| tags_sfw = ', '.join(r.tag for r in valid if r.nsfw != '1') | |
| cached_queries = [q for q, hit in zip(queries, hit_mask) if hit] | |
| response = SearchResponse( | |
| tags_all=tags_all, tags_sfw=tags_sfw, | |
| results=valid, keywords=keywords, segments=extra_segments, | |
| cached_queries=cached_queries, | |
| ) | |
| self._search_cache.put(cache_key, response) | |
| return response | |
| # ── CPU 并发闸门(类级信号量,所有 CPU 密集型操作共享)─────────────── | |
| def _get_cpu_sem(cls) -> asyncio.Semaphore: | |
| if cls._cpu_sem is None: | |
| cls._cpu_sem = asyncio.Semaphore(1) | |
| return cls._cpu_sem | |
| async def search_async(self, request: SearchRequest) -> SearchResponse: | |
| """search() 的并发安全异步封装:共享闸门串行化 + 线程池执行。 | |
| 所有异步入口(MCP / API / UI)都应改用本方法,而非各自 | |
| asyncio.to_thread(self.search),以共享同一个 CPU 并发闸门。 | |
| 包含 60 秒超时,防止异常卡死导致信号量永久泄漏。 | |
| """ | |
| async with self._get_cpu_sem(): | |
| return await asyncio.wait_for( | |
| asyncio.to_thread(self.search, request), | |
| timeout=120.0, | |
| ) | |
| async def get_related_async( | |
| self, | |
| seed_tags: list[str], | |
| exclude: set[str] | None = None, | |
| limit: int = 20, | |
| show_nsfw: bool = True, | |
| ) -> list: | |
| """get_related() 的并发安全异步封装,共享同一个 CPU 闸门。""" | |
| async with self._get_cpu_sem(): | |
| return await asyncio.to_thread( | |
| self.get_related, seed_tags, exclude, limit, show_nsfw, | |
| ) | |
| async def get_group_candidates_async( | |
| self, | |
| selected_tags: list[str], | |
| show_nsfw: bool = True, | |
| ) -> list[dict]: | |
| """get_group_candidates() 的并发安全异步封装,共享同一个 CPU 闸门。""" | |
| async with self._get_cpu_sem(): | |
| return await asyncio.to_thread( | |
| self.get_group_candidates, selected_tags, show_nsfw, | |
| ) | |
| async def search_artists_by_tags_async( | |
| self, | |
| tags: list[str], | |
| limit: int = 30, | |
| min_cooc: int = 5, | |
| ) -> list: | |
| """search_artists_by_tags() 的并发安全异步封装。""" | |
| async with self._get_cpu_sem(): | |
| return await asyncio.to_thread( | |
| self.search_artists_by_tags, tags, limit, min_cooc, | |
| ) | |
| async def search_artists_pipeline_async( | |
| self, | |
| query: str, | |
| limit: int = 30, | |
| min_cooc: int = 5, | |
| target_layers: list[str] | None = None, | |
| target_categories: list[str] | None = None, | |
| ) -> tuple: | |
| """search_artists_pipeline() 的并发安全异步封装。""" | |
| async with self._get_cpu_sem(): | |
| return await asyncio.wait_for( | |
| asyncio.to_thread( | |
| self.search_artists_pipeline, | |
| query, limit, min_cooc, target_layers, target_categories, | |
| ), | |
| timeout=120.0, | |
| ) | |
| def _apply_group_expand(self, final: dict[str, TagResult]) -> None: | |
| """expand 模式:提升同 group 标签的分数。""" | |
| BETA = 0.2 | |
| TOP_N = 20 | |
| sorted_items = sorted(final.values(), key=lambda r: r.final_score, reverse=True) | |
| top_n = min(TOP_N, len(sorted_items)) | |
| anchor_results = sorted_items[:top_n] | |
| # 收集锚点结果所属的所有 group | |
| active_groups: set[str] = set() | |
| for r in anchor_results: | |
| groups = self._tag_to_groups.get(r.tag) | |
| if groups: | |
| active_groups.update(groups) | |
| if not active_groups: | |
| return | |
| # 预计算每个 group 的锚点最大分 | |
| group_max_score: dict[str, float] = {} | |
| for g in active_groups: | |
| group_max_score[g] = max( | |
| (r.final_score for r in anchor_results | |
| if g in self._tag_to_groups.get(r.tag, set())), | |
| default=0.0, | |
| ) | |
| # 对所有候选应用 boost | |
| for r in final.values(): | |
| groups = self._tag_to_groups.get(r.tag) | |
| if not groups: | |
| continue | |
| overlap = groups & active_groups | |
| if not overlap: | |
| continue | |
| best_group_score = max(group_max_score[g] for g in overlap) | |
| boost = 1.0 + BETA * best_group_score | |
| r.final_score = round(r.final_score * boost, 4) | |
| # ── 全量构建 ────────────────────────────────────────────────────────── | |
| def _build_full(self) -> None: | |
| print(f'[Engine] 全量读取 {self.csv_path} ...') | |
| raw_df = self._read_csv_robust(self.csv_path) | |
| self.df = self._preprocess_raw_df(raw_df) | |
| self.max_log_count = float(np.log1p(self.df['post_count'].max())) | |
| self._encode_all_and_save() | |
| def _encode_all_and_save(self) -> None: | |
| print('[Engine] 全量编码...') | |
| for _, attr, col in _LAYER_SPEC: | |
| setattr(self, attr, self._encode_texts(self.df[col].tolist())) | |
| self._save_cache() | |
| # ── 增量更新 ────────────────────────────────────────────────────────── | |
| def _smart_update(self) -> None: | |
| print('[Engine] 检查增量变更...') | |
| t0 = time.time() | |
| raw_df = self._read_csv_robust(self.csv_path) | |
| new_df = self._preprocess_raw_df(raw_df) | |
| _SIG_COLS = ['cn_name', 'wiki', 'cn_core'] | |
| def _sig(df: pd.DataFrame, iloc_idx: int) -> tuple: | |
| row = df.iloc[iloc_idx] | |
| return tuple(str(row.get(c, '')) for c in _SIG_COLS) | |
| cached_idx: dict[str, int] = {n: i for i, n in enumerate(self.df['name'])} | |
| new_idx: dict[str, int] = {n: i for i, n in enumerate(new_df['name'])} | |
| added_names = [n for n in new_idx if n not in cached_idx] | |
| deleted_names = [n for n in cached_idx if n not in new_idx] | |
| changed_names = [ | |
| n for n in new_idx | |
| if n in cached_idx and _sig(new_df, new_idx[n]) != _sig(self.df, cached_idx[n]) | |
| ] | |
| if not added_names and not deleted_names and not changed_names: | |
| print('[Engine] 数据已是最新,无需更新。') | |
| return | |
| print(f'[Engine] 变更 → 新增: {len(added_names)} 修改: {len(changed_names)} 删除: {len(deleted_names)}') | |
| if deleted_names: | |
| keep_mask = ~self.df['name'].isin(set(deleted_names)) | |
| keep_pos = [i for i, v in enumerate(keep_mask) if v] | |
| self.df = self.df[keep_mask].reset_index(drop=True) | |
| for _, attr, _ in _LAYER_SPEC: | |
| setattr(self, attr, getattr(self, attr)[keep_pos]) | |
| cached_idx = {n: i for i, n in enumerate(self.df['name'])} | |
| if changed_names: | |
| changed_rows = new_df[new_df['name'].isin(set(changed_names))].reset_index(drop=True) | |
| _vecs = {attr: self._encode_texts(changed_rows[col].tolist()) for _, attr, col in _LAYER_SPEC} | |
| for j, name in enumerate(changed_rows['name']): | |
| ci = cached_idx[name] | |
| for _, attr, _ in _LAYER_SPEC: | |
| getattr(self, attr)[ci] = _vecs[attr][j] | |
| for col in changed_rows.columns: | |
| self.df.at[ci, col] = changed_rows.at[j, col] | |
| if added_names: | |
| added_rows = new_df[new_df['name'].isin(set(added_names))].reset_index(drop=True) | |
| for _, attr, col in _LAYER_SPEC: | |
| vecs = self._encode_texts(added_rows[col].tolist()) | |
| setattr(self, attr, torch.cat([getattr(self, attr), vecs], dim=0)) | |
| self.df = pd.concat([self.df, added_rows], ignore_index=True) | |
| self.max_log_count = float(np.log1p(self.df['post_count'].max())) | |
| self._name_to_idx = {n: i for i, n in enumerate(self.df['name'])} | |
| self._tag_names_set = set(self._name_to_idx.keys()) | |
| self._rebuild_arrays_from_df() | |
| self._load_groups() | |
| self._normalize_embeddings() | |
| self._save_cache() | |
| print(f'[Engine] 增量更新完成,耗时 {time.time() - t0:.2f}s(共 {len(self.df)} 条)') | |
| # ── 缓存 I/O ────────────────────────────────────────────────────────── | |
| def _save_cache(self) -> None: | |
| self.paths.ensure_dir() | |
| st_save( | |
| {attr: getattr(self, attr).half() for _, attr, _ in _LAYER_SPEC}, | |
| str(self.paths.embeddings), | |
| ) | |
| save_cols = ['name', 'cn_name', 'cn_core', 'wiki', 'nsfw', 'category', 'post_count'] | |
| self.df[save_cols].to_parquet(str(self.paths.metadata), index=False) | |
| current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S') | |
| with open(self.paths.meta_json, 'w', encoding='utf-8') as f: | |
| json.dump({ | |
| 'schema_version': SCHEMA_VERSION, | |
| 'updated_at': current_time, | |
| }, f, ensure_ascii=False, indent=4) | |
| print(f'[Engine] 缓存保存完成({len(self.df)} 条记录),生成时间: {current_time}') | |
| def _load_from_cache(self) -> None: | |
| t0 = time.time() | |
| emb_path = str(self.paths.embeddings) | |
| meta_path = str(self.paths.metadata) | |
| emb_size_mb = self.paths.embeddings.stat().st_size / 1024 / 1024 | |
| meta_size_mb = self.paths.metadata.stat().st_size / 1024 / 1024 | |
| # ── 步骤 1/3: embedding 文件(逐层加载以显示进度)───────────────── | |
| n_layers = len(_LAYER_SPEC) | |
| print(f' [1/3] 加载 embedding ({emb_size_mb:.0f} MB, {n_layers} 层) ...') | |
| with safe_open(emb_path, framework="pt", device=self.device) as f: | |
| for i, (name, attr, _) in enumerate(_LAYER_SPEC, 1): | |
| print(f' [{i}/{n_layers}] {attr:12s} ...', end=' ', flush=True) | |
| _t = time.time() | |
| tensor = f.get_tensor(attr) | |
| setattr(self, attr, tensor.float()) | |
| print(f'✓ {time.time() - _t:.1f}s') | |
| print(f' [1/3] ✓ {time.time() - t0:.1f}s') | |
| # ── 步骤 2/3: 元数据 ────────────────────────────────────────────── | |
| print(f' [2/3] 加载元数据 ({meta_size_mb:.0f} MB) ...', end=' ', flush=True) | |
| _t = time.time() | |
| self.df = pd.read_parquet(meta_path) | |
| print(f'✓ {time.time() - _t:.1f}s ({len(self.df):,} 条)') | |
| # ── 步骤 3/3: 统计信息 ──────────────────────────────────────────── | |
| print(f' [3/3] 计算统计信息 ...', end=' ', flush=True) | |
| _t = time.time() | |
| self.max_log_count = float(np.log1p(self.df['post_count'].max())) | |
| print(f'✓ {time.time() - _t:.1f}s') | |
| print(f'[Engine] 缓存加载完成 (总耗时 {time.time() - t0:.1f}s)') | |
| def _cached_schema_version(self) -> int: | |
| try: | |
| with open(self.paths.meta_json, 'r', encoding='utf-8') as f: | |
| return int(json.load(f).get('schema_version', 1)) | |
| except Exception: | |
| return 0 | |
| # ── 编码 & 预处理 ────────────────────────────────────────────────────── | |
| def _encode_texts(self, texts: list[str]) -> torch.Tensor: | |
| return self.model.encode( | |
| texts, batch_size=64, show_progress_bar=False, convert_to_tensor=True, | |
| ).float() | |
| def _load_model(self) -> None: | |
| if self.model is not None: | |
| return | |
| print(f'[Engine] 加载模型 (path={self.model_path}, device={self.device})...') | |
| try: | |
| self.model = SentenceTransformer(self.model_path, device=self.device) | |
| except Exception as e: | |
| print(f'[Engine] 指定路径加载失败,尝试重新解析: {e}') | |
| fallback = resolve_model_path() | |
| self.model = SentenceTransformer(fallback, device=self.device) | |
| def _read_csv_robust(self, path: str) -> pd.DataFrame: | |
| for enc in ['utf-8', 'gbk', 'gb18030']: | |
| try: | |
| return pd.read_csv(path, dtype=str, encoding=enc).fillna('') | |
| except UnicodeDecodeError: | |
| continue | |
| raise ValueError('CSV 读取失败,请检查编码') | |
| def _preprocess_raw_df(self, df: pd.DataFrame) -> pd.DataFrame: | |
| df = df.copy() | |
| df.dropna(subset=['name'], inplace=True) | |
| df = df[df['name'].str.strip() != ''] | |
| for col in ['cn_name', 'category', 'wiki', 'nsfw']: | |
| if col not in df.columns: | |
| df[col] = '' | |
| df['category'] = df['category'].fillna('0') | |
| df['nsfw'] = df['nsfw'].fillna('0') | |
| for char in [',', '|', '、']: | |
| df['cn_name'] = df['cn_name'].str.replace(char, ',', regex=False) | |
| if 'post_count' not in df.columns: | |
| df['post_count'] = 0 | |
| df['post_count'] = pd.to_numeric(df['post_count'], errors='coerce').fillna(0) | |
| df['cn_name'] = df['cn_name'].fillna('') | |
| df['wiki'] = df['wiki'].fillna('') | |
| df['cn_core'] = df['cn_name'].str.split(',', n=1).str[0].str.strip().fillna('') | |
| df.drop_duplicates(subset=['name'], inplace=True) | |
| df.reset_index(drop=True, inplace=True) | |
| return df | |
| def _setup_jieba_from_memory(self) -> None: | |
| if self.df is None: | |
| return | |
| unique_words: set[str] = set() | |
| for text in self.df['cn_name'].dropna().astype(str): | |
| for part in text.replace(',', ' ').split(): | |
| part = part.strip() | |
| if len(part) > 1: | |
| unique_words.add(part) | |
| for word in unique_words: | |
| jieba.add_word(word, 2000) | |
| def _detect_intent(self, query: str) -> dict[str, float]: | |
| """ | |
| 根据查询词特征返回各视图的语义分系数。 | |
| 系数 > 1 表示加权,< 1 表示降权。 | |
| """ | |
| stripped = query.replace(' ', '') | |
| cn_chars = sum(1 for c in stripped if '\u4e00' <= c <= '\u9fff') | |
| en_chars = sum(1 for c in stripped if c.isascii() and c.isalpha()) | |
| total = max(len(stripped), 1) | |
| is_long = len(query) > 8 | |
| is_cn = cn_chars / total > 0.5 | |
| is_en = en_chars / total > 0.5 | |
| if is_long and is_cn: | |
| return {'英文': 0.8, '中文核心词': 0.9, '中文扩展词': 1.1, '释义': 1.4} | |
| if is_long and is_en: | |
| return {'英文': 1.0, '中文核心词': 0.8, '中文扩展词': 0.9, '释义': 1.3} | |
| if is_cn: | |
| return {'英文': 0.8, '中文核心词': 1.3, '中文扩展词': 1.1, '释义': 0.6} | |
| if is_en: | |
| return {'英文': 1.3, '中文核心词': 1.0, '中文扩展词': 0.8, '释义': 0.6} | |
| return {'英文': 1.0, '中文核心词': 1.0, '中文扩展词': 1.0, '释义': 1.0} | |
| # ── 英文分词辅助 ────────────────────────────────────────────────────── | |
| _EN_MAX_COMPOUND = 4 # 复合标签最大单词数 | |
| def _tokenize_en_chunk(self, chunk: str) -> list[str]: | |
| """对一段英文文本做分词:清洗 → 按空格切分 → 过滤停用词/纯数 → 变体规范化 → 合并已知复合标签。 | |
| 变体规范化指:未直接命中 tag_set 的 token 尝试 `连字符→下划线` / | |
| 复数还原(s/es/ies→y),仅在变体落在 tag_set 才采用。 | |
| """ | |
| cleaned = re.sub(r'[,()\[\]{}:]', ' ', chunk) | |
| raw = [p for p in cleaned.split() if p] | |
| tag_set = getattr(self, '_tag_names_set', None) | |
| tokens: list[str] = [] | |
| for part in raw: | |
| low = part.lower() | |
| # 已知标签直接保留(如用户输入了带下划线的 tag 名) | |
| if tag_set and low in tag_set: | |
| tokens.append(low) | |
| continue | |
| if low in STOP_WORDS: | |
| continue | |
| # 连字符/复数变体探测(仅当变体落在 tag_set 才采用) | |
| variant = self._resolve_tag_variant(low) | |
| if variant: | |
| tokens.append(variant) | |
| continue | |
| if part.isdigit(): # 仅过滤纯数字,保留 3d/2b 等含数字的词 | |
| continue | |
| tokens.append(low) | |
| if not tokens: | |
| return [] | |
| return self._merge_compound_english(tokens) | |
| def _resolve_tag_variant(self, low: str) -> str | None: | |
| """对未直接命中 tag_set 的英文 token 探测常见变体。 | |
| 覆盖:连字符→下划线(cat-ears → cat_ears)、复数→单数 | |
| (cats → cat / dresses → dress / bunnies → bunny)。 | |
| 仅在变体落在 _tag_names_set 中才返回,避免 'glass'→'glas' 之类误伤。 | |
| Returns: | |
| 命中的 tag 名;都不命中返回 None。 | |
| """ | |
| tag_set = getattr(self, '_tag_names_set', None) | |
| if not tag_set: | |
| return None | |
| # 连字符直接换成下划线若直接命中 tag 则优先返回 | |
| bases = [low] | |
| if '-' in low: | |
| hyphen_normalized = low.replace('-', '_') | |
| if hyphen_normalized in tag_set: | |
| return hyphen_normalized | |
| bases.append(hyphen_normalized) | |
| # 对每个基串尝试复数还原(按"剥离短→长"顺序,避免 houses→hous 误判) | |
| for base in bases: | |
| if base.endswith('s') and len(base) > 1: | |
| v = base[:-1] | |
| if v in tag_set: | |
| return v | |
| if base.endswith('es') and len(base) > 2: | |
| v = base[:-2] | |
| if v in tag_set: | |
| return v | |
| if base.endswith('ies') and len(base) > 3: | |
| v = base[:-3] + 'y' | |
| if v in tag_set: | |
| return v | |
| return None | |
| def _merge_compound_english(self, tokens: list[str]) -> list[str]: | |
| """将相邻英文单词合并为已知的 Danbooru 下划线复合标签。 | |
| 贪心最长匹配:优先 4-gram,依次递减到 bigram,匹配到即消耗。 | |
| 例: ['beam', 'rifle', 'scope'] → 如果 'beam_rifle' 是标签则合并, | |
| 否则保留原样。 | |
| """ | |
| tag_set = getattr(self, '_tag_names_set', None) | |
| if tag_set is None or len(tokens) < 2: | |
| return tokens | |
| result: list[str] = [] | |
| i = 0 | |
| max_w = min(self._EN_MAX_COMPOUND, len(tokens)) | |
| while i < len(tokens): | |
| merged = False | |
| for w in range(max_w, 1, -1): # 4, 3, 2 | |
| if i + w > len(tokens): | |
| continue | |
| candidate = '_'.join(tokens[i:i + w]) | |
| if candidate in tag_set: | |
| result.append(candidate) | |
| i += w | |
| merged = True | |
| break | |
| if not merged: | |
| result.append(tokens[i]) | |
| i += 1 | |
| return result | |
| # ── 查询切分 ────────────────────────────────────────────────────────── | |
| def _smart_split(self, text: str) -> tuple[list[str], list[str]]: | |
| """将查询文本拆分为关键词列表,同时返回从句级片段。 | |
| 先把文本切成交替的 CN-region(含 CJK 字符)与 EN-region(无 CJK); | |
| 中英文用各自的规则处理: | |
| 1. CN-region 按空格/CJK 标点切出"子句"(segments),中文自然语句里 | |
| 这些符号是显式概念边界。子句的 token 切分策略由"整句是否含任何 | |
| 分隔符"决定: | |
| - 整句无任何分隔符 → 视作自然句,jieba 切分; | |
| - 整句有分隔符 → 用户已标边界,每个短纯 CJK 子句原子保留, | |
| 超过 _ATOMIC_CJK_MAX_LEN 才走 jieba。 | |
| 2. EN-region 仅走 _tokenize_en_chunk(停用词过滤 + 复合词合并), | |
| 不产出 segments——英文里空格是词内分隔而非概念边界。 | |
| Returns: | |
| (tokens, segments): | |
| - tokens: 处理后的关键词列表 | |
| - segments: CN-region 切出的子句片段;纯英文查询为空列表 | |
| """ | |
| user_pieces = [s.strip() for s in re.split(r'[\s\n\r,、;。]+', text) if s.strip()] | |
| if not user_pieces: | |
| return [], [] | |
| has_boundary = len(user_pieces) > 1 # 整句是否含任何用户标注的概念边界 | |
| tokens: list[str] = [] | |
| segments: list[str] = [] | |
| # 把文本切成交替的 CN-region 与 EN-region。 | |
| # CN-region 允许内部以空格/CJK 标点连接相邻 CJK 块。 | |
| cjk_region = r'[一-龥]+(?:[\s\n\r,、;。]+[一-龥]+)*' | |
| parts = re.split(f'({cjk_region})', text) | |
| for part in parts: | |
| if not part.strip(): | |
| continue | |
| if re.search(r'[一-龥]', part): | |
| # CN region:产出子句 + tokens | |
| cn_segs = [s.strip() for s in re.split(r'[\s\n\r,、;。]+', part) if s.strip()] | |
| for seg in cn_segs: | |
| segments.append(seg) | |
| if has_boundary and re.match(r'^[一-龥]+$', seg) and len(seg) <= _ATOMIC_CJK_MAX_LEN: | |
| tokens.append(seg) # 短 → 原子概念 | |
| else: | |
| for chunk in re.split(r'([一-龥]+)', seg): | |
| if not chunk.strip(): | |
| continue | |
| if re.match(r'[一-龥]+', chunk): | |
| tokens.extend(jieba.cut(chunk)) | |
| else: | |
| tokens.extend(self._tokenize_en_chunk(chunk)) | |
| else: | |
| # EN region:仅 tokenize,不产出子句 | |
| tokens.extend(self._tokenize_en_chunk(part)) | |
| return tokens, segments | |
| # ── 关联推荐 ────────────────────────────────────────────────────────── | |
| def get_related( | |
| self, | |
| seed_tags: list[str], | |
| exclude: set[str] | None = None, | |
| limit: int = 20, | |
| show_nsfw: bool = True, | |
| ) -> list: | |
| from .models import RelatedTag | |
| import math | |
| if not self.cooc or not seed_tags: | |
| return [] | |
| exclude = exclude or set() | |
| related_key = (tuple(sorted(seed_tags)), tuple(sorted(exclude)), limit, show_nsfw) | |
| cached = self._related_cache.get(related_key) | |
| if cached is not None: | |
| return cached | |
| # 估算语料库总大小 N,取数据集中发帖量的最大值,并设置合理下限 | |
| total_posts = float(max(self.df['post_count'].max(), 7000000.0)) | |
| npmi_scores: dict[str, float] = {} | |
| total_cooc: dict[str, int] = {} | |
| tag_sources: dict[str, list[str]] = {} | |
| name_to_idx = self._name_to_idx | |
| arr_post_count = self._arr_post_count | |
| for seed in seed_tags: | |
| if seed not in name_to_idx: | |
| continue | |
| seed_count = float(arr_post_count[name_to_idx[seed]] or 1) | |
| for neighbor, cnt in self.cooc.get(seed, []): | |
| if neighbor in exclude or neighbor == seed: | |
| continue | |
| if neighbor not in name_to_idx: | |
| continue | |
| neighbor_count = float(arr_post_count[name_to_idx[neighbor]] or 1) | |
| cooc = min(float(cnt), seed_count, neighbor_count) | |
| if cooc <= 0: | |
| continue | |
| # 计算分子:(Cooc * N) / (Count(A) * Count(B)) | |
| numerator = (cooc * total_posts) / (seed_count * neighbor_count) | |
| # 忽略负相关或完全不相关的词条 | |
| if numerator <= 1.0: | |
| continue | |
| pmi = math.log(numerator) | |
| # 计算分母:-log(P(A, B)) | |
| p_a_b = cooc / total_posts | |
| if p_a_b >= 1.0: | |
| npmi = 1.0 | |
| else: | |
| npmi = pmi / -math.log(p_a_b) | |
| # 多词条搜索时累加 NPMI | |
| npmi_scores[neighbor] = npmi_scores.get(neighbor, 0.0) + npmi | |
| total_cooc[neighbor] = total_cooc.get(neighbor, 0) + cnt | |
| tag_sources.setdefault(neighbor, []).append(seed) | |
| if not npmi_scores: | |
| return [] | |
| # 归一化用于前端展示 | |
| max_score = max(npmi_scores.values()) | |
| sorted_candidates = sorted(npmi_scores.items(), key=lambda x: x[1], reverse=True) | |
| # ── 构建 NPMI 结果 ───────────────────────────────────────────── | |
| results: list = [] | |
| for tag_name, raw_score in sorted_candidates: | |
| if len(results) >= limit: | |
| break | |
| idx = name_to_idx[tag_name] | |
| nsfw = self._arr_nsfw[idx] | |
| if nsfw == '1' and not show_nsfw: | |
| continue | |
| cat = CAT_MAP.get(self._arr_category[idx], 'Other') | |
| results.append(RelatedTag( | |
| tag=tag_name, | |
| cn_name=str(self._arr_cn_name[idx]), | |
| category=cat, | |
| nsfw=nsfw, | |
| cooc_count=total_cooc.get(tag_name, 0), | |
| cooc_score=round(raw_score / max_score, 4), | |
| sources=tag_sources.get(tag_name, []), | |
| post_count=int(self._arr_post_count[idx]), | |
| wiki=str(self._arr_wiki[idx]) if self._arr_wiki is not None else '', | |
| )) | |
| self._related_cache.put(related_key, results) | |
| return results | |
| def get_group_candidates( | |
| self, | |
| selected_tags: list[str], | |
| show_nsfw: bool = True, | |
| ) -> list[dict]: | |
| """根据已选标签,返回候选 Group 及其成员标签。""" | |
| if not self._tag_to_groups or not selected_tags: | |
| return [] | |
| group_hit_count: dict[str, int] = {} | |
| for tag_name in selected_tags: | |
| groups = self._tag_to_groups.get(tag_name) | |
| if groups: | |
| for g in groups: | |
| group_hit_count[g] = group_hit_count.get(g, 0) + 1 | |
| if not group_hit_count: | |
| return [] | |
| selected_set = set(selected_tags) | |
| sorted_groups = sorted(group_hit_count.items(), key=lambda x: -x[1]) | |
| results = [] | |
| for group_name, hit_count in sorted_groups: | |
| member_idxs = self._group_to_tags_idx.get(group_name) | |
| if member_idxs is None: | |
| continue | |
| tags = [] | |
| for idx in member_idxs: | |
| tag_name = str(self._arr_name[idx]) | |
| if tag_name in selected_set: | |
| continue | |
| nsfw = self._arr_nsfw[idx] | |
| if nsfw == '1' and not show_nsfw: | |
| continue | |
| cat = CAT_MAP.get(self._arr_category[idx], 'Other') | |
| tags.append({ | |
| 'tag': tag_name, | |
| 'cn_name': str(self._arr_cn_name[idx]), | |
| 'category': cat, | |
| 'nsfw': nsfw, | |
| 'post_count': int(self._arr_post_count[idx]), | |
| 'wiki': str(self._arr_wiki[idx]) if self._arr_wiki is not None else '', | |
| }) | |
| tags.sort(key=lambda x: -x['post_count']) | |
| cn_name = self._group_cn_names.get(group_name, group_name) | |
| results.append({ | |
| 'group': group_name, | |
| 'group_cn_name': cn_name, | |
| 'hit_count': hit_count, | |
| 'tags': tags, | |
| }) | |
| return results | |
| # ── 画师查找 ────────────────────────────────────────────────────────── | |
| def search_artists_by_tags( | |
| self, | |
| tags: list[str], | |
| limit: int = 30, | |
| min_cooc: int = 5, | |
| ) -> list: | |
| """按标签查找画师:聚合多个标签的 NPMI 得分,返回排名靠前的画师。""" | |
| from .models import ArtistResult | |
| if not self._tag_artist_index or not tags: | |
| return [] | |
| artist_scores: dict[str, float] = {} | |
| artist_cooc: dict[str, int] = {} | |
| artist_post_count: dict[str, int] = {} | |
| artist_sources: dict[str, list[str]] = {} | |
| artist_hits: dict[str, int] = {} | |
| for tag in tags: | |
| tag = tag.strip().lower() | |
| if not tag or tag not in self._tag_artist_index: | |
| continue | |
| for artist, npmi, cooc, post_count in self._tag_artist_index[tag]: | |
| if cooc < min_cooc: | |
| continue | |
| artist_scores[artist] = artist_scores.get(artist, 0.0) + npmi | |
| artist_cooc[artist] = artist_cooc.get(artist, 0) + cooc | |
| artist_post_count[artist] = max(artist_post_count.get(artist, 0), post_count) | |
| artist_sources.setdefault(artist, []).append(tag) | |
| artist_hits[artist] = artist_hits.get(artist, 0) + 1 | |
| if not artist_scores: | |
| return [] | |
| scored = [] | |
| for artist, raw_score in artist_scores.items(): | |
| hit_bonus = 1.0 + 0.3 * (artist_hits[artist] - 1) | |
| final_score = raw_score * hit_bonus | |
| scored.append(( | |
| artist, final_score, artist_cooc[artist], | |
| artist_post_count[artist], artist_sources[artist], | |
| artist_hits[artist], | |
| )) | |
| scored.sort(key=lambda x: -x[1]) | |
| results = [] | |
| for artist, score, cooc, post_count, sources, hits in scored[:limit]: | |
| results.append(ArtistResult( | |
| artist=artist, | |
| score=round(score, 4), | |
| cooc_count=cooc, | |
| post_count=post_count, | |
| sources=sources, | |
| hit_count=hits, | |
| )) | |
| return results | |
| def get_artist_top_tags(self, artist_names: list[str], top_n: int = 10, | |
| show_nsfw: bool = True) -> dict[str, list[str]]: | |
| """获取画师的 top-N 共现标签(含中文简称)。 | |
| Returns: | |
| {artist: [f"{tag} ({cn_short})", ...]} | |
| """ | |
| result: dict[str, list[str]] = {} | |
| for artist in artist_names: | |
| entries = self._artist_top_tags.get(artist, []) | |
| items: list[str] = [] | |
| for tag, npmi, cooc in entries: | |
| if len(items) >= top_n: | |
| break | |
| # NSFW 过滤 | |
| if not show_nsfw and self._name_to_idx is not None and tag in self._name_to_idx: | |
| idx = self._name_to_idx[tag] | |
| if self._arr_nsfw is not None and self._arr_nsfw[idx] == '1': | |
| continue | |
| cn_short = "" | |
| if self._name_to_idx is not None and tag in self._name_to_idx: | |
| idx = self._name_to_idx[tag] | |
| cn_full = str(self._arr_cn_name[idx]) if self._arr_cn_name is not None else "" | |
| cn_short = cn_full.split(',')[0].strip() if cn_full else "" | |
| display = f"{tag}" | |
| if cn_short: | |
| display += f" ({cn_short})" | |
| items.append(display) | |
| result[artist] = items | |
| return result | |
| def search_artists_pipeline( | |
| self, | |
| query: str, | |
| limit: int = 30, | |
| min_cooc: int = 5, | |
| target_layers: list[str] | None = None, | |
| target_categories: list[str] | None = None, | |
| ) -> tuple[list, list[str], list[str], list[str]]: | |
| """画师查找完整管线:自然语言 → 标签搜索 → 提取最佳标签 → 画师查询。 | |
| Returns: | |
| (artist_results, seed_tags, found_tags, missing_tags) | |
| """ | |
| if target_layers is None: | |
| target_layers = ['英文', '中文扩展词', '释义', '中文核心词'] | |
| if target_categories is None: | |
| target_categories = ['General', 'Character', 'Copyright'] | |
| # Step 1: 自然语言 → 标签搜索 | |
| tag_request = SearchRequest( | |
| query=query, | |
| top_k=10, | |
| limit=80, | |
| popularity_weight=0.15, | |
| show_nsfw=True, | |
| use_segmentation=True, | |
| target_layers=target_layers, | |
| target_categories=target_categories, | |
| ) | |
| tag_response = self.search(tag_request) | |
| if not tag_response.results: | |
| return [], [], [], [] | |
| # Step 2: 按 source 分组,每组按分数降序取前 10 个候选,找到能命中画师的第一个 | |
| tag_artist = self._tag_artist_index | |
| source_candidates: dict[str, list[str]] = {} | |
| for r in tag_response.results: | |
| if r.final_score < 0.45: | |
| continue | |
| source_candidates.setdefault(r.source, []).append(r.tag) | |
| seed_tags: list[str] = [] | |
| seen = set() | |
| for candidates in source_candidates.values(): | |
| for tag in candidates[:10]: | |
| tag_lower = tag.strip().lower() | |
| if tag_lower in tag_artist and tag_lower not in seen: | |
| seed_tags.append(tag_lower) | |
| seen.add(tag_lower) | |
| break | |
| if not seed_tags: | |
| return [], [], [], [] | |
| # Step 3: 标签 → 画师查找 | |
| artist_results = self.search_artists_by_tags(seed_tags, limit, min_cooc) | |
| # 统计 found / missing(seed_tags 中哪些匹配到了画师) | |
| matched = {s for r in artist_results for s in r.sources} | |
| found_tags = [t for t in seed_tags if t in matched] | |
| missing_tags = [t for t in seed_tags if t not in matched] | |
| return artist_results, seed_tags, found_tags, missing_tags | |
| # ── 共现数据加载 ────────────────────────────────────────────────────── | |
| def _load_cooc(self) -> None: | |
| csv_path = Path(self.cooc_file) | |
| parquet_path = csv_path.with_suffix('.parquet') | |
| if parquet_path.is_file() and ( | |
| not csv_path.is_file() | |
| or parquet_path.stat().st_mtime >= csv_path.stat().st_mtime | |
| ): | |
| read_path = parquet_path | |
| is_parquet = True | |
| elif csv_path.is_file(): | |
| read_path = csv_path | |
| is_parquet = False | |
| else: | |
| print(f'[Engine] 未找到共现表 ({self.cooc_file}),关联推荐功能不可用。') | |
| return | |
| print(f'[Engine] 加载共现表 ({read_path.name})...') | |
| t0 = time.time() | |
| try: | |
| if is_parquet: | |
| df = pd.read_parquet(str(read_path)) | |
| else: | |
| df = self._read_csv_robust(str(read_path)) | |
| df['count'] = pd.to_numeric(df['count'], errors='coerce').fillna(0).astype(int) | |
| df.to_parquet(str(parquet_path), index=False) | |
| print(f'[Engine] 已将共现表缓存为 {parquet_path.name},下次启动将直接加载。') | |
| tag_a = df['tag_a'].astype(str).to_numpy() | |
| tag_b = df['tag_b'].astype(str).to_numpy() | |
| counts = df['count'].astype(int).to_numpy() | |
| src = np.concatenate([tag_a, tag_b]) | |
| dst = np.concatenate([tag_b, tag_a]) | |
| cnt = np.concatenate([counts, counts]) | |
| sort_idx = np.lexsort((-cnt, src)) | |
| src = src[sort_idx] | |
| dst = dst[sort_idx] | |
| cnt = cnt[sort_idx] | |
| unique_srcs, first_pos = np.unique(src, return_index=True) | |
| end_pos = np.append(first_pos[1:], len(src)) | |
| cooc: dict[str, list[tuple[str, int]]] = {} | |
| for s, start, end in zip(unique_srcs, first_pos, end_pos): | |
| cooc[s] = list(zip(dst[start:end].tolist(), cnt[start:end].tolist())) | |
| self.cooc = cooc | |
| print( | |
| f'[Engine] 共现表加载完成,{len(cooc):,} 个 tag,' | |
| f'耗时 {time.time() - t0:.2f}s' | |
| ) | |
| except Exception as e: | |
| print(f'[Engine] 共现表加载失败: {e}') | |
| def _load_tag_artist_cooc(self) -> None: | |
| """加载标签-画师共现数据(tag_artist_cooc.parquet)。""" | |
| path = Path(self.tag_artist_file) | |
| if not path.is_file(): | |
| print(f'[Engine] 未找到标签-画师共现表 ({self.tag_artist_file}),画师查找功能不可用。') | |
| return | |
| print(f'[Engine] 加载标签-画师共现表 ({path.name})...') | |
| t0 = time.time() | |
| try: | |
| import math | |
| df = pd.read_parquet(str(path)) | |
| # 预计算语料库总大小和 tag → post_count 映射 | |
| total_posts = float(max(self.df['post_count'].max(), 7_000_000)) | |
| tag_post_count = dict(zip(self.df['name'], self.df['post_count'])) | |
| index: dict[str, list[tuple]] = {} | |
| skipped = 0 | |
| for _, row in df.iterrows(): | |
| tag = str(row["tag"]) | |
| artist = str(row["artist"]) | |
| cooc = int(row["cooc_count"]) | |
| artist_pc = int(row["artist_post_count"]) | |
| # 获取标签的 post_count | |
| tag_pc = tag_post_count.get(tag) | |
| if tag_pc is None or tag_pc <= 0: | |
| skipped += 1 | |
| continue | |
| # 即时计算 NPMI(与 get_related 相同算法) | |
| cooc_capped = min(float(cooc), float(tag_pc), float(artist_pc)) | |
| if cooc_capped <= 0: | |
| skipped += 1 | |
| continue | |
| numerator = (cooc_capped * total_posts) / (float(tag_pc) * float(artist_pc)) | |
| if numerator <= 1.0: | |
| skipped += 1 | |
| continue | |
| pmi = math.log(numerator) | |
| p_ab = cooc_capped / total_posts | |
| if p_ab >= 1.0: | |
| npmi_val = 1.0 | |
| else: | |
| npmi_val = pmi / -math.log(p_ab) | |
| if math.isnan(npmi_val): | |
| skipped += 1 | |
| continue | |
| index.setdefault(tag, []).append( | |
| (artist, npmi_val, cooc, artist_pc)) | |
| self._tag_artist_index = index | |
| # 构建反向索引:artist → top tags(按 NPMI 降序) | |
| artist_tags: dict[str, list[tuple]] = {} | |
| for tag, entries in index.items(): | |
| for artist, npmi, cooc, _ in entries: | |
| artist_tags.setdefault(artist, []).append((tag, npmi, cooc)) | |
| for artist, entries in artist_tags.items(): | |
| entries.sort(key=lambda x: -x[1]) | |
| self._artist_top_tags = artist_tags | |
| print( | |
| f'[Engine] 标签-画师共现表加载完成,{len(index):,} 个标签,' | |
| f'跳过 {skipped:,} 行,耗时 {time.time() - t0:.2f}s' | |
| ) | |
| except Exception as e: | |
| print(f'[Engine] 标签-画师共现表加载失败: {e}') | |
| def _load_groups(self) -> None: | |
| """加载 Tag Group 数据,构建 tag→group 和 group→idx 索引。""" | |
| if not Path(self.group_file).is_file(): | |
| print('[Engine] 未找到 Tag Group 数据,group 功能不可用。') | |
| return | |
| with open(self.group_file, 'r', encoding='utf-8') as f: | |
| data = json.load(f) | |
| raw_t2g = data.get('tag_to_groups', {}) | |
| name_to_idx = self._name_to_idx | |
| self._tag_to_groups = {} | |
| group_members: dict[str, list[int]] = {} | |
| for tag_name, groups in raw_t2g.items(): | |
| if tag_name not in name_to_idx: | |
| continue | |
| group_set = set(groups) | |
| self._tag_to_groups[tag_name] = group_set | |
| idx = name_to_idx[tag_name] | |
| for g in group_set: | |
| group_members.setdefault(g, []).append(idx) | |
| self._group_to_tags_idx = { | |
| g: np.array(idxs, dtype=np.int64) for g, idxs in group_members.items() | |
| } | |
| self._group_cn_names = data.get("group_cn_names", {}) | |
| print(f'[Engine] Tag Group loaded, {len(self._tag_to_groups)} tags, ' | |
| f'{len(self._group_to_tags_idx)} groups, ' | |
| f'{len(self._group_cn_names)} cn_names') | |