摘要:對於中文的搜索來說,詞庫系統是一個很比較重要的模塊,本篇以IK分詞器為例子,介紹如何讓分詞器從緩存或文件系統中自動按照一定頻次進行加載擴展詞庫
Lucene、Solr或ElasticStack如何從外部動態加載詞庫進入到內存作為分詞使用,且這一系列動作不需要重啟相應的搜索服務?當前市面上各種博客、論壇都是各種轉載或者只是最簡單的使用IK,根本無法達到線上使用的條件,而IK分詞器默認是一次啟動將主詞庫、停用詞以及擴展詞庫全部加載完畢,后續如果再想要增加額外的擴展詞就必須得修改對應的擴展詞表並重新打包上傳並重啟服務方能生效,這種方式不適合應用與線上服務。那么到底如何實現這種無縫擴充詞庫呢?下面針對IK來做分析,其他的幾種分詞器,都是大同小異的原理。
(一)詞庫介紹
不論使用什么類型的分詞器,一般都少不了使用詞庫,而詞庫里面,除了主詞庫之外,還有擴展詞庫,同義詞庫,禁用詞庫等,其中擴展詞庫,同義詞庫,禁用詞庫是比較基礎的詞庫,一般類型的業務開發,使用這3種詞庫后,基本能滿足需求,特殊情況需要另外考慮。
(二)詞庫需求
每一個網站都需要有一個特定行業的詞庫,來豐富詞庫系統,當然你可以不用建立詞庫,這樣的效果可能檢索的時候,用戶體驗可能會比較差,在系統運行過程中,詞庫是可以動態更新的,所以要求我們的分詞器,能夠動態更新所有的詞庫,比如禁用詞,同義詞,擴展詞等,這樣做動態性比較好,但已經建好索引的文本,與目前的詞庫可能會存在一些誤差,這種差別會在下一次重建索引時得到改變,所謂詞庫的動態更新,也就是在后台單獨起個線程定時在內存里重新Load詞庫。
(三)為什么需要從數據庫或文件系統加載詞庫?
在實際的開發中,搜索作為一個重要的組件,很少單獨部署作為一個應用,除非是那種比較小的數據量,或者對搜索要求不是非常嚴格,通常在互聯網或者電子商務行業,特別是電商行業,因為訪問量比較大,對系統並發、負載均衡、響應請求要求比較高,所以搜索作為一個關鍵的應用通常需要采用集群的方式來構建一個高可用,高擴展的檢索系統,在集群中,一般采用主從架構的方式,這樣一來1主N從,需要有很多份詞庫文件,如果詞庫經常變化那么這種牽一發而動全身的趨勢,就會變的很明顯,解決辦法主要有2種:
(1)在配置主從同步架構時,把變化的詞庫放在Master上,然后同步的時候把詞庫的配置文件也同步過去。
(2)第二種就是今天主題所說,所有的詞庫文件都從某一個集中的地方管理,然后各個solr節點,定時從數據庫或緩存里讀取並更新(在IK源碼的Dictionary里進行更新)。
第一種方式的弊端在於,僅僅在solr的主從架構時,采用這種會比較方便,如果是solrcloud的模式,這種方法就不適用了
第二種方式相對來說比較方便,整個集群只維持一份詞庫文件,改動較小,而且更好的辦法我們可以結合本地詞庫+數據庫的方式一起工作,這樣一來當數據庫出現宕機的時候,我們的詞庫仍能正常工作。
(四)使用流程簡析
定義一個IKTokenizerFactory類繼承TokenizerFactory並實現ResourceLoaderAware接口,並重寫inform方法和create方法,在solr里配置使用。
GitHub源碼地址:https://github.com/liang68/ik-analyzer-solr6
(五) 修改詳情
1. 源碼級別(需要JDK1.8)
代碼下載、編譯和修改,擴展自己用於動態更新詞庫的類IKTokenizerFactory以及固定頻次掃描的UpdateKeeper類。具體看如下代碼示例:
package:org.wltea.analyzer.lucene
可以直接從我的github上下載solr-6.3.0版本下的詞庫動態更新版。額外增加用於配置文件配置IK詞庫加載的工廠類:IKTokenizerFactory,具體源代碼見下或者參看我的github源碼。其中集成了TokenizerFactory,實現了ResourceLoaderAware和UpdateKeeper.UpdateJob接口,需要重寫inform和create方法:

