和hanlp_HanLP在海外文章的应用

129f069277e2f50fa11e4c6675defc6e.gif

总篇107篇 2020年第31篇

  • 背景

文章板块是汽车之家海外站(yesauto.com)的重要组成部分,在产生自发流量和整站SEO方面作用明显。为方便读者,提升阅读体验,同时让汽车内容与汽车销售产生更直接的关联,即提升留资转化率,需要更有效的方式将文章内容与经销商库存直接关联起来。

因为汽车评测文章内容中包含很多品牌、车系等信息,直接把品牌、车系变成热点,配置相关超链接,这样用户点击时,能直接跳转到该品牌的库存列表页面。将直接产生导流效果,也符合用户的期待。

显然,随着网站输出的文章内容越来越多,如果编辑采用手动方式的把文章内容与品牌车系部分替换成对应相关的链接,会花费很多不必要的时间开销。为了让编辑更专注在提升内容的品质上,最终采用软件方式,笔者开发出扫描文章动态增加超链接功能。

匹配的内容是到车系级别,词库的数据是通过经销商库存接口返回的数据来构建词库。英文词库大概在330+个单词词组14000+个字符,德文词库大概是280+单词词组6500+个字符。英国文章平均字符数大概5000+个字符,德国文章平均字符数也大概5000+个字符。同时,需要解决以下两方面的技术问题:

一是最好不改变原文内容,主要原因是创作者可能会编辑修改,加入链接后会让创作者感到意外;

二是库存列表本身也是动态的,可能今天某个车系、车型有库存,则加链接是合适的,但明天没有了,就不应该加链接,换句话说,链接能根据库存情况,在用户阅读文章那一刻动态添加。

基于以上的考虑,简单地循环关键词,遍历文章替换的方案在时间复杂度显然不可行。本文介绍的算法能在时间复杂度上很好地满足以上两点需求。采用开源的HanLP系统进行构建词库和匹配内容。

  • HanLP简介

HanLP系统的关键词匹配算法是通过双数组Trie (Double-ArrayTrie)的Aho-Corasick自动机实现的一种自动机。关于算法本身本篇只做个粗略介绍。
  • Aho-Corasick算法
Aho–Corasick(简称AC算法)算法是用于在输入的一串字符串中匹配有限组“字典”中的子串。它与普通字符串匹配的不同点在于同时与所有字典串进行匹配。算法均摊情况下具有近似于线性的 时间复杂度 ,约为字符串的长度加所有匹配的数量。然而由于需要找到所有匹配数,如果每个子串互相匹配(如字典为a,aa,aaa,aaaa,输入的字符串为aaaa),算法的时间复杂度会近似于匹配的二次函数。 该算法主要依靠构造一个 有限状态机 (类似于在一个 trie树 中添加失配指针)来实现。这些额外的失配指针允许在查找字符串失败时进行回退(例如设Trie树的单词cat匹配失败,但是在Trie树中存在另一个单词cart,失配指针就会指向前缀ca),转向某前缀的其他分支,免于重复匹配前缀,提高算法效率。
  • Trie树

Trie树也称前缀树。即将所有模式串构建为一颗字典树,同时将终止状态绑定外部value。Trie树利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较。Trie树的基本实现有两种,array和linked-list(都以bachelor、baby、badge、jar举例)。

753bd8e352bcabc12937a8ed78160f82.png

(图片出自www.semanticscholar.org)   上图是用array来实现Trie建立字典,每个字符需要开辟a~z字母表的空间,是非常浪费空间的,但是查询时间复杂度O(1),基本上比如我们可以这样实现一棵前缀树,每个节点都是完整的索引。这样实现起来非常简单,但正如上图可看出的,bachelor, baby, badge, jar这几个单词就消耗了大量的空间。 

70957ec1e25dc718261fdf89c269f72f.png

(图片出自www.semanticscholar.org)   上图是用linked-list来实现Trie,这样确实可以减少空间的浪费。但是增加查询时间复杂度,公共前缀就意味着多次回溯。
  • Double-ArrayTrie
双数组Trie (Double-Array Trie)结构由日本人JUN-ICHI AOE于1989年提出的,是Trie结构的压缩形式,仅用两个线性数组来表示Trie树,该结构有效结合了数字搜索树(Digital Search Tree)检索时间高效的特点和链式表示的Trie空间结构紧凑的特点。双数组Trie的本质是一个确定有限状态自动机(DFA),每个节点代表自动机的一个状态,根据变量不同,进行状态转移,当到达结束状态或无法转移时,完成一次查询操作。对比素Trie树的优势,在于在双数组所有键中包含的字符之间的联系都是通过简单的数学加法运算表示,不仅提高了检索速度,而且省去了链式结构中使用的大量指针,节省了存储空间。 Double-array结合了array查询效率高、list节省空间的优点,具体是通过两个数组base、check来实现。Trie树可以等同于一个 自动机 ,状态为树节点的编号,边为字符;那么goto函数g(r,c)=sg(r,c)=s则表示状态r可以按字符c转移到状态s。base数组便是goto函数array实现,check数组为验证转移的有效性;两个数组满足如下转移方程: base[r] + c = s check[s] = r 33509d325039edd3fe181c2443a72e95.png (图片出自www.semanticscholar.org) 其中,字符的编码表为{'#'=1, 'a'=2, 'b'=3, 'c'=4, etc. }。为了对Trie做进一步的压缩,用tail数组存储无公共前缀的尾字符串:
tail of string [b1..bh] has no common prefix and the corresponding state is m:  base[m] < 0;  p = -base[m], tail[p] = b1, tail[p+1] = b2, ..., tail[p+h-1] = bh;  

 那么,用DAT检索词badge的过程如下:

