"""语言无关的文本相似度基元 —— 求助域内共用。 原本长在 `service.py` 里,只服务「相似的已解决工单」召回。2026-08-30 员工知识库 检索也要用同一套:**中文查询没有空格**,按空白分词等于把整句当一个词, `ILIKE '%怎么连VPN%'` 一条都匹配不到 —— 这正是当初「按第一个空格分词 ILIKE」 对中文标题是事实 no-op 的同一个坑,换了个地方又踩一次。 """ from __future__ import annotations import re def shingles(text: str) -> set[str]: """CJK 二元组 + 小写 ascii 词 —— 语言无关的相似度 token。 中文按**相邻两字**切:「打印机卡纸」→ 打印/印机/机卡/卡纸。这样「怎么连VPN」 也能命中「如何连接飞连VPN」,而按空白分词只会得到一个匹配不上任何东西的整串。 """ text = (text or "").lower() words = set(re.findall(r"[a-z0-9]{2,}", text)) cjk = re.findall(r"[一-鿿]", text) bigrams = {a + b for a, b in zip(cjk, cjk[1:], strict=False)} return words | bigrams #: 问句骨架 —— 只表示「这是一个问题」,不表示问的是什么。 #: #: 不是拍脑袋列的:拿真实的 124 条服务台 FAQ 算过每个 shingle 的文档频率, #: 高频的那一头清一色是这些词 —— 「如何」出现在 **31%** 的条目里、``to`` 23%、 #: ``how`` 21%、「怎么」14%、「么办」9%。而同样高频的「申请」「使用」「权限」 #: 「飞连」是**真的在说什么**,所以留着。 #: #: 为什么必须去掉:员工的问题里几乎一定有一个骨架词,于是「怎么连VPN」和 #: 「云打印怎么装」共享一个「怎么」就能越过相似度下限,一条毫不相干的条目 #: 就挤进了前四。 QUESTION_FRAME_SHINGLES = frozenset( { # 中文疑问骨架(含跨字边界产生的二元组) "如何", "怎么", "怎样", "么办", "了怎", "什么", "为什", "么样", "请问", "可以", "是否", "需要", "哪里", "我的", "求助", # English question frame. ``shingles`` only keeps tokens of length ≥2, # so "a" / "i" never appear here. "how", "to", "the", "an", "of", "for", "in", "is", "are", "do", "does", "can", "my", "me", "and", "or", "on", "at", "with", "what", "when", "where", "which", "it", "as", "be", "by", "if", "that", "this", "you", "your", } ) def topical_shingles(text: str) -> set[str]: """:func:`shingles` minus the question frame — what the query is ABOUT. Used by the knowledge-base search. :func:`find_similar_resolved` keeps the unfiltered set on purpose: it compares a ticket to other tickets, where both sides carry the same frame, so the frame cancels out instead of misleading. """ return shingles(text) - QUESTION_FRAME_SHINGLES def jaccard(a: set[str], b: set[str]) -> float: if not a or not b: return 0.0 inter = len(a & b) if not inter: return 0.0 return inter / len(a | b) def like_escape(token: str) -> str: """转义 LIKE 的通配符。调用方仍需传 ``escape="\\\\"``。""" return token.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") __all__ = [ "QUESTION_FRAME_SHINGLES", "jaccard", "like_escape", "shingles", "topical_shingles", ]