1 /** 2 * @author liangyongxing 3 * @editTime 2017-02-06 4 * 增加IK擴展詞庫動態更新類 5 */ 6 public class IKTokenizerFactory extends TokenizerFactory 7 implements ResourceLoaderAware, UpdateKeeper.UpdateJob { 8 private boolean useSmart = false; 9 private ResourceLoader loader; 10 private long lastUpdateTime = -1L; 11 private String conf = null; 12 13 public IKTokenizerFactory(Map<String, String> args) { 14 super(args); 15 this.useSmart = getBoolean(args, "useSmart", false); 16 this.conf = get(args, "conf"); 17 System.out.println(String.format(":::ik:construction:::::::::::::::::::::::::: %s", this.conf)); 18 } 19 20 @Override 21 public Tokenizer create(AttributeFactory attributeFactory) { 22 return new IKTokenizer(attributeFactory, useSmart()); 23 } 24 25 @Override 26 public void inform(ResourceLoader resourceLoader) throws IOException { 27 System.out.println(String.format(":::ik:::inform:::::::::::::::::::::::: %s", this.conf)); 28 this.loader = resourceLoader; 29 update(); 30 if ((this.conf != null) && (!this.conf.trim().isEmpty())) { 31 UpdateKeeper.getInstance().register(this); 32 } 33 } 34 35 @Override 36 /** 37 * 執行更新詞典操作 38 * @throws IOException 39 */ 40 public void update() throws IOException { 41 Properties p = canUpdate(); 42 if (p != null) { 43 List<String> dicPaths = SplitFileNames(p.getProperty("files")); 44 List inputStreamList = new ArrayList(); 45 for (String path : dicPaths) { 46 if ((path != null) && (!path.isEmpty())) { 47 InputStream is = this.loader.openResource(path); 48 49 if (is != null) { 50 inputStreamList.add(is); 51 } 52 } 53 } 54 if (!inputStreamList.isEmpty()) 55 Dictionary.reloadDic(inputStreamList); 56 } 57 } 58 59 /** 60 * 檢查是否要更新 61 * @return 62 */ 63 private Properties canUpdate() { 64 try { 65 if (this.conf == null) 66 return null; 67 Properties p = new Properties(); 68 InputStream confStream = this.loader.openResource(this.conf); 69 p.load(confStream); 70 confStream.close(); 71 String lastupdate = p.getProperty("lastupdate", "0"); 72 //System.err.println(String.format("read %s file get lastupdate is %s.", this.conf, lastupdate)); 73 Long t = new Long(lastupdate); 74 if (t.longValue() > this.lastUpdateTime) { 75 this.lastUpdateTime = t.longValue(); 76 String paths = p.getProperty("files"); 77 if ((paths == null) || (paths.trim().isEmpty())) 78 return null; 79 System.out.println("loading conf files success."); 80 return p; 81 } 82 this.lastUpdateTime = t.longValue(); 83 return null; 84 } 85 catch (Exception e) { 86 //e.printStackTrace(); 87 System.err.println("IK parsing conf NullPointerException~~~~~" + e.getStackTrace()); 88 } 89 return null; 90 } 91 92 public static List<String> SplitFileNames(String fileNames) { 93 if (fileNames == null) { 94 return Collections.emptyList(); 95 } 96 List result = new ArrayList(); 97 for (String file : fileNames.split("[,\\s]+")) { 98 result.add(file); 99 } 100 return result; 101 } 102 103 private boolean useSmart() { 104 return this.useSmart; 105 } 106 }
UpdateKeeper這個類主要實現了Runnable接口,封裝了每1分鍾自動讀取配置,查看當前配置是否發生改變(根據配置的lastupdate=1 整數與當前內存中的整數做對比從而判斷是否需要重新加載擴展詞庫),若發生改變會自動讀取擴展詞庫將其加入到內存中。

1 /** 2 * Created by liangyongxing on 2017/2/6. 3 * 1分鍾自動判斷更新 4 */ 5 public class UpdateKeeper implements Runnable { 6 static final long INTERVAL = 60000L; 7 private static UpdateKeeper singleton; 8 Vector<UpdateJob> filterFactorys; 9 Thread worker; 10 11 private UpdateKeeper() { 12 this.filterFactorys = new Vector(); 13 14 this.worker = new Thread(this); 15 this.worker.setDaemon(true); 16 this.worker.start(); 17 } 18 19 public static UpdateKeeper getInstance() { 20 if (singleton == null) { 21 synchronized (UpdateKeeper.class) { 22 if (singleton == null) { 23 singleton = new UpdateKeeper(); 24 return singleton; 25 } 26 } 27 } 28 return singleton; 29 } 30 31 public void register(UpdateJob filterFactory) { 32 this.filterFactorys.add(filterFactory); 33 } 34 35 public void run() { 36 while (true) { 37 try { 38 Thread.sleep(INTERVAL); 39 } catch (InterruptedException e) { 40 e.printStackTrace(); 41 } 42 43 if (!this.filterFactorys.isEmpty()) { 44 for (UpdateJob factory : this.filterFactorys) { 45 try { 46 factory.update(); 47 } catch (IOException e) { 48 e.printStackTrace(); 49 } 50 } 51 } 52 } 53 } 54 55 public interface UpdateJob { 56 void update() throws IOException; 57 } 58 }
package:org.wltea.analyzer.dic
這個包下的Dictionary類是加載詞典到內存的主要類,針對自動動態擴展詞庫功能需要在這個類中增加額外的方法來實現只針對自己配置的擴展詞庫進行加載,具體增加的代碼片段詳情如下(考慮到該類代碼有點多,只貼出來自己新增加的方法):
1 /** 2 * 重新更新詞典 3 * 由於停用詞等不經常變也不建議常增加,故這里只修改動態擴展詞庫 4 * @param inputStreamList 5 * @author liangyongxing 6 * @createTime 2017年2月7日 7 * @return 8 */ 9 public static Dictionary reloadDic(List<InputStream> inputStreamList) { 10 if (singleton == null) { 11 Configuration cfg = DefaultConfig.getInstance(); 12 initial(cfg); 13 } 14 for (InputStream is : inputStreamList) { 15 try { 16 BufferedReader br = new BufferedReader(new InputStreamReader(is, "UTF-8"), 512); 17 String theWord = null; 18 HashMap<String, String> map = new HashMap(); 19 do { 20 theWord = br.readLine(); 21 if (theWord != null && !"".equals(theWord.trim())) { 22 singleton._MainDict.fillSegment(theWord.trim().toLowerCase().toCharArray()); 23 } 24 } while (theWord != null); 25 } catch (IOException ioe) { 26 System.err.println("Other Dictionary loading exception."); 27 ioe.printStackTrace(); 28 } finally { 29 try { 30 if (is != null) { 31 is.close(); 32 is = null; 33 } 34 } catch (IOException e) { 35 e.printStackTrace(); 36 } 37 } 38 } 39 return singleton; 40 }
改好了之后需要修改兼容版本的pom.xml文件,對應的lucene版本如下:
<groupId>org.wltea.ik-analyzer</groupId> 2 <artifactId>ik-analyzer-solr</artifactId> 3 <version>6.3.0</version> 4 <packaging>jar</packaging> 6 <name>ik-analyzer-solr6.3</name> 7 <url>http://code.google.com/p/ik-analyzer/</url> 9 <properties> 10 <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> 11 <lucene.version>6.3.0</lucene.version> 12 <javac.src.version>1.8</javac.src.version> 13 <javac.target.version>1.8</javac.target.version> 14 <maven.compiler.plugin.version>3.3</maven.compiler.plugin.version> 15 </properties>
改完之后重新build之后對應的有個核心類會拋異常,對應的類IKQueryExpressionParser會報錯,報錯的原因是對應的方法以及下線了,需要修改為當前最新用法,通過官方API文檔看到BooleanQuery的使用方法已經改變,將其改正。 具體修改如下所示:
修改為如下所示:
改正完成后重新打包即可,將對應的jar上傳到服務器solr安裝的對應dist位置下即可。剩下的就是需要配置好相對應集群的配置文件上傳,具體不要心急繼續往后看哦。
2. 配置級別
jar包上傳完之后搜索引擎服務啟動或者重啟之后,需要將配置文件上傳到zk進行統一管理,其中我們需要修改schema.xml和solrconfig.xml
schema.xml中需要將中文分詞引入(6.x名稱修改為managed-schema,該文件采用和ElasticStack類似的映射方式,但是不論solr還是ES還是建議使用人為指定配置方式,這樣不會出一些其他意想不到的問題--具體問題請參看我的博客:http://www.cnblogs.com/liang1101/articles/6379393.html)
<fieldType name="text_zh" class="solr.TextField" > <analyzer type="index" > <tokenizer class="org.wltea.analyzer.lucene.IKTokenizerFactory" useSmart="false" conf="ik.conf"/> <filter class="solr.StopFilterFactory" ignoreCase="true" words="stopwords.txt" /> </analyzer> <analyzer type="query"> <tokenizer class="org.wltea.analyzer.lucene.IKTokenizerFactory" useSmart="false" conf="ik.conf"/> <filter class="solr.StopFilterFactory" ignoreCase="true" words="stopwords.txt" /> </analyzer> </fieldType>
其中userSmart的意思是是否啟用智能分詞,默認是最大細粒度的方式,一般建議index為默認的,query為智能分詞,但是這也需要看自己的業務再做相應的決定;對應conf="ik.conf"是自己指定的,這塊最終是在代碼處進行處理,這里定義的conf則在代碼中需要通過conf名稱去獲取到ik.conf配置文件,再通過ik.conf文件配置項進行判斷是否需要動態更新詞庫,具體的可以參看源代碼,這里就不做多余的解釋了。
solrconfig.xml配置文件需要將你新打的ik-analyzer-solr-6.3.0.jar引入:
<lib dir="/home/solr/solr/dist/" regex="ik-analyzer-solr-\d.*\.jar" />
至此代碼級別和配置級別都已經大功告成,剩下的就是具體的測試流程了。
特此申明:
有好多朋友在按照上述配置運行代碼的時候發現有問題,報出空指針異常等問題,這里我再作進一步的解釋,包括整個要上傳的文件目錄與內容,以及 ik.conf 有什么用
1. ik.conf 作用
ik.conf 主要用於標識 IK 詞庫是否有進行更新的,有一個實時掃描程序在掃描這個文件里的內容從而判定是否需要重新加載詞庫。ik.conf 內容為:
lastupdate=1 files=dict1.txt
其中:lastupdate 后面的數字來記錄當前更新版本,如果有需要修改只需要修改這個值(我一般就是直接 + 1 處理,例如我增加詞庫后會將這里修改為 2)重新上傳即可。而 files 后面的文件指向你自己的擴展詞庫,這樣你想增加新詞庫的話,后面用逗號跟着新文件就可以接入新詞庫文件,而不需要修改 solr ik 底層核心配置文件 IKAnalyzer.cfg,如果修改這個文件必須得重啟 solr,這個線上肯定是不允許的(你要知道一個文件大小限制是1MB,所以可能會隨着詞庫的增加,文件數也是需要增加的)
2. 往 zk 上傳一個集群所需要的配置信息列表
以下列表列出來的就是我在 solr-6.4.0 上做測試時的所有內容
如有其它朋友有什么問題,歡迎在博客下面提問,或者在我對應的 github 地址提問題,這兩塊我有對應的信息提示,會及時回復大家。
(六) 測試詳情
1. 配置信息上傳至ZooKeeper
通過zk上傳命令將對應的配置文件夾上傳,具體上傳命令如下:
/data/solr-6.3.0/server8983/scripts/cloud-scripts/zkcli.sh -zkhost zk1 -cmd upconfig -confdir /data/article_common_newest -confname article_common_newest
一台服務器上起4個實例,分別為:8983/7574/6362/5251,對應服務也分別是server8983/server7574/server6362/server5251
zk1 是其中一台zk服務器也是solr服務,對應的服務器hostname為zk1
article_common_newest 為對應配置文件存放的文件夾名稱,是放在了/data 目錄下
-confname article_common_newest 對應創建集群時所依賴的底層配置名稱

2. 搜索引擎集群創建
curl "http://ip:8983/solr/admin/collections?action=CREATE&name=article_test&router.field=fingerprint&numShards=2&replicationFactor=2&maxShardsPerNode=2&collection.configName=article_common_newest&createNodeSet=ip:8983_solr,ip:7574_solr"

到創建好的集群內通過界面的Analysis選項卡進行分析,查看IK是否生效:
add by 2017/02/22 增加第二種遠程讀取方案
申明:當前提交在github上的代碼,主版本還是以讀取相對本地文件來進行更新詞庫的(雖然是通過ZooKeeper進行同步的),所以為了區分主邏輯,在需要改動的類上都加上后綴:Remote
1. 本地主詞庫和主擴展詞庫是不需要改動的,即源IKAnalyzer.cfg.xml中的配置是不需要改動的。
2. 由於新增加了遠程讀取詞庫,故需要在IKAnalyzer.cfg.xml中增加兩項配置,指定遠程路徑地址,例如:
<!--用戶可以在這里配置遠程擴展字典 --> <entry key="remote_ext_dict">location</entry> <!--用戶可以在這里配置遠程擴展停止詞字典--> <entry key="remote_ext_stopwords">location</entry>
其中 location 是指一個 url,比如 http://yoursite.com/getCustomDict,該請求只需滿足以下兩點即可完成分詞熱更新。該 http 請求需要返回兩個頭部(header),一個是 Last-Modified,一個是 ETag,這兩者都是字符串類型,只要有一個發生變化,該插件就會去抓取新的分詞進而更新詞庫。該 http 請求返回的內容格式是一行一個分詞,換行符用 \n 即可。
滿足上面兩點要求就可以實現熱更新分詞了,不需要重啟solr
可以將需自動更新的熱詞放在一個 UTF-8 編碼的 .txt 文件里,放在 nginx 或其他簡易 http server 下,當 .txt 文件修改時,http server 會在客戶端請求該文件時自動返回相應的 Last-Modified 和 ETag。可以另外做一個工具來從業務系統提取相關詞匯,並更新這個 .txt 文件。
3. 增加了兩項配置,故需要在源碼級別的配置文件中增加兩個屬性值,用於讀取上面配置的路徑,在org.wltea.analyzer.cfg.ConfigurationRemote中,即:
/** * 獲取遠程擴展詞典配置路徑 * @return List<String> 相對類加載器的路徑 */ public abstract List<String> getRemoteExtDictionarys(); /** * 獲取遠程停止詞配置路徑 * @return List<String> 相對類加載器的路徑 */ public abstract List<String> getRemoteExtStopWordDictionarys();
其次在org.wltea.analyzer.cfg.DefaultConfigRemote類中(這個類為對應獲取相應地址值),增加對這兩個屬性值的讀取:

//配置屬性--遠程擴展詞典 private static final String REMOTE_EXT_DICT = "remote_ext_dict"; //配置屬性--遠程停止詞典 private static final String REMOTE_EXT_STOP = "remote_ext_stopwords"; /** * 讀取遠程擴展詞典內容到內存 */ public List<String> getRemoteExtDictionarys() { List remoteExtDictFiles = new ArrayList(2); String remoteExtDictCfg = this.props.getProperty("remote_ext_dict"); if (remoteExtDictCfg != null) { String[] filePaths = remoteExtDictCfg.split(";"); if (filePaths != null) { for (String filePath : filePaths) { if ((filePath != null) && (!"".equals(filePath.trim()))) { remoteExtDictFiles.add(filePath); } } } } return remoteExtDictFiles; } /** * 讀取遠程停止詞典內容到內存 */ public List<String> getRemoteExtStopWordDictionarys() { List remoteExtStopWordDictFiles = new ArrayList(2); String remoteExtStopWordDictCfg = this.props.getProperty("remote_ext_stopwords"); if (remoteExtStopWordDictCfg != null) { String[] filePaths = remoteExtStopWordDictCfg.split(";"); if (filePaths != null) { for (String filePath : filePaths) { if ((filePath != null) && (!"".equals(filePath.trim()))) { remoteExtStopWordDictFiles.add(filePath); } } } } return remoteExtStopWordDictFiles; }
4. 修改詞典加載主類,為了與之前的org.wltea.analyzer.dic.Dictionary類不相互影響,故新擴展了一個名為:org.wltea.analyzer.dic.DictionaryRemote類,內部基本不用變,只需要修改動態加載詞庫那部分代碼,具體請看第5點。
5. 新增加一個名為:org.wltea.analyzer.dic.Monitor類,用於定時更新詞庫的事件類,這個類主要的功能就是每1分鍾執行一次Monitor類查看指定的指標數據是否發生變化,如果發生變化則自動更新詞庫,否則不變,具體代碼如下所示:

1 /** 2 * 遠程調用文件並定時檢查更新 3 * add by liangyongxing 4 * @createTime 2017-02-22 5 */ 6 public class Monitor implements Runnable { 7 private static CloseableHttpClient httpclient = HttpClients.createDefault(); 8 private String last_modified; 9 private String eTags; 10 private String location; 11 12 public Monitor(String location) { 13 this.location = location; 14 this.last_modified = null; 15 this.eTags = null; 16 } 17 18 @Override 19 public void run() { 20 RequestConfig rc = RequestConfig.custom().setConnectionRequestTimeout(10000).setConnectTimeout(10000).setSocketTimeout(15000).build(); 21 22 HttpHead head = new HttpHead(this.location); 23 head.setConfig(rc); 24 25 if (this.last_modified != null) { 26 head.setHeader("If-Modified-Since", this.last_modified); 27 } 28 if (this.eTags != null) { 29 head.setHeader("If-None-Match", this.eTags); 30 } 31 CloseableHttpResponse response = null; 32 try { 33 response = httpclient.execute(head); 34 35 if (response.getStatusLine().getStatusCode() == 200) { 36 if ((!response.getLastHeader("Last-Modified").getValue().equalsIgnoreCase(this.last_modified)) 37 || (!response.getLastHeader("ETag").getValue().equalsIgnoreCase(this.eTags))) { 38 DictionaryRemote.getSingleton().reLoadMainDict(); 39 this.last_modified = (response.getLastHeader("Last-Modified") == null ? null : response.getLastHeader("Last-Modified").getValue()); 40 this.eTags = (response.getLastHeader("ETag") == null ? null : response.getLastHeader("ETag").getValue()); 41 } 42 } else if (response.getStatusLine().getStatusCode() != 304) { 43 System.err.println("remote_ext_dict " + this.location + " return bad code " + response.getStatusLine().getStatusCode() + ""); 44 } 45 } catch (Exception e) { 46 System.err.println("remote_ext_dict error!" + e.getStackTrace()); 47 } finally { 48 try { 49 if (response != null) 50 response.close(); 51 } catch (IOException e) { 52 e.printStackTrace(); 53 } 54 } 55 } 56 }
6. 在DictionaryRemote類即字典主類初始化時,需要將自動監控加載代碼注冊
7. 新增加IKTokenizerFactoryRemote類,如果想要使用這種方式自動加載詞庫,那么在solrconfig.xml文件中需要配置這個類即可
相應最新的代碼都已經上傳到我的github上,地址為:https://github.com/liang68/ik-analyzer-solr6
有什么問題歡迎留言,我會盡可能早的回復。