内容审查系统最怕的往往不是漏放,而是误杀。一个知识付费平台上线课程审核后,客服突然收到大量讲师申诉:课程《成人高考数学冲刺》被判定违规,原因是命中了“成人”这个词。运营查后台发现规则库里只有一条简单包含匹配,任何文本只要出现“成人”二字就被拦截。这类误报表面上是敏感词库太脏,实际是规则模型缺乏上下文理解能力。要解决它,不能只靠增加人工复审,而要把规则定制和白名单机制做扎实。

一、误报的根源:单向匹配丢失语境
关键词包含匹配的实现通常用AC自动机或DFA,文本流转过来后扫描词库,命中即返回违规。这个方法快,但误报高。自然语言中同一个词在不同短语里有完全不同的语义。比如“成人”在“成人用品”中可能是禁售商品信息,但在“成人教育”“成人高考”中完全是合法业务。再如“枪支”在“玩具枪支管理条例”中不是售卖信息。只做字符串匹配,系统无法感知这些差异。
正则能缓解一部分问题,但会带来维护成本。比如把“成人(?!教育|高考)”写进规则,每个新场景都要不断补充负向断言,规则文件会膨胀成没人敢动的黑盒。更麻烦的是,一些误报不是相邻词问题,而是整个句义问题,比如“这本小说讨论了色情文学的历史”如果只看到“色情”就拦,显然过度。需要把词、上下文、场景和豁免条件一起建模。
分词与词性标注虽然能识别“成人”是否作为形容词修饰“教育”等,但单独依赖也会误判,因为新词不断出现,领域词典维护成本不低。对大多数业务来说,规则定制与白名单是性价比最高的第一步,它不要求训练数据,也能让运营直接理解每一次命中或放行的原因。
二、规则定制:关键词、窗口与场景组合
规则定制的核心是把单个关键词升级为条件组合。一条审核规则至少应包含主关键词、所属场景、窗口上下文、豁免正则和风险分。比如教育场景下,主词“成人”不应直接判违规,除非附近出现“用品”“保健”“情趣”等销售导向词。反过来,电商场景下“成人用品”要被识别。下面是一个规则配置示例。
{
"rule_id": "ADULT_EDU_001",
"scene": "education",
"priority": 10,
"match": {
"keywords": ["成人"],
"context_near": ["教育", "高考", "自考", "培训"],
"context_distance": 6,
"exclude_regex": "成人(教育|高考|自考|培训)"
},
"action": "allow",
"risk_score": 20
}
这里的context_distance需要根据业务回测调整。中文场景下取6到10个字符比较合适,太近会漏掉“成人高考”这种相隔一个字的情况,太远又可能把无关内容纳入窗口。规则之间的冲突也要提前设计好优先级:场景规则通常高于通用规则,条件白名单高于黑名单。不要把每条命中都直接设置为禁发,先标记为人工复核,对误报更友好。
规则定制还带来一个好处是可解释性。每次审核结果都应该能输出命中规则ID、关键词和窗口内容,运营不用翻原始文本就能判断对错。相比直接上黑盒分类模型,规则在强合规场景里更安全,因为每一处拦截或放行都能追溯到具体条件。等规则稳定后,再把低风险文本交给模型做二次排序,能进一步降低人力成本。
三、白名单设计:三层豁免为什么比单层更有效
白名单如果只做一个全局列表,很容易把拦截能力掏空,但分层设计能做到既放行安全内容,又保留对真正违规内容的拦截。第一层是全局白名单,只放绝对安全的固定短语,比如“成人教育法”“成人高考报名”,这些短语任何场景都不应被拦截。全局白名单数量要克制,不能把泛化词放进去。
第二层是场景白名单,它依赖请求来源信息,例如教育频道、电商频道、社区问答。同一个词在不同场景下风险不同,因此审核入口必须把channel或biz_type透传给规则引擎。第三层是条件白名单,它不是固定短语,而是满足某个上下文条件才放行,比如关键词附近出现“报考”“学历”“培训”等词,或命中“成人(教育|高考|自考)”这样的正则。下面这个Python示例演示了全局白名单、规则匹配和上下文豁免如何配合。
import re
from dataclasses import dataclass
@dataclass
class AuditResult:
rule_id: str = ""
text: str = ""
matched: bool = False
hit_keyword: str = ""
risk_score: int = 0
allow_reason: str = ""
class RuleEngine:
def __init__(self):
self.global_allowlist = []
self.rules = []
def add_global_allow(self, phrase: str):
self.global_allowlist.append(phrase)
def add_rule(self, rule: dict):
self.rules.append(rule)
def audit(self, text: str):
for phrase in self.global_allowlist:
if phrase in text:
return AuditResult(
text=text,
matched=False,
allow_reason=f"global allowlist: {phrase}"
)
for rule in self.rules:
keywords = rule.get("keywords", [])
for word in keywords:
if word not in text:
continue
exclude_regex = rule.get("exclude_regex")
if exclude_regex and re.search(exclude_regex, text):
continue
near_words = rule.get("context_near", [])
context_distance = rule.get("context_distance", 6)
if near_words:
pos = text.find(word)
start = max(0, pos - context_distance)
end = pos + len(word) + context_distance
window = text[start:end]
if any(near in window for near in near_words):
continue
return AuditResult(
text=text,
matched=True,
rule_id=rule.get("rule_id", ""),
hit_keyword=word,
risk_score=rule.get("risk_score", 80)
)
return AuditResult(text=text, matched=False)
这段代码里,audit方法先检查全局白名单,然后逐条遍历规则。发现主关键词后,如果命中了exclude_regex或上下文窗口里存在豁免词,就跳过该规则;否则返回一条带风险分的命中结果。实际生产环境还需要把allow_reason写入日志。白名单不是不审核,而是记录豁免原因。假如某平台发现所有通过“教育”上下文豁免的内容中,有少量夹带违规广告,就可以进一步收紧条件,只对带“报考”“学历”的文本豁免。
四、工程落地:从命中即拦截到风险分与人工复核
规则引擎上线前一定要做离线回测。用过去一个月的人工审核数据同时跑旧规则和新规则,对比误报率、召回率以及每条结果的命中详情。输出结构可以像下面这样,除了是否命中和风险分,还要保留允许原因或命中规则。
{
"text": "成人高考报名时间已公布",
"matched": false,
"risk_score": 0,
"allow_reason": "scene allowlist: education context",
"rule_id": ""
}
线上切换时不要一次性全网放开,建议按频道灰度。比如先切教育、职业培训,观察三天申诉率和漏放率。如果误报明显下降、召回没有明显下滑,再扩大范围。同时监控各条白名单的命中次数,如果某条规则每天都因为同一个豁免条件被放行,说明它本身设计存在问题,或者场景边界划分不清。白名单命中率应该成为审核系统的一项核心指标。
规则引擎和机器学习模型并不冲突。规则先做初筛,把明显安全的内容放行,把明显违规的内容拦截,把不确定的文本交给BERT分类器或大模型进行二次判断。这样既能减少模型调用成本,又能保留规则的可解释性。在强监管场景,即使模型判断为违规,但规则未命中,也不能直接放行,仍应转人工复核。最终目标不是追求零误报,而是在召回不下降的前提下,把误报压缩到运营和用户都能接受的范围。

