147 lines
6.3 KiB
Python
147 lines
6.3 KiB
Python
"""事实层的词面通道:BM25 + 分数融合,给 store.search_facts 做重排。
|
||
|
||
为什么要加这一路:statement 是唯一进检索向量的字段(store._upsert_fact_vec 只嵌
|
||
`subject: statement`),而它记的大量内容是给定量——符号名、数值、单位、标签。稠密
|
||
向量对这类 token 很钝:"step 3 input: n=64" 和 "step 4 input: n=32" 的余弦几乎一样,
|
||
但查询要的是其中一条。词面通道对精确 token 敏感,正好补这个洞。
|
||
|
||
只有候选数超过 top-k 时重排才会改变结果;候选不足时该通道自然是空操作。
|
||
词面权重应足以打破语义分接近的邻居,但不能盖过明显更相关的语义候选。
|
||
|
||
两个设计约束,都是被调用方逼出来的:
|
||
|
||
1. 绝不能把融合分数返回给上层。client.py:851 用 `s >= fact_min_score`
|
||
(默认 0.30)过闸门,那是余弦分的量纲;融合分是另一套量纲,一返回就等于
|
||
把整个事实层静默清空。所以融合只用来定顺序,返回的分数仍是稠密余弦分。
|
||
|
||
2. 重排只在"已经过了稠密下限"的候选里做。否则 BM25 可能把一条余弦 0.2 的事实顶进
|
||
前 top,它随后被 client 的闸门丢掉,于是混检反而比纯稠密召回得更少。先按
|
||
min_score 砍掉候选池,再重排,返回条数就恒等于纯稠密的条数,只是选得更准。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
import re
|
||
from typing import List, Optional, Sequence, Tuple
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
ENV_DISABLE = "HY_FACT_LEXICAL_OFF" # 置 1 关掉词面通道,退回纯稠密
|
||
ENV_WEIGHT = "HY_FACT_LEXICAL_WEIGHT"
|
||
|
||
# 词面相对稠密的权重,含义是"词面最多能翻过多大的余弦间距"。
|
||
_LEX_WEIGHT = 0.05
|
||
|
||
# 数值必须整体成词,包括科学计数法:给定量的辨识度全在完整数值上,"1e-6" 被切成
|
||
# 1/e/6 之后和 "6e-1" 无从区分。数值分支放在字母分支前面,否则 "1e-6" 的 e 会先被
|
||
# 字母分支吃掉。
|
||
_LATIN = re.compile(r"\d+(?:\.\d+)?(?:e[-+]?\d+)?|[a-z]+")
|
||
_CJK = re.compile(r"[一-鿿]+")
|
||
|
||
_warned = False
|
||
|
||
|
||
def _warn_once(msg: str) -> None:
|
||
global _warned
|
||
if not _warned:
|
||
_warned = True
|
||
logger.warning(f"[coding-lexical] {msg}")
|
||
|
||
|
||
def tokenize(text: str) -> List[str]:
|
||
"""中英混排分词。
|
||
|
||
先用正则取拉丁词和数值(数值整体成词,见 _LATIN 上的注释),CJK 段落交给 jieba。
|
||
jieba 首次调用要加载 0.4s 词典,所以在这里 lazy import,没有 CJK 的库不付这个钱。
|
||
"""
|
||
low = text.lower()
|
||
tokens = _LATIN.findall(low)
|
||
cjk = _CJK.findall(low)
|
||
if cjk:
|
||
try:
|
||
import jieba
|
||
|
||
for run in cjk:
|
||
tokens.extend(t for t in jieba.lcut(run) if t.strip())
|
||
except Exception as e: # noqa: BLE001
|
||
_warn_once(f"jieba unavailable ({e}); CJK falls back to bigrams")
|
||
for run in cjk:
|
||
tokens.extend(run[i:i + 2] for i in range(max(len(run) - 1, 1)))
|
||
return tokens
|
||
|
||
|
||
def _weight() -> float:
|
||
"""每次调用都读环境变量:便于在不改代码的前提下切换。"""
|
||
raw = os.getenv(ENV_WEIGHT)
|
||
if not raw:
|
||
return _LEX_WEIGHT
|
||
try:
|
||
return float(raw)
|
||
except ValueError:
|
||
_warn_once(f"{ENV_WEIGHT}={raw!r} is not a number; using {_LEX_WEIGHT}")
|
||
return _LEX_WEIGHT
|
||
|
||
|
||
def rerank(
|
||
query_text: str,
|
||
candidates: Sequence[Tuple[str, float, str]],
|
||
*,
|
||
lex_weight: Optional[float] = None,
|
||
) -> Optional[List[str]]:
|
||
"""返回融合后的 id 顺序;不可用或无意义时返回 None(调用方保持纯稠密)。
|
||
|
||
candidates 是 (id, 稠密余弦分, 用于建词面索引的文本)。文本应当与嵌入侧一致
|
||
(`subject: statement`),否则两路排的不是同一个东西。
|
||
"""
|
||
if os.getenv(ENV_DISABLE, "").strip() in ("1", "true", "True"):
|
||
return None
|
||
if len(candidates) < 2:
|
||
return None
|
||
if lex_weight is None:
|
||
lex_weight = _weight()
|
||
try:
|
||
# BM25Plus 而不是 BM25Okapi。Okapi 的 idf 对"出现在多数文档里"的词取负值,
|
||
# 再由 epsilon 地板截平:候选池只有 2 条时每个词都出现在 >=50% 的文档里,
|
||
# 两条得分相同,完全无法区分。而小候选池正是本路的主要工作区间,用
|
||
# Okapi 等于在最常见的情形下把词面通道关掉。BM25Plus 的下界项保证正分。
|
||
from rank_bm25 import BM25Plus
|
||
except Exception as e: # noqa: BLE001
|
||
_warn_once(f"rank_bm25 unavailable ({e}); fact search stays dense-only")
|
||
return None
|
||
|
||
q = tokenize(query_text)
|
||
if not q:
|
||
return None
|
||
corpus = [tokenize(text) for _, _, text in candidates]
|
||
if not any(corpus):
|
||
return None
|
||
try:
|
||
scores = BM25Plus(corpus).get_scores(q)
|
||
except Exception as e: # noqa: BLE001
|
||
_warn_once(f"bm25 scoring failed ({e}); fact search stays dense-only")
|
||
return None
|
||
|
||
ids = [fid for fid, _, _ in candidates]
|
||
# 全部候选得分相同 => 词面没有区分力(一个查询 token 都没命中,或命中得一样多)。
|
||
# 此时它的"排名"是任意的,融合进去等于拿噪声扰动稠密顺序,直接不重排。
|
||
lo, hi = min(scores), max(scores)
|
||
if hi - lo <= 1e-9:
|
||
return None
|
||
|
||
# 融合用余弦原分 + 归一化的词面分,而不是两路各自的名次。
|
||
#
|
||
# 名次的问题是它抹掉间距:候选池只有个位数,把名次归一到 [0,1] 会把 0.0004 的
|
||
# 余弦差和 0.05 的余弦差同等放大成一个名次身位,于是"稠密几乎没意见"和"稠密强烈
|
||
# 反对"变得无从区分。余弦本身已经是可比的标度,直接用。
|
||
#
|
||
# 这样 lex_weight 就有了可解释的含义:词面最多能翻过 lex_weight 这么大的余弦
|
||
# 间距。
|
||
lex = {ids[i]: (scores[i] - lo) / (hi - lo) for i in range(len(ids))}
|
||
dense = {fid: s for fid, s, _ in candidates}
|
||
dense_pos = {fid: i for i, fid in enumerate(
|
||
sorted(ids, key=lambda f: -dense[f]))}
|
||
fused = {fid: dense[fid] + lex_weight * lex[fid] for fid in ids}
|
||
# 并列时按稠密顺序稳定收敛:稠密是主索引,词面是补丁。
|
||
return sorted(ids, key=lambda f: (-fused[f], dense_pos[f]))
|