内容过滤与替换引擎怎么做?一次词表匹配的工程复盘

一、一篇稿子过滤要二十三秒

年初我们把过滤这一环拖出来单独压过一轮,一篇一千八百字的稿子,走完五轮不同的处理策略要四点七秒,因为每一轮都是拿两万六千条词表逐条去做字符串替换,一轮一次全量扫描。我们一天要生成四百篇左右,光过滤就吃掉二十多分钟的 CPU,接在生成接口后面的那一段,响应时间的九十五分位从两秒出头涨到了九秒。

更麻烦的是命中率并不高。两万六千条词表里,实际会在稿子里出现的不足八百条,也就是说绝大多数的扫描是空转。可我们又不敢把这些词筛掉,因为下一次生成换个题材,命中集合就完全变了。思路走到这里其实已经很清楚,需要的是让一次扫描同时匹配所有模式,而不是让所有模式轮流扫一遍文本。

二、难点不在匹配,在匹配之前和之后

把这件事拆成三段看。第一段是匹配,也就是在文本里找出所有命中词的位置,这一段有成熟的算法可以借。第二段是归一化,用户写的稿子里,同一个词可能有全角半角两种写法、简体繁体两种字形、中间还夹着看不见的零宽字符,不先折叠就一定会漏。第三段是还原,折叠之后文本的位置变了,替换必须回到原文的偏移上去改,否则改出来的是一份被清洗过标点的稿子,而不是作者写的那篇。

最容易被低估的是第三段。我们第一版实现直接在折叠后的文本上做替换,结果交付的稿子里,作者习惯用的全角括号全变成了半角,数字之间的空格也没了。客户提了一句话,说你们这不是过滤,是重写。从那一版之后我们才明白,归一化只用于判断,绝不能用于输出,判断用的文本和输出的文本必须是两份。

三、正则、分词还是自动机

第一条路是把词表合并成一条正则,让引擎去做一趟扫描。这条路写起来很省事,两万六千条词拼一条正则也不过几兆的字符串,代价是词表里难免有重叠的模式,正则引擎在这种输入上容易退化成回溯,我们实测过一版,某些词表上单篇耗时反而涨到七秒。

第二条路是先分词再查表,用词典匹配的思路逐个词表决。它的好处是能拿到词边界,判断一词多义时更准,代价是分词器对网络新词和变形写法不友好,我们的词表里恰好有很多这类词,分词器会把它们切碎,命中率掉得厉害。

第三条路是构建自动机,把所有词条编成前缀树,再补上失败指针,文本只扫一遍,边走边跳,一次扫描就能拿到全部命中位置。我们最后选了第三条,理由很直接,词表规模固定、匹配只需一次、更新频率不高,这三点恰好是自动机的舒适区,而它的构建开销可以通过复用摊薄。

四、把匹配逻辑固化进过滤层

过滤层属于 AI智能媒体助理,词表加载和文本扫描各占一个模块,彼此只通过一个命中结果列表通信。扫描模块不关心命中之后怎么办,它只负责给出一串三元组,词条编号、起始位置、结束位置。策略模块拿到这串三元组,再按配置决定每一处是该替换、该打码、该整段丢掉,还是只记下来送人工复核。

策略做成可配置之后,同一份词表能服务不同的产线。对外发布的稿子走严格策略,命中即替换并打标;内部试写的草稿走宽松策略,只打标不替换,让人工去看;图片描述这类短文本走折中策略,命中就整段丢掉,因为短文本里留着一处打码痕迹比删掉更难看。四条策略共用一套匹配结果,切换只改一个配置项。

五、前缀树、失败指针与偏移映射

自动机的节点结构很简单,每个节点存一张子节点表、一个失败指针、一份输出词条列表。两万六千条词条展开后大约十八万个节点,构建一次耗时一点二秒,这个开销放在服务启动时做一次可以接受,但绝不能放在每次请求里。所以词表是常驻内存的,构建后的根节点被一个原子引用持有。

替换的落脚点是偏移映射。折叠文本的每一个字符都记下它在原文里的下标,命中位置在折叠文本上算出来之后,回头查映射表就能换算成原文的起止下标,替换只在原文的这一段执行。这样原文的标点、空格、全角符号全都原样保留,只有命中词本身被改动,作者看到的就是自己被改过的句子,而不是一份被重新排版的稿子。

六、三个坑分别踩在语义、热更新和变体上

