ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于前缀树与百度词库的敏感词过滤服务设计实践

基于前缀树与百度词库的敏感词过滤服务设计实践 简介面向Go语言开发者的敏感词过滤工具包源自Java版DFA算法实现核心逻辑直接迁移且未经改动集成百度敏感词库同时支持自定义扩展词条。DFA算法即确定性有限自动机匹配效率高且内存占用小适合对性能有要求的文本过滤场景。压缩包共6个文件包含4个Go源码、1个词典文件与1个Markdown说明文档整体仅3KB非常轻量可快速集成到内容审核、评论过滤、聊天消息检测等在线服务中。项目中可在init方法中调用ReadSwfDict加载词库在检查点调用Match方法验证文本是否包含敏感词或调用Repl方法执行替换swf_test.go提供了完整调用示例清晰展示初始化、匹配与替换流程开发者可按需更换词典或扩充敏感词条。资源已有1902人学习下载适合需要快速实现敏感词过滤功能的中级Go开发者参考代码结构简洁便于二次开发与学习DFA算法在敏感词过滤中的实际应用。 敏感词过滤这种需求说实话每个做内容产品的团队早晚都会碰到。不管是社区评论、用户昵称、聊天消息还是文章发布只要涉及UGC就必须有一套能扛得住事的敏感词拦截方案。我这次做的这个Sensitive-word-filtering项目是基于百度开源敏感词库做的一套可扩展的过滤服务底层用前缀树完成高效匹配同时保留了自定义词库、黑白名单、动态更新这些能力。整理这篇文章主要是想把整个项目的设计思路、关键实现和踩坑经历都摊开来讲给那些正准备做敏感词过滤、或者已经做了但总被漏报误杀折腾的朋友一个可参考的模板。这个项目适合谁后端开发、内容安全相关的工程师还有独立开发者在给自己的应用做内容风控时都能直接参考。下面从架构思路到具体实现一步步拆解。1. 整体思路与架构设计1.1 为什么选择百度敏感词库作为起点做敏感词过滤第一个绕不开的问题就是词库从哪来。很多团队一开始喜欢自己整理运营提几个、测试补几个结果上线之后漏报不断用户发个擦边内容直接绕过拦截这其实是底子没打好。百度敏感词库的好处在于它是公开积累的通用敏感词集合覆盖了低俗、暴力、赌博、诈骗等常见高危类别。虽然在实际使用中会发现它并不能覆盖所有场景但作为基础库它的覆盖面比小团队自己手动收集要完整得多。更重要的是它不是静态的社区里有人在更新维护你可以把它当一个种子词库在这个基础上不断补充自己的行业词、地区词、黑话变体。我实际跑下来直接用百度词库做基础过滤准确率大概能到七成以上剩下的三成就靠后面的扩展机制去补。别指望一个词库解决所有问题能把基础拦截做扎实、留好扩展通道这个方案就算成功了一大半。1.2 可扩展架构的核心考量项目设计的时候我把可扩展当成了一等公民不是后面想到才加的。扩展点主要卡在三个层面第一个是词库来源的扩展。除了启动时加载本地文件还要支持从数据库、远程配置中心拉取词库这样运营同学改个词不用求着开发发版。第二个是过滤策略的扩展。敏感词不是一棍子打死有的词直接拦截有的词需要人工审核有的词在特定场景下不算敏感。所以过滤结果不能只有一个通过/不通过最好能返回命中的词、位置、类别方便上层做差异化处理。第三个是算法的可替换性。用前缀树Trie做基础实现但如果词库到了百万级别就得考虑换成AC自动机Aho-Corasick架构上要把算法接口抽象出来别写死。打个比方这就像装修房子先别急着选沙发颜色得先把水电改造和插座位置留好。扩展性就是那些插座现在用不上等真需要的时候就知道有多值钱了。2. 词库加载与预处理2.1 词库格式与初始化流程词库文件的格式我是用一行一个词的方式这种最基础也最好处理。不过直接用txt文件有个问题没有分类信息。所以在实际项目中我扩展成了两列用分隔符把词和类别分开。暴力|暴力 违禁品|违禁品 代购|广告这样做的好处是过滤的时候能知道命中的词属于什么类别方便做不同处理。初始化流程分为三步读取词库文件逐行解析跳过空行和注释行把词条插入前缀树同时记录词的长度和类别对比新旧词库输出新增和删除的词条数量这里有个细节很多人会忽略加载之前要先对词条做去重和排序。去重很好理解排序是为了后面调试方便两个词在文件里的顺序稳定了出问题的时候才能快速定位。2.2 用前缀树组织敏感词前缀树Trie是敏感词过滤最直观的数据结构。核心思想是把敏感词按字符拆开每个节点存一个字符从根节点到叶子节点的一条完整路径就是一个敏感词。举个例子假设词库里有赌博和赌场前缀树会长这样根节点 → 赌 → 博终止根节点 → 赌 → 场终止赌博和赌场共享了赌这个前缀所以在匹配的时候只需要遍历一遍文本就能找出所有敏感词不需要对每个敏感词都跑一遍字符串查找。在Java里每个节点用一个Map存子节点再加一个标志位表示当前节点是否是一个词的结尾顺便存一下词的类别。class TrieNode { MapCharacter, TrieNode children new HashMap(); boolean isEnd false; String category; int length; }插入词条的时候从根节点开始逐字符往下走没有子节点就创建。走到最后一个字符把isEnd置为true记录词的长度和类别。这段逻辑很基础但它是整个过滤器的基石。2.3 加载结果的验证方法词库加载完别急着上线先做一轮验证。我的做法是写一个自检方法从词库里随机抽一批词逐个拿来过滤确认每个词都能被命中。再手动造几个正常句子确保没有误杀。这一步看着简单但能拦住大量低级问题。我遇到过的情况是数据库里导出的词条自带BOM头或者Windows换行符\r结果第一个词永远匹配不上或者最后一个字符被吞了。用自检方法一跑问题马上就暴露了。验证通过的指标我一般卡两个召回率大于99.5%也就是词库里的词基本都能命中误报率低于0.1%拿正常语料跑一遍不能有莫名其妙被拦截的。3. 核心过滤算法实现3.1 单次扫描匹配算法基础匹配算法的思路是双指针加前缀树。外层指针遍历文本的每一个字符内层指针顺着前缀树往下走只要能一直匹配上就一直走走到某个节点时isEnd为true就说明命中了一个敏感词。有个关键细节命中了词之后要从敏感词最后一个字符的下一个位置继续匹配而不是从起始位置的下一个字符继续。这样能避免重叠匹配导致同一个敏感词被重复处理。不过这里需要做取舍像赌博赌场这种两个敏感词紧挨着的场景直接从尾部继续会导致第二个词漏掉。我的处理方式是命中后记录结果然后把外层指针移动到当前词结束位置同时再从下一个位置继续匹配一次也就是跳跃 重试。public ListHitWord filter(String text) { ListHitWord hits new ArrayList(); TrieNode current root; int start 0; int i 0; while (i text.length()) { char c text.charAt(i); TrieNode next current.children.get(c); if (next null) { // 当前路径匹配失败从下一个字符重新开始 current root; i start 1; start i; } else { if (next.isEnd) { hits.add(new HitWord(text.substring(start, i 1), next.category, start, i)); // 跳到敏感词后面继续匹配同时从start1重试防止漏掉重叠词 current root; i i 1; start i; } else { current next; i; } } } return hits; }这段代码看着简单其实我前前后后调了好几版主要就是处理重叠匹配和连续命中的边界问题。实际测试下来这个算法对常规敏感词过滤场景完全够用1万词库、1000字的文本匹配耗时在毫秒级。3.2 命中星号替换策略匹配到敏感词之后最常见的处理方式是替换成*。但这个看起来简单的需求其实也有门道。我原来的实现是直接替换成固定数量的星号跟原词长度一致。后来测试发现有些场景希望固定替换成三个星号不管原词多长。两种策略各有适用场景按长度替换适合需要保留原文可读性的场景用户能看出哪里被屏蔽了固定数量替换适合直接抹除信息的场景让敏感内容完全不可见所以我在这块做了一个可配置的策略接口默认按长度替换但提供固定数量的选项。另外还加了一个保留首字符的策略比如赌*这种既能起到提示作用又不至于完全看不出来原词。实际业务中还有一个场景有些词不是敏感词但组合在一起就是敏感内容。比如涉政词汇周围跟着某些形容词这些上下文关联的判定单纯靠词典匹配是搞不定的得靠规则引擎或者模型。我在这个项目里暂时没有做这层以后可以作为一个扩展方向。3.3 多模式匹配的进阶优化AC自动机说句实在话如果词库只有几万个词上文的前缀树匹配完全足够了。但如果你要处理的文本特别长比如几MB的文档或者词库膨胀到百万级别那就得上AC自动机了。AC自动机可以理解成前缀树上跑KMP它给每个节点加了一个失败指针fail指针当某个字符匹配失败的时候通过fail指针跳到另一个节点继续匹配避免了回溯重新匹配的成本。这样说可能还有点抽象举个生活化的例子你在图书馆找一本书A书架没有不需要回到门口重新找而是直接去邻接的B书架继续翻。fail指针就是这个邻接书架的索引。在一开始的项目里我并没有立刻用AC自动机而是专门写了一个可替换的接口。后来词库从5万扩到30万的时候我实现了AC版本匹配性能提升了将近三倍。不过代价是构建fail指针的复杂度增加代码量也大了不少。所以这块的建议是词库小用Trie够用真到了性能瓶颈再切AC别过度设计。4. 扩展机制设计4.1 三种扩展方式百度词库只是一个起点真正的竞争力在于扩展能力。我在项目里实现了三种扩展方式第一种是本地词库文件更新。运营直接把新词加到文件里通过管理接口触发热加载不需要重启服务。第二种是数据库扩展。建一张敏感词表服务启动时和定时任务里从数据库加载词库支持在线增删改查。适合词库变更非常频繁的场景。第三种是配置中心扩展。如果是微服务架构可以接入Nacos或者Apollo词库变更通过配置中心推送所有服务实例同步更新。这三种方式不是互斥的实际使用中我建议本地文件 数据库组合本地文件放通用基础词库数据库放业务自定义词库加载的时候做一个合并。4.2 黑白名单与权重设置扩展机制里最容易被忽视但实际价值极高的是白名单。白名单的作用是某些词虽然命中了敏感词库但在特定业务场景下是合法的需要放行。举个例子小姐这个词在通用词库里大概率是敏感的但在酒店服务行业它就是一个正常称呼。又比如一些游戏里的特殊术语可能和敏感词字面重合但游戏场景里完全没问题。我的实现是维护一个白名单前缀树。过滤的时候先跑敏感词命中再对命中的词做一次白名单校验如果在白名单里就直接跳过。黑名单则是反过来有些词不在基础词库里但在这个业务里必须拦截比如某个产品特有的竞品词。黑名单可以直接当普通敏感词处理只是来源不同、优先级更高。权重设置这一块我做得比较简单每个词可以配一个等级高、中、低。高等级的词一命中就拒绝发布中等级的词走人工审核低等级的词只记录不拦截。这种分级策略在实际业务中特别有用能大大降低误杀带来的用户投诉。4.3 动态更新词库而不重启服务敏感词过滤服务最忌讳的就是每次改词库都要重启。重启意味着短暂的不可用高并发场景下还会造成请求堆积。所以热更新是刚需。热更新实现的核心思路是读写分离的引用切换。我用了一个volatile关键字修饰当前活跃的前缀树引用更新词库的时候先在内存里构建一棵新的前缀树构建完成后再把引用切换过去。private volatile TrieNode activeRoot; public synchronized void reload(ListString words) { TrieNode newRoot buildTrie(words); this.activeRoot newRoot; }因为volatile保证了内存可见性其他线程在activeRoot切换后下一次读取就能拿到新词库。构建期间旧词库还在服务整个过程对请求方完全无感。这种方案我实测下来词库从10万更新到12万切换耗时在几十毫秒级别线上服务完全不用停。不过有个坑需要注意构建新前缀树期间如果有写入操作线程安全性需要保障。我的做法是加synchronized锁住reload方法每次只允许一个线程触发重载避免并发构建出多棵新树互相覆盖。5. 常见问题与排查技巧实录5.1 词库加载慢、内存占用高怎么办词库从5万发展到30万时我第一次遇到了内存告警。排查了一下内存主要吃在TrieNode上。每个节点都有一个HashMapCharacter, TrieNode而HashMap本身的存储开销就不小几万个节点叠加起来内存占用轻松超过几百MB。这里的第一个优化方案是当节点只有一个子节点时用char字段替代HashMap极端情况下保存单个字当节点有多个子节点时才升级为HashMap。这个方案实现起来稍微复杂一点但内存能省下30%左右。另一个处理路径是直接启用AC自动机的fail指针构建虽然构建时也会消耗一些内存但后续匹配时的计算开销显著下降整体用了后内存占用其实也降了因为Trie树里很多字符串共享节点AC自动机在优化重叠前缀方面比纯Trie更高。5.2 误杀与漏报的平衡策略误杀和漏报是敏感词过滤里的跷跷板压了一头必然翘起另一头。很常见的一个场景词库里加了代购结果所有提到海外代购攻略的帖子都发不出去了用户疯狂投诉。我自己的经验是一定要做分级处理和上下文感知。分级处理前面说过了差异化拦截。上下文可以是词的前后若干个字命中了敏感词再往前看看是不是有特定的修饰语比如反对XX和坚决反对XX语义截然相反但字面上匹配的是同一个敏感词。这种场景规则引擎能解决一部分但根本解法还是要引入语义模型属于另一个层面的问题了。对于纯粹的词典匹配建议是敏感词宁可漏报也不要误杀。漏报还能通过后续的人工审核补救误杀直接流失的是用户信任。所以词库里能不碰的灰色词尽量不碰命中高危词再走审核流程。5.3 性能压测与最优参数选择压测是我上线前的固定动作。我用JMeter对过滤接口做了压测词库10万词文本长度平均500字机器配置普通的4核8G。实测结果Trie树版本QPS约3000延迟P99在3ms左右。切换AC自动机后QPS提升到8000以上P99延迟降到1ms以内。如果词库不经常变化强烈建议用AC自动机这个词库规模下的收益非常明显。还有一个参数容易被忽视最大匹配长度。有些异常长的词其实是用户误输入的不是真敏感词过滤时却拖慢了匹配速度。我的方案是给敏感词长度设一个上限超过这个长度的候选词不再继续匹配默认50个字符足够覆盖所有正常敏感词。单条超长文本的处理也要有兜底超时直接放行绝不能因为过滤拖垮主流程。写在最后敏感词过滤这个项目做下来我最大的体会是它看着简单实则有非常多细节。词库怎么维护、算法怎么选、误杀漏报怎么平衡、热更新怎么做到无感每一个点都能展开讲很久。这个版本的方案不敢说多完美但至少在我经手的几个业务线里扛住了真实流量通过这套基础词库 可扩展架构 分级策略的组合拳从上线到现在没有出过比较大的内容安全事故。最后再分享一个小技巧词库的更新日志一定要做每次变更了什么词、谁改的、什么时候改的全部记录下来。遇到线上问题回溯的时候这些日志比任何技术方案都管用。如果后续有时间我打算在这个项目的基础上继续做上下文语义维度的过滤把意思对但字面不匹配的那些漏网之鱼也捞出来。本文还有配套的精品资源点击获取
返回列表