// root -> b  base[1] + 'b' = 4 + 3 = 7  // root -> b -> a  base[7] + 'a' = 1 + 2 = 3  // root -> b -> a -> d  base[3] + 'd' = 1 + 5 = 6  // badge#  base[6] = -12  tail[12..14] = 'ge#'  应用

       主要流程如下:

e6d81b9e0d44990e59eb9932e78d8c93.png

  首先引入JAR包,JAR集合了自建的一些中文词库。但是对于业务使用不上,所以得自建词库。  
<dependency>      <groupId>com.hankcsgroupId>      <artifactId>hanlpartifactId>      <version>portable-1.7.7version>  dependency> 

  • 构建词库

业务需求方面,主要是要根据文章内容中品牌或者车系的字符来进行匹配,并且生成的指向经销商的库存页面的超链接。例如:Land Rover跳转到https://example.com/land-rover,所以构建的词库,会存储品牌、品牌-车系的两种数据结构进行存储。在HanLP中,DoubleArrayTrie是有序的TreeMap构建,key存储词库,value就用来存储业务要求跳转的后缀。返回的业务数据是可预见的,所以相应的做了转换。

/**   * 构建词库   */   TreeMap<String, String> treeThesaurus = new TreeMap<String, String>();   resultMap.getResult().get("params").forEach(o -> {       if ("makeList".equals(o.get("paramname")))  {           o.get("options").forEach(d-> {               //构建品牌               String sourceBrandValue = d.get("value").toString().trim();               String brandValue = replaceStockWord(sourceBrandValue.replace(" ","-"));               treeThesaurus.put(sourceBrandValue.toUpperCase(), brandValue);               List<Map> options = (List<Map>) d.get("options");               if (!CollectionUtils.isEmpty(options)) {                   options.forEach(v-> {                       //构建品牌--》车系                       String svalue = v.get("value").toString().trim();                       String value = brandValue+"/"+replaceStockWord(svalue.replace(" ","-"));                       treeThesaurus.put((sourceBrandValue+" "+svalue).toUpperCase(),                               value.trim());                       }                   });               }           });       }   }); 
  • 匹配文章
文章的内容都是以HTML格式存储,会涉及到除去标签匹配确认位置之后,再进行替换文字超链。 首先调用JSoup包的clean的方法把标签进行剔除。
String cleanArticle = Jsoup.clean(content, Whitelist.none());
剔除完之后的字符串和用构建好的词库再去调用了HanLP的parseText方法。 parseText需要实现一个命中字符串后处理的接口。
public interface IHit<V>  {      /**      * @param begin 模式串在母文本中的起始位置      * @param end   模式串在母文本中的终止位置      * @param value 模式串对应的值      */      void hit(int begin, int end, V value);  } 
  这里需要记录命中的位置,去做相同位置字符串的最长匹配。 这里我采用了另外个TreeMap来进行剔除。 并且使用命中的最起始字符的下表来做key,value使用构建BuildThesaurus实体,主要是用来存储命中begin、end和词库拼接好的value。 为后面进行替换的这步操作路标记好起止位置和需替换字符:   BuildThesaurus实体结构,begin存匹配起始字符下表、end存匹配字符结束下表、sourceStr存储字典库的value:
public class BuildThesaurus {      private Integer begin;      private Integer end;      private String sourceStr;      //get set.....  } 
  实现IHIT接口,找到起始位置相同且长度最长的字符串:
BuildThesaurusConfiguration.getAhoCorasickDoubleArrayTrie(region).parseText(s.toUpperCase(), new AhoCorasickDoubleArrayTrie.IHit()          {              @Override              public void hit(int begin, int end, String value)              {                  BuildThesaurus bt = new BuildThesaurus(begin, end, value);                  if (null != buildThesaurusMap.putIfAbsent(begin, bt)) {                      if (bt.getSourceStr().length() > buildThesaurusMap.get(begin).getSourceStr().length()) {                          buildThesaurusMap.put(begin, bt);                      }                  }              }          });  
build ThesaurusMap存了相同最长匹配的字符串和此字符串,在除去html标签下的字符串的首位下标,为下一步原文替换做准备。
  • 判断单词
带着HTML原文替换的方法,主要是需要把原文按每行去标签形式进行循环和替换操作。这里还需要进行断词的判断。比如: 1.     匹配单词是audi,但是句中是audience 2.     匹配单词是audi,句中是+audi 等等这些情况。进行了简单的判断,判断是否符合替换的标准

private static boolean judgingWord (int begin, int end, int wordsLength, String ownText) {          //判断是否为是句子第一个单词          if (begin == 0 && end != wordsLength) {          ....              return true;          }          //判断是否为是句子结尾单词          if (end == wordsLength && begin != 0) {          ....              return true;          }          //略      }  
  • 替换

判断句中有可以替换的单词之后,开始按照之前匹配 最后把超链接拼接字符串。这里注意需要记录替换前后字符变化长度的差值。每次替换之后,和最开始进行匹配存储的字符串字符下标会对应不上,所以得全局记录替换前后字符串差值。在下次循环替换的时候,才能找到真正需要替换的字符起始位置。

  • 总结

本文主要贴合工作中遇到的业务需求,使用的HanLP在海外业务中的一些实际应用。目前海外业务主要是以英德两文为主,所以HanLP另外的一些强大的功能例如:分词、命名实体识别、篇章理解、依存句法分析等等应用不上。所以只是介绍目前在使用的功能,如有更好的解决方案或者建议的部分,欢迎指出。

  • 参考文献

  • Aho–Corasick算法》-----维基百科

  • http://www.hankcs.com/program/algorithm/aho-corasick-double-array-trie.html

  • https://www.cnblogs.com/en-heng/p/6265256.html

2d50561393f51d4a14603e7e7d8b9d9b.png

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值