第一个坑是替换之后句子读不通。有一批稿子里出现了一处人名,替换词把它换掉之后,后半句的指代全部悬空,整段话成了病句。根因是命中即改,完全不看前后文。改法是给每一处命中取前后各十二字的上下文窗口,与一份白名单比对,窗口里出现白名单词汇就跳过不替换,只打标送人工。同时给整段丢弃策略加了一条段落长度阈值,段内汉字少于三十个不执行丢弃,免得把文章掏成骨架。

第二个坑是词表热更新不生效。运营改了词表,前台跑的结果还是老样子,非得重启服务。根因是我们把前缀树建在了模块级常量上,进程起来之后就再没重建过。改法是引入词表版本号,更新时先在后台把新树建好,建完再用一次原子赋值换掉根节点引用,正在跑的请求还握着老根节点,跑完自然释放。匹配结果里也带上版本号,出了问题能追到是用哪一版词表判的。

第三个坑是变体绕过和一词多义误伤,这两个问题总是同时出现。变体那半边,把词里的一个汉字换成同音的另一个字、或者在全角环境下写字母,逐字比对就漏了;误伤那半边,一个词在有的语境里是敏感词,在别的语境里是普通名词,一刀切换掉会把正常的句子改坏。改法是两头各做一半,匹配前先折叠,统一大小写、全角转半角、繁体转简体、剔除零宽字符,折叠出问题只影响判断不影响输出;判不准的词进复核队列,由人工决定要不要加入替换词对,绝不自动替换。

七、这层管不到的地方

图片和视频里的文字它管不到。我们的过滤只吃纯文本,图片上的水印文字、视频字幕里的口播内容都要另外走识别链路,识别出来再送进这一层。所以对外说明里我们从不说内容全部过了一遍,只能说文本部分过了哪几条策略,这个边界必须讲清楚,否则出了事就是我们的责任。

谐音替代和拆字这类写法也覆盖不了。词表是穷举的,写法是无限的,今天补了一条,明天就有人换个相近的说法绕过去。我们的应对是把词表约束同时写进生成侧的提示词里,让模型在生成阶段就避开这些表达,而不是全靠事后来抓,两层叠加之后漏网的比例降了下来,但仍然不是零。

改写之后的稿子好不好读,机器判不了。打散句式、降低连接词密度、重组段落这几步只能让形式上不像模板,读起来自不自然得靠人工抽检,我们目前每批抽一成,发现问题就回滚到那一版的规则。抽检这件事没有捷径,谁偷懒谁承担后果。

八、小结

生成前的词表约束和生成后的替换改写,是 AI智能媒体助理 在这一层留的两个入口。前者把明显不能出现的表达挡在提示词里,后者负责兜住漏网的,两层合起来之后,单篇过滤的耗时从四点七秒降到零点四秒,词表从两万六千条加到三万四千条,耗时几乎没有变化,因为自动机的扫描成本跟词表规模基本无关。

如果你也在自己写过滤,我的建议是先把判断文本和输出文本分开,这一条看着简单,却是后面所有替换逻辑能不能站住的前提。判断用的那一份可以随便折叠清洗,输出用的那一份必须逐字保留,两者之间只靠一张偏移映射表连接,谁搞混了,改出来的稿子就不再是作者写的那一篇。

相关推荐
带娃的IT创业者2 个月前
赋予AI“品味”:拒绝平庸,OpenCV如何重塑生成式内容的审美边界
人工智能·opencv·计算机视觉·生成式ai·内容过滤·ai审美
闻缺陷则喜何志丹8 个月前
【动态规划 AC自动机】P9188 [USACO23OPEN] Pareidolia S|普及+
c++·算法·动态规划·洛谷·ac自动机
疯狂的程需猿10 个月前
Go语言高性能关键词100%匹配:比Regex快500倍的AC自动机实现
golang·ac自动机
Ayanami_Reii10 个月前
进阶数据结构应用-单词
数据结构·字符串·ac自动机
Ayanami_Reii10 个月前
进阶数据结构-AC自动机
数据结构·算法·动态规划·字符串·ac自动机
程序猿编码1 年前
二进制签名查找器(Aho-Corasick 自动机):设计思路与实现原理(C/C++代码实现)
c语言·c++·网络安全·二进制·逆向工程·ac自动机
lilihuigz3 年前
FacetWP Relevanssi Integration相关性集成插件
wordpress网站·内容过滤
lilihuigz3 年前
FacetWP WordPress网站高级筛选过滤插件(含所有扩展)
wordpress网站·内容过滤
Qres8213 年前
根号重构AC自动机:HDU4787
ac自动机·根号