搜索引擎核心技术与实现(基于Lucene和Solr)

SevnInfor 贡献于2011-08-02

作者 LG  创建于2011-02-11 11:26:00   修改者walkinnet  修改于2011-06-09 08:27:00字数508872

文档摘要:第一章首先概要的介绍搜索引擎的总体结构和基本模块,然后会介绍其中的最核心的模块:全文检索的基本原理。为了尽快普及搜索引擎开发技术,本章介绍的搜索引擎结构可以采用开源软件实现。为了通过实践来深入了解相关技术,本章中会介绍相关的开发环境。本书介绍的搜索技术使用Java编程语言实现,之所以没有采用性能可能会更好的C/C++,是希望读者不仅能够快速完成相关的开发任务,而且可以把相关实践作为一个容易上手的游戏。另外,为了集中关注程序的基本逻辑,书中的Java代码去掉了一些错误和异常处理,实际可以运行的代码可以在本书附带的光盘中找到。在以后的各章中会深入探索搜索引擎的每个组成模块。
关键词:

 搜索引擎开发实战:基于Lucene和Solr 搜索引擎核心技术与实现 ——Lucene+Solr 罗刚 2011 目录 搜索引擎核心技术与实现 1 第1章 搜索引擎总体结构 2 1.1 搜索引擎基本模块 2 1.2 开发环境 3 1.3 搜索引擎工作原理 4 1.3.1 网络爬虫 5 1.3.2 全文索引结构与Lucene实现 5 1.3.3 搜索用户界面 10 1.3.4 计算框架 10 1.3.5 文本挖掘 12 1.4 本章小结 12 第2章 网络爬虫的原理与应用 14 2.1 爬虫的基本原理 14 2.1.1 广度优先遍历 16 2.1.2 最好优先遍历 18 2.1.3 遍历特定网站 19 2.2 爬虫架构 19 2.2.1 基本架构 20 2.2.2 分布式爬虫架构 22 2.2.3 垂直爬虫架构 23 2.3 下载网络资源 24 2.3.1 下载网页的基本方法 24 - - 目录 2.3.2 HTTP协议 27 2.3.3 使用HttpClient下载网页 33 2.3.4 重定向 39 2.3.5 解决套结字连接限制 40 2.3.6 下载图片 43 2.3.7 抓取FTP 43 2.3.8 RSS抓取 44 2.3.9 网页更新 46 2.3.10 抓取限制应对方法 48 2.3.11 URL地址提取 51 2.3.12 抓取需要登录的网页 52 2.3.13 抓取JavaScript动态页面 58 2.3.14 抓取即时信息 61 2.3.15 抓取暗网 61 2.3.16 信息过滤 62 2.4 URL地址查新 70 2.4.1 BerkeleyDB 70 2.4.2 布隆过滤器 72 2.5 增量抓取 75 2.6 并行抓取 75 2.6.1 多线程爬虫 76 2.6.2 垂直搜索的多线程爬虫 78 - - 目录 2.6.3 异步IO 82 2.7 Web结构挖掘 86 2.7.1 存储Web图 86 2.7.2 PageRank算法 90 2.7.3 HITs算法 97 2.7.4 主题相关的PageRank 102 2.8 部署爬虫 104 2.9 本章小结 105 第3章 索引内容提取 108 3.1 从HTML文件中提取文本 108 3.1.1 字符集编码 108 3.1.2 识别网页的编码 111 3.1.3 网页编码转换为字符串编码 114 3.1.4 使用正则表达式提取数据 114 3.1.5 使用HTMLParser实现定向抓取 117 3.1.6 结构化信息提取 123 3.1.7 网页的DOM结构 126 3.1.8 使用NekoHTML提取信息 127 3.1.9 使用XPath提取信息 136 3.1.10 网页去噪 137 3.1.11 网页结构相似度计算 141 3.1.12 提取标题 143 - - 目录 3.1.13 提取日期 145 3.2 从非HTML文件中提取文本 145 3.2.1 提取标题的一般方法 146 3.2.2 PDF文件 151 3.2.3 Word文件 154 3.2.4 Rtf文件 156 3.2.5 Excel文件 168 3.2.6 PowerPoint文件 171 3.3 图像的OCR识别 172 3.3.1 图像二值化 173 3.3.2 切分图像 175 3.3.3 SVM分类 179 3.4 提取垂直行业信息 183 3.4.1 医疗行业 183 3.4.2 旅游行业 184 3.5 流媒体内容提取 184 3.5.1 音频流内容提取 185 3.5.2 视频流内容提取 188 3.6 存储提取内容 189 3.6.1 存入数据库 189 3.6.2 写入维基 190 3.7 本章小结 191 - - 目录 第4章 中文分词原理与实现 193 4.1 Lucene中的中文分词 193 4.1.1 Lucene切分原理 194 4.1.2 Lucene中的Analyzer 195 4.1.3 自己写Analyzer 198 4.1.4 Lietu中文分词 201 4.2 查找词典算法 201 4.2.1 标准Trie树 202 4.2.2 三叉Trie树 206 4.3 中文分词的原理 210 4.4 中文分词流程与结构 214 4.5 全切分词图 215 4.5.1 保存切分词图 215 4.5.2 形成切分词图 219 4.6 概率语言模型的分词方法 222 4.7 N元分词方法 227 4.8 语料库 229 4.9 新词发现 230 4.10 未登录词识别 231 4.11 词性标注 232 4.11.1 隐马尔可夫模型 236 4.11.2 基于转换的错误学习方法 246 - - 目录 4.12 平滑算法 248 4.13 机器学习的方法 252 4.13.1 最大熵 253 4.13.2 条件随机场 256 4.14 有限状态机 256 4.15 本章小结 264 第5章 让搜索引擎理解自然语言 265 5.1 停用词表 265 5.2 句法分析树 267 5.3 相似度计算 274 5.4 文档排重 278 5.4.1 语义指纹 279 5.4.2 SimHash 282 5.4.3 分布式文档排重 293 5.5 中文关键词提取 294 5.5.1 关键词提取的基本方法 294 5.5.2 HITS算法应用于关键词提取 297 5.5.3 从网页中提取关键词 299 5.6 相关搜索词 299 5.6.1 挖掘相关搜索词 300 5.6.2 使用多线程计算相关搜索词 302 5.7 信息提取 303 - - 目录 5.8 拼写检查与建议 308 5.8.1 模糊匹配问题 311 5.8.2 英文拼写检查 314 5.8.3 中文拼写检查 316 5.9 自动摘要 319 5.9.1 自动摘要技术 319 5.9.2 自动摘要的设计 320 5.9.3 基于篇章结构的自动摘要 326 5.9.4 Lucene中的动态摘要 326 5.10 文本分类 330 5.10.1 特征提取 332 5.10.2 关键词加权法 335 5.10.3 朴素贝叶斯 338 5.10.4 支持向量机 348 5.10.5 多级分类 357 5.10.6 规则方法 359 5.10.7 网页分类 362 5.11 文本聚类 362 5.11.1 K均值聚类方法 363 5.11.2 K均值实现 365 5.11.3 深入理解DBScan算法 370 5.11.4 使用DBScan算法聚类实例 372 - - 目录 5.12 拼音转换 374 5.13 概念搜索 375 5.14 多语言搜索 383 5.15 跨语言搜索 384 5.16 情感识别 385 5.16.1 确定词语的褒贬倾向 388 5.16.2 实现情感识别 390 5.16.3 用户协同过滤 391 5.17 本章小结 393 第6章 Lucene原理与应用 394 6.1 Lucene深入介绍 394 6.1.1 常用查询 395 6.1.2 查询语法与解析 396 6.1.3 查询原理 401 6.1.4 遍历索引库 402 6.1.5 索引数值列 404 6.1.6 检索结果排序 407 6.1.7 处理价格 408 6.2 Lucene中的压缩算法 408 6.2.1 变长压缩 409 6.2.2 PForDelta 411 6.2.3 VSEncoding 413 - - 目录 6.2.4 前缀压缩 415 6.2.5 差分编码 416 6.2.6 设计索引库结构 418 6.3 创建和维护索引库 419 6.3.1 创建索引库 419 6.3.2 向索引库中添加索引文档 420 6.3.3 删除索引库中的索引文档 422 6.3.4 更新索引库中的索引文档 422 6.3.5 索引的合并 424 6.3.6 索引文件格式 424 6.3.7 多线程写索引 427 6.3.8 分发索引 430 6.3.9 修复索引 433 6.4 查找索引库 433 6.4.1 基本查询 433 6.4.2 排序 434 6.4.3 使用Filter筛选搜索结果 435 6.5 读写并发 435 6.6 优化使用Lucene 436 6.6.1 索引优化 436 6.6.2 查询优化 437 6.6.3 实现时间加权排序 440 - - 目录 6.6.4 实现字词混合索引 444 6.6.5 重用Tokenizer 448 6.6.6 定制Tokenizer 449 6.7 检索模型 451 6.7.1 向量空间模型 451 6.7.2 BM25概率模型 456 6.7.3 统计语言模型 461 6.8 查询大容量索引 463 6.9 实时搜索 464 6.10 本章小结 466 第7章 搜索引擎用户界面 467 7.1 实现Lucene搜索 467 7.1.1 测试搜索功能 467 7.1.2 加载索引 468 7.2 搜索页面设计 470 7.2.1 Struts2实现的搜索界面 471 7.2.2 实现翻页 473 7.3 实现搜索接口 475 7.3.1 编码识别 475 7.3.2 布尔搜索 479 7.3.3 指定范围搜索 479 7.3.4 搜索结果排序 481 - - 目录 7.3.5 索引缓存与更新 482 7.4 历史搜索词记录 489 7.5 实现关键词高亮显示 489 7.6 实现分类统计视图 492 7.7 实现相似文档搜索 498 7.8 实现AJAX搜索联想词 499 7.8.1 估计查询词的文档频率 500 7.8.2 搜索联想词总体结构 500 7.8.3 服务器端处理 501 7.8.4 浏览器端处理 507 7.8.5 拼音提示 509 7.8.6 部署总结 510 7.9 集成其他功能 510 7.9.1 拼写检查 510 7.9.2 分类统计 515 7.9.3 相关搜索 522 7.9.4 再次查找 525 7.9.5 搜索日志 525 7.10 搜索日志分析 527 7.10.1 日志信息过滤 527 7.10.2 信息统计 528 7.10.3 挖掘日志信息 531 - - 目录 7.11 部署网站 532 7.12 本章小结 533 第8章 使用Solr实现企业搜索 535 8.1 Solr简介 535 8.2 Solr基本用法 536 8.2.1 Solr服务器端的配置与中文支持 537 8.2.2 把数据放进Solr 542 8.2.3 删除数据 545 8.2.4 Solr客户端与搜索界面 545 8.2.5 Solr索引库的查找 548 8.2.6 索引分发 552 8.2.7 Solr搜索优化 555 8.3 从FAST Search移植到Solr 558 8.4 Solr扩展与定制 560 8.4.1 Solr中字词混合索引 560 8.4.2 相关检索 562 8.4.3 搜索结果去重 564 8.4.4 定制输入输出 567 8.4.5 分布式搜索 572 8.4.6 分布式索引 574 8.4.7 SolrJ查询分析器 576 8.4.8 扩展SolrJ 585 - - 目录 8.4.9 扩展Solr 586 8.4.10 日文搜索 590 8.4.11 查询Web图 591 8.5 Solr的.NET客户端 594 8.6 Solr的PHP客户端 598 8.7 本章小结 601 第9章 使用Hadoop实现分布式计算 602 第10章 地理信息系统案例分析 605 10.1 新闻提取 606 10.2 POI信息提取 611 10.2.1 提取主体 617 10.2.2 提取地区 618 10.2.3 指代消解 620 10.3 本章小结 622 第11章 户外活动搜索案例分析 623 11.1 爬虫 623 11.2 信息提取 624 11.3 分类 627 11.3.1 活动分类 627 11.3.2 资讯分类 628 11.4 搜索 628 11.5 本章小结 629 - - 目录 第12章 英文价格搜索 629 专业英语词汇列表 630 参考资源 633 书籍 633 网址 633 - - 搜索引擎的基本功能 - - 遍历搜索引擎技术 第1章 搜索引擎总体结构 本章首先概要的介绍搜索引擎的总体结构和基本模块,然后会介绍其中的最核心的模块:全文检索的基本原理。为了尽快普及搜索引擎开发技术,本章介绍的搜索引擎结构可以采用开源软件实现。为了通过实践来深入了解相关技术,本章中会介绍相关的开发环境。本书介绍的搜索技术使用Java编程语言实现,之所以没有采用性能可能会更好的C/C++,是希望读者不仅能够快速完成相关的开发任务,而且可以把相关实践作为一个容易上手的游戏。另外,为了集中关注程序的基本逻辑,书中的Java代码去掉了一些错误和异常处理,实际可以运行的代码可以在本书附带的光盘中找到。在以后的各章中会深入探索搜索引擎的每个组成模块。 1.1 搜索引擎基本模块 一个最简单的搜索引擎由索引和搜索界面两部分组成,相对完整的搜索结构如图1-1所示。 文档 文本提取 索引程序 索引库 搜索查询服务器 文件 数据库 爬虫 NBA 搜索 图1-1 搜索引擎的简单结构 实现按关键字快速搜索的方法是建立全文索引库,所以最基础的程序是管理全文索引库的程序。 - - 遍历搜索引擎技术 搜索的数据来源可以是互联网或者数据库,也可以是本地路径等。搜索引擎的基本模块从底向上的结构如图1-2所示: 检索模块 查询结果显示模块 Web交互模块 信息处理分析模块 索引库 文档库 信息采集模块 图1-2 搜索引擎中的主要模块 1.2 开发环境 由于开源软件的迅速发展,可以借助开源软件简化搜索引擎开发工作。很多开源软件用Java语言开发,例如最流行的全文索引库Lucene,所以本书采用Java来自己实现搜索。为了实现一个简单的指定目录文件的搜索引擎,首先要准备好JDK和集成开发环境Eclipse。当前可以使用JDK1.6。JDK1.6可以从Java官方网站http://java.sun.com下载得到。使用缺省方式安装即可。本书中的程序在附赠的光盘中都能找到,可以直接导入到Eclipse中。Eclipse缺省是英文界面,如果习惯用中文界面可以从http://www.eclipse.org/babel/downloads.php下载支持中文的语言包。 Lucene是一个Java实现的jar包用来管理搜索引擎索引库。可以从http://lucene.apache.org/java/docs/index.html下载到最新版本的Lucene,当前的版本是3.0。 如果需要用Web搜索界面,还要下载Tomcat,当前可以从http://tomcat.apache.org/下载到,推荐使用Tomcat6以上的版本。使用开源的全文检索包Lucene做索引后,要把实现搜索的界面发布到Tomcat。 对于Web搜索界面建议使用MyEclipse开发。对于其他的普通的非Web开发工作则不建议使用MyEclipse。例如开发爬虫,建议只使用Eclipse,而不要用MyEclipse。 MyEclipse开发普通的Java项目时速度慢。 - - 遍历搜索引擎技术 Lucene及一些相关项目的源代码由版本管理工具SVN管理,如果要构建源代码工程,可以使用工具Ant和Maven。 如果需要导出Lucene的最新开发版本,就需要用到SVN的客户端。小乌龟TortoiseSVN是最流行的SVN客户端。TortoiseSVN的下载地址是http://tortoisesvn.tigris.org/。安装TortoiseSVN后,选择一个存放源代码的文件夹,单击右键,选择TortoiseSVN菜单中的Export...选项导出源代码。 Ant与Maven都和项目管理软件make类似。虽然Maven正在逐步替代Ant,但当前仍然有很多开源项目在继续使用Ant。从http://ant.apache.org/bindownload.cgi 可以下载到Ant的最新版本。 在windows下ant.bat和三个环境变量相关 ANT_HOME、CLASSPATH 和JAVA_HOME。需要用路径设置ANT_HOME和JAVA_HOME环境变量,并且路径不要以\或/结束,不要设置CLASSPATH。使用echo命令检查ANT_HOME环境变量: >echo %ANT_HOME% D:\apache-ant-1.7.1 如果把Ant解压到c:\apache-ant-1.7.1则修改环境变量PATH,增加当前路径c:\apache-ant-1.7.1\bin。 如果一个项目的源代码根路径包括一个build.xml文件,则说明这个项目可能是用Ant构建的。大部分用Ant构建的项目只需要如下一个命令: #ant 可以从http://maven.apache.org/download.html下载最新版本的Maven,当前版本是maven-2.2.1。解压下载的Maven压缩文件到C: 根路径,将创建一个c:\apache-maven-2.2.1路径。修改Windows系统环境变量PATH,增加当前路径c:\apache-maven-2.2.1\bin。如果一个项目的源代码根路径包括一个pom.xml文件,则说明这个项目可能是用Maven构建的。大部分用Maven构建的项目只需要如下一个命令: #mvn clean install 盖大楼的时候需要搭建最终不会交付使用的脚手架。很多单元测试代码也不会在正式环境中运行,但是必须写的。可以使用JUnit做单元测试。 1.3 搜索引擎工作原理 一个基本的搜索包括采集数据的爬虫和索引库管理以及搜索页面展现等部分。 - - 遍历搜索引擎技术 1.3.1 网络爬虫 网络爬虫(Crawler)又被称作网络机器人(Robot),或者蜘蛛(Spider),它的主要目的是为获取在互联网上的信息。只有掌握了“吸星大法”,才能源源不断的获取信息。网络爬虫利用网页中的超链接遍历互联网,通过URL引用从一个HTML文档爬行到另一个HTML文档。http://dmoz.org可以作为整个互联网抓取的入口。网络爬虫收集到的信息可有多种用途,如建立索引、HTML文件的验证、URL链接验证、获取更新信息、站点镜象等。为了检查网页内容是否更新过,网络爬虫建立的页面数据库往往包含有根据页面内容生成的文摘。 在抓取网页时大部分网络爬虫会遵循Robot.txt协议。网站本身可以有两种方式声明不想被搜索引擎收入的内容:第一种方式是在站点的根目录增加一个纯文本文件http://www.yourdomain.com/robots.txt;另外一种方式是直接在HTML页面中使用robots的meta标签。 1.3.2 全文索引结构与Lucene实现 查找文档最原始的方式是通过文档编号找。就像一个人生下来就有一个身份证号,一个文档从创建开始就有一个文档编号。 早在计算机出现之前,为了方便查询,已经出现了人工为图书建立的索引,比如图1-4中的名词索引: - - 遍历搜索引擎技术 图1-4 人工建立的名词索引 为了按词快速定位抓取过来的文档,需要以词为基础建立全文索引,也叫倒排索引(Inverted index),如图1-5所示。在这里,索引中的文档用编号表示。 - - 遍历搜索引擎技术 1 2 4 3 5 词: 文档: 北京 武汉 天津 上海 大连 图1-5 以词为基础的全文索引 倒排索引是相对于正向索引来说的,首先用正向索引来存储每个文档对应的单词列表,然后再建立倒排索引,根据单词来索引文档编号。 例如要索引如下两个文档: Doc Id 1: 自己动手写搜索引擎 Doc Id 2: 自己动手写网络爬虫 首先把这些文档中的内容分成一个个的词: Doc Id 1: 自己/动手/写/搜索引擎 Doc Id 2: 自己/动手/写/网络爬虫 按单词建立的倒排索引结构如表1-1所示: 词 ( 文档, 频率 ) 在文档中出现的位置 动手 ( 1, 1 ),( 2, 1 ) ( 2 ) ,( 2 ) 搜索引擎 ( 1, 1 ) ( 4 ) 网络爬虫 ( 2, 1 ) ( 4 ) - - 遍历搜索引擎技术 词 ( 文档, 频率 ) 在文档中出现的位置 写 ( 1, 1 ) ,( 2, 1 ) ( 3 ) ,( 3 ) 自己 ( 1, 1 ),( 2, 1 ) ( 1 ),( 1 ) 表1-1倒排索引结构 每个单词(term)后面的文档编号(docId)列表叫做posting list。在Lucene中,倒排索引结构存储在二进制格式的多个索引文件中,其中以tis为后缀的文件中包含了单词信息,frq后缀的文件记录单词的文档编号和这个单词在文档中出现了多少次,也就是频率信息,prx后缀的文件包含了单词出现的位置信息。 为了快速的查找单词,可以先对单词列表排序,例如:《新华字典》和《现代汉语词典》按拼音排序。从排好序的词表中查找一个词可以采用折半查找的方法快速查询。下面是实现折半查找的代码。 int low = fromIndex; //开始位置 int high = toIndex - 1; //结束位置 while (low <= high) { int mid = (low + high) >>> 1; //相当于mid = (low + high)/2 Comparable midVal = (Comparable)a[mid]; //取中间的值 int cmp = midVal.compareTo(key); //中间值和要找的关键字比较 if (cmp < 0) low = mid + 1; else if (cmp > 0) high = mid - 1; else return mid; // 查找成功,返回找到的位置 } return -(low + 1); // 没找到,返回负值 在Lucene中,org.apache.lucene.index.TermInfosReader类的getIndexOffset方法实现了一个类似的折半查找。对于特别大的顺序集合可以用插值法查找提高查找速度。 Lucene(http://lucene.apache.org/)是一个开放源代码的全文索引库。经过10多年的发展,Lucene拥有了大量的用户和活跃的开发团队。Eclipse软件和Twitter网站等都在使用Lucene。如果说Google是拥有最多用户访问的搜索引擎网站,那么拥有最多开发人员支持的搜索软件项目也许是Lucene。 - - 遍历搜索引擎技术 Lucene的整体结构如图1-6所示。 search() addDocument() Lucene索引库 IndexWriter Document: url:http://www.lietu.com title:猎兔搜索 body:内容介绍 IndexSearcher Query title:搜索 ScoreDoc[] 匹配到的文档 图1-6 Lucene原理图 Lucene中的基本概念介绍如下: l 第一个概念是Index,也就是索引库。文档的集合组成索引。和一般的数据库不一样,Lucene不支持定义主键。在Lucene中并不存在一个叫做Index的类。通过IndexWriter来写索引,通过IndexReader读索引。索引库在物理形式上一般是位于一个路径下的一系列文件。 l 一段有意义的文字需要通过Analyzer分割成一个个词语后才能按关键词搜索。Analyzer就是分析器,StandardAnalyzer是Lucene中最常用的分析器。为了达到更好的搜索效果,不同的语言可以使用不同的分析器,例如CnAnalyzer就是一个主要处理中文的分析器。 l Analyzer返回的结果就是一串Token。Token包含一个代表词本身含义的字符串和该词在文章中相应的起止偏移位置,Token还包含一个用来存储词类型的字符串。 l 一个Document代表索引库中的一条记录,也叫做文档。要搜索的信息封装成Document后通过IndexWriter写入索引库。调用Searcher接口按关键词搜索后,返回的也是一个封装后的Document的列表。 l 一个Document可以包含多个列,叫做Field。例如一篇文章可以包含“标题”、“正文”、“修改时间”等field。创建这些列对象以后,可以通过Document的add方法增加这些列。和一般的数据库不一样,一个文档的一个列可以有多个值。例如一篇文档既可以属于互联网类,又可以属于科技类。 - - 遍历搜索引擎技术 l Term是搜索语法的最小单位,复杂的搜索语法会分解成一个一个的Term查询。它表示文档的一个词语,Term由两部分组成:它表示的词语和这个词语所出现的Field。 Lucene中的API相对数据库来说比较灵活,没有类似数据库先定义表结构后使用的过程。如果前后两次写索引时定义的列名称不一样,Lucene会自动创建新的列,所以Field的一致性需要我们自己掌握。 1.3.3 搜索用户界面 随着搜索引擎技术逐渐走向成熟,搜索用户界面也形成了一些比较固定的模式。 l 输入提示词:在用户在搜索框中输入查询词的过程中随时给予查询提示词。对中文来说,当用户输入拼音时,也能提示。 l 相关搜索提示词:当用户对当前搜索结果不满意时,也许换一个搜索词就能够得到更有用的信息。一般会根据用户当前搜索词给出多个相关的提示词。可以看成是协同过滤在搜索词上的一种具体应用。 l 相关文档:返回和搜索结果中的某一个文档相似的文档。例如:Google搜索结果中的“类似结果”。 l 在结果中查询:如果返回结果很多,则用户在返回结果中再次输入查询词以缩小查询范围。 l 分类统计:返回搜索结果在类别中的分布图。用户可以按类别缩小搜索范围,或者在搜索结果中导航。有点类似数据仓库中的向下钻取和向上钻取。 l 搜索热词统计界面:往往按用户类别统计搜索词,例如按用户所属区域或者按用户所属部门等,当然也可以直接按用户统计搜索热词。例如Google的Trends。 搜索界面的改进都是以用户体验为导向。所以搜索用户界面往往还根据具体应用场景优化。所有这一切都是为了和用户的交互达到最大的效果。 1.3.4 计算框架 互联网搜索经常面临海量数据。需要分布式的计算框架来执行对网页重要度打分等计算。有的计算数据很少,但是计算量很大,还有些计算数据量比较大,但是计算量相对比较小。例如计算圆周率是计算密集型,互联网搜索中的计算往往是数据密集型。所以出现了数据密集型的云计算框架。MapReduce是一种常用的云计算框架。 MapReduce把计算任务分成两个阶段。映射(Map)阶段按数据分类完成基本计算,化简(Reduce)阶段收集基本的计算结果。使用MapReduce统计词频的例子如图1-8所示。 - - 遍历搜索引擎技术 输入 分割 映射 混洗 化简 最终结果 Dear Bear River Car Car River Deer Car Bear Dear Bear River Car Car River Deer Car Bear Dear, 1 Bear, 1 River, 1 Bear, 1 Bear, 1 Bear, 2 Bear, 2 Car, 3 Deer, 2 River, 2 Car, 1 Car, 1 River, 1 Deer, 1 Car, 1 Bear, 1 Car, 1 Car, 1 Car, 1 Deer, 1 Deer, 1 Car, 3 River, 1 River, 1 Deer, 2 River, 2 图1-8词频统计的例子 Hadoop(http://hadoop.apache.org/)是MapReduce思想实现的一个开源计算平台,已经在包括百度等搜索引擎开发公司得到商用。使用Hadoop实现词频统计的TokenCounterMapper类如下: public class TokenCounterMapper extends Mapper{ private final static IntWritable one = new IntWritable(1); private Text word = new Text(); public void map(Object key, Text value, Context context) throws IOException { StringTokenizer itr = new StringTokenizer(value.toString()); while (itr.hasMoreTokens()) { word.set(itr.nextToken()); context.collect(word, one); } } } 但是MapReduce是批处理的操作方式。一般来说,直到完成上一阶段的操作后才能启动下一阶段的操作。 要有一种计算,可以尽快出结果,随着时间的延长,计算结果会越来越好。很多计算可以用 - - 遍历搜索引擎技术 迭代的方式做,迭代次数越多,结果往往越好 比如PageRank或者KMeans、EM算法。当然,这个应该不只需要迭代,还需要向最优解收敛。 1.3.5 文本挖掘 搜索文本信息需要理解人类的自然语言。文本挖掘指从大量文本数据中抽取隐含的,未知的,可能有用的信息。 常用的文本挖掘方法包括:全文检索、中文分词、句法分析、文本分类、文本聚类、关键词提取、文本摘要、信息提取、智能问答等。文本挖掘相关技术的结构如图1-9所示: 文本分析 文档集 特征提取 检索 分类 聚类 自动问答 中文分词 识别数字 识别日期 识别名字 词性标注 句法分析 特征选择 关键词提取 文本摘要 图1-9文本挖掘的结构 1.4 本章小结 牛顿用简单的原理来近似自然界中的复杂现象,搜索引擎开发也是一门复杂的技术,但是可以从简单的折半查找开始理解。本章介绍了互联网搜索Google及其创新原则。在Google出现之前,Yahoo使用人工对网站分类,提供按目录导航和搜索目录数据库功能。在Google尚未占据互联网搜索绝对优势之前,也是在笔者第一次听人推荐Google之前,就出现了元搜索引擎(Meta Search Engine)。用户只需提交一次搜索请求,由元搜索引擎负责转换处理后提交给多个预先选定的独立搜索引擎,并将从各独立搜索引擎返回的所有查询结果,集中起来处理后再返回给用户。但Google开始独家垄断全球互联网搜索后,元搜索引擎逐渐被人遗忘。 Google早期的时候使用MapReduce实现分布式索引。后来之所以放弃这种方式,是因为它并不能为Google提供它所想要的索引速度。工程师需要等待8个小时的计算时间才能够得到计算的全部结果,然后把它发布到索引系统中去。随着实时检索时代的到来,Google需要在几秒内刷新索引内容,而非8小时。 Hadoop来源于开源的分布式搜索项目Nutch。Powerset公司在Hadoop的基础上开发了 - - 遍历搜索引擎技术 基于BigTable架构的数据库Hbase(http://hbase.apache.org/)。2008年,微软收购了Powerset。 与文本挖掘技术对应的是包括语音识别、基于内容的图像检索等技术的流媒体挖掘技术。随着网络电视和视频网站的流行,流媒体挖掘技术正越来越引起人们的关注。 除了像Google的网页搜索这样的常规搜索引擎,还有些特殊的搜索引擎。搜索的输入不一定是简单的关键词,例如Wolfram|Alpha(http://www.wolframalpha.com/)是一个特殊的可计算的知识引擎。它可以根据用户的问句式的输入精确地返回一个答案。TextRunner搜索(http://www.cs.washington.edu/research/textrunner/)是另外一个问答式的搜索。搜索引擎不一定只是简单列出搜索结果,Vivisimo(http://vivisimo.com/)是实现聚类的搜索,也可以分类统计搜索结果。搜索结果不一定是文档,例如Aardvark(http://vark.com/)是一个社会化搜索,它可以自动选择合适的人来回答用户提出的问题。 - - 获得海量数据 第2章 网络爬虫的原理与应用 大的搜索引擎,例如Google,对整个互联网做了一个镜像。很多有专门用途的信息也需要汇总,例如网上购物或者旅游。这些专门收集互联网中的信息的程序叫做网络爬虫。如果把互联网比喻成一个覆盖地球的蜘蛛网,那么抓取程序就是在网上爬来爬去的蜘蛛。 虽然存在一些通用的采集器,但是因为应用目的不同,很多爬虫程序都是定制开发的。网络爬虫需要实现的基本功能包括下载网页以及对URL地址的遍历。为了高效的快速遍历大量的URL地址,还需要应用专门的数据结构来优化。爬虫很消耗带宽资源,设计爬虫时需要仔细的考虑如何节省网络带宽。 2.1 爬虫的基本原理 如果把网页看成节点,网页之间的超链接看成边,则可以把整个互联网看成是一个巨大的非连通图。网络爬虫有两个基本的任务:发现包含有效信息的URL和下载网页。 为了获取网页,需要有一个初始的URL地址列表。然后通过网页中的超链接访问到其他的页面。有人可能会奇怪像Google或百度这样的搜索门户怎么设置这个初始的URL地址列表。一般来说,网站拥有者把网站提交给分类目录,例如dmoz(http://www.dmoz.org/),爬虫则可以从开放式分类目录dmoz抓取。 抓取下来的网页中包含了想要的信息,一般存放在数据库或索引库这样的专门的存储系统中,如图2-1所示。如果把网页原文存储下来,就可以实现“网页快照”功能。 互联网 请求网页 解析网页 存储系统 新解析出的URL 初始URL地址列表 图2-1 爬虫基本结构图 爬虫程序的工作是从一个种子链接的集合开始。把种子URL集合作为参数传递给网络爬虫。 - - 获得海量数据 爬虫先把这些初始的URL放入URL工作队列(Todo队列,又叫做Frontier),然后遍历所有工作队列中的URL,下载网页并把其中新发现的URL再次放入工作队列。为了判断一个URL是否已经遍历过,把所有遍历过的URL放入历史表(Visited表)。爬虫抓取的基本过程如图2-1所示: 新解析出的URL Todo队列 Visited集合 初始URL地址 解析URL 图2-2 网页遍历流程图 抓取的主要流程如下: while (todo.size() > 0) { //如果Todo队列不是空的 //从Todo队列里面提取URL String strUrl = todo.iterator().next(); //下载URL对应的网页内容 String content = downloadPageContent(strUrl); //把网页内容存储到本地 //提取网页内容中新发现的URL链接 HashSet newLinks = retrieveLinks(content, new URL(strUrl)); //把新发现的链接加入Todo队列 todo.addAll(newLinks); //从Todo队列里删除已经爬过的URL todo.remove(strUrl); //把从Todo队列里删除的URL添加到Visited集合中 visited.add(strUrl); } 整个互联网是一个大的图,其中,每个URL相当于图的一个节点,因此,网页遍历就可以采用图遍历的算法进行。通常,网络爬虫的遍历策略有三种,广度优先遍历,深度优先遍历和最佳优先遍历。其中,深度优先遍历由于极有可能使爬虫陷入黑洞,因此,广度优先遍历和最佳优先遍历就成为了常用的爬虫策略。垂直搜索往往采用定制的遍历方法抓取特定的网站。 - - 获得海量数据 2.1.1 广度优先遍历 广度优先是指网络爬虫会先抓取起始网页中链接的所有网页,然后再选择其中的一个链接网页,继续抓取在此网页中链接的所有网页。这是最常用的方式,这个方法也可以让网络爬虫并行处理,提高其抓取速度。以图2-2中的图为例说明广度遍历的过程。 G B A C D F E I H 图2-2 网络爬虫遍历的图 例如在图2-2中,A为种子节点,则首先遍历A(第一层),接着是BCDEF(第二层),接着遍历GH(第三层),最后遍历I(第四层)。蜘蛛从蛛网中心往外,一圈一圈的织网,可以类比作广度遍历。 广度优先遍历使用一个队列来实现Todo表,先访问的网页先扩展。针对上图,广度优先遍历的执行过程如下: Todo队列 Visited集合 a null b c d e f a c d e f a b d e f a b c e f a b c d f h a b c d e h g a b c d e f g i a b c d e f h - - 获得海量数据 Todo队列 Visited集合 i a b c d e f h g null a b c d e f h g i 表2-1 广度优先遍历过程表 庄子曾说:“吾生也有涯,而知也无涯,以有涯随无涯,殆已”。在学习和工作的时候,需要要分辨事情的轻重缓急,否则一味蛮干,最终结果只能是--“殆已”。对于浩瀚无边的互联网而言,网络爬虫涉及到页面确实只是冰山一角。因此,需要以最小的代价(硬件、带宽)获取到最大的利益(数量最多的重要的网页)。 为了先抓取重要的网页,可以采用最佳优先。最佳优先爬虫策略也称为“页面选择问题”(PageSelection),通常,这样保证在有限带宽条件下,尽可能的照顾到重要性高的网页。 如何实现最佳优先爬虫呢,最简单的方式可以使用优先级队列(PriorityQueue)来实现TODO表,这样,每次选出来扩展的URL就是具有最高重要性的网页。在队列中,先进入的元素先出,但是在优先队列中,优先级高的元素先出队列。 比如,假设上图的节点重要性D>B>C>A>E>F>I>G>H,则整个遍历过程如表2-2所示: Todo优先级队 Visited集合 A null B,C,D,E,F A B,C,E,F A,D C,E,F A,B,D E,F A,B,C,D F,H A,B,C,D,E H,G A,B,C,D,E,F H A,B,C,D,E,F,G I A,B,C,D,E,F,H,G null A,B,C,D,E,F,H,I 表2-2 最佳优先遍历过程表 - - 获得海量数据 2.1.2 最好优先遍历 下面实现一个Best-First的爬虫,也就是说每次取的URL地址都是Todo列表中的最好的URL地址。为了实现快速的查找和操作,一般使用Berkeley DB 来实现TodoList。 Berkeley DB中存储的一般是一个关键字和值的Map。为了同时实现按照URL地址查找和按照url地址分值排序,简单的map不能满足要求,这时候可以使用SecondaryIndex和PrimaryIndex来达到多列索引的目的。实现持久化的基本类如下: @Entity public class NewsSource { @PrimaryKey public String URL; public String source; public int level; public int rank; public String urlDesc = null; @SecondaryKey(relate=Relationship.MANY_TO_ONE) public int score; } Todo列表的构造方法如下: public ToDoTaskList(Environment env) throws Exception { StoreConfig storeConfig = new StoreConfig(); storeConfig.setAllowCreate(true); storeConfig.setTransactional(false); store = new EntityStore(env, "classDb", storeConfig); newsByURL = store.getPrimaryIndex(String.class, NewsSource.class); secondaryIndex = store.getSecondaryIndex(this.newsByURL, Integer.class, "score"); } 从Todo列表取得最大分值的URL地址的方法如下: public NewsSource removeBest() throws DatabaseException { Integer score = secondaryIndex.sortedMap().lastKey(); if (score != null){ EntityIndex urlLists = secondaryIndex.subIndex(score); EntityCursor ec = urlLists.keys(); String url = ec.first(); ec.close(); NewsSource source = urlLists.get(url); - - 获得海量数据 urlLists.delete(url); return source; } return null; } 2.1.3 遍历特定网站 从首页提取类别信息,然后按类别信息找到目录页。通过翻页遍历所有的目录页,提取详细页。从详细页面提取商品信息。把商品信息存入数据库。 以新浪新闻为例,同一个目录下的URL是: http://roll.news.sina.com.cn/news/gjxw/hqqw/index.shtml http://roll.news.sina.com.cn/news/gjxw/hqqw/index_2.shtml http://roll.news.sina.com.cn/news/gjxw/hqqw/index_3.shtml 以购物网站为例: http://www.mcmelectronics.com/browse/Cameras/0000000002 http://www.mcmelectronics.com/browse/Cameras/0000000002/p/2 http://www.mcmelectronics.com/browse/Cameras/0000000002/p/3 http://www.mcmelectronics.com/browse/Cameras/0000000002/p/4 不断增加翻页参数,一直到找不到新的商品为止。 boolean parserIndexPage(String indexUrl){ //解析目录页中的商品,看看是否有新的商品 return isNew; } 2.2 爬虫架构 本节首先介绍爬虫的基本架构,然后介绍可以在多台服务器上运行的分布式爬虫架构。 - - 获得海量数据 2.2.1 基本架构 一般的爬虫软件,通常都包含以下几个模块: 1、保存种子URL和待抓取的URL的数据结构。 2、保存已经抓取过的URL的数据结构,防止重复抓取。 3、页面获取模块。 4、对已经获取的页面内容的各个部分进行抽取的模块。例如抽取HTML、JavaScript等。 其他可选的模块包括: 5、负责连接前处理模块。 6、负责连接后处理模块。 7、过滤器模块。 8、负责多线程的模块。 9、负责分布式的模块。 各模块详细介绍如下: 1、保存种子和爬取出来的URL的数据结构 农民会把有生长潜力的籽用做种子,这里把一些活跃的网页用做种子URL,例如网站的首页或者列表页,因为在这些页面经常会发现新的链接。通常,爬虫都是从一系列的种子URL开始爬取,一般从数据库表或者配置文件中读取这些种子URL。种子URL描述表如下: 字段名 字段类型 说明 Id NUMBER(12) 唯一标识 url Varchar(128) 网站url source Varchar(128) 网站来源描述 rank NUMBER(12) 网站PageRank值 表2-3 最佳优先遍历过程表 - - 获得海量数据 但是保存待抓取的URL的数据结构确因系统的规模、功能不同而可能采用不同的策略。一个比较小的示例爬虫程序,可能就使用内存中的一个队列,或者是优先级队列进行存储。一个中等规模的爬虫程序,可能使用BekerlyDB这种内存数据库来存储,如果内存中存放不下的话,还可以序列化到磁盘上。但是,真正的大规模爬虫系统,是通过服务器集群来存储已经爬取出来的URL的。并且,还会在存储URL的表中附带一些其他信息,比如说PageRank值等,供之后的计算用。 2、保存已经抓取过的URL的数据结构 已经抓取过的URL的规模和待抓取的URL的规模是一个相当的量级。正如我们前面介绍的TODO表和Visited表。但是,他们唯一的不同是,Visited表会经常被查询,以便确定发现的URL是否已经处理过。因此,Visited表数据结构如果是一个内存数据结构的话,可以采用Hash(HashMap或者HashSet)来存储,如果保存在数据库中的话,可以对URL列建立索引。 3、页面获取模块 当从种子URL队列或者抓取出来的URL队列中获得URL后,便要根据这个URL来获得当前页面的内容,获得的方法非常简单,就是普通的IO操作。在这个模块中,仅仅是把URL所指的内容按照二进制的格式读出来,而不对内容做任何处理。 4、提取已经获取的网页的内容中的有效信息 从页面获取模块的结果是一个表示HTML源代码的字符串。从这个字符串中抽取各种相关的内容,是爬虫软件的目的。因此,这个模块就显得非常重要。 通常,在一个网页中,除了包含有文本内容还有图片,超链接等。对于文本内容,首先把HTML源代码的字符串保存成HTML文件即可。关于超链接提取,可以根据HTML语法,使用正则表达式来提取,并且把提取的超链接加入到TODO表中。也可以使用专门的HTML文档解析工具。 在网页中,超连接不光指向HTML页面,还会指向各种文件,对于除了HTML页面的超连接之外,其他内容的链接不能放入TODO表中,而要直接下载。因此,在这个模块中,还必须包含提取图片,JavaScript,PDF,DOC等等内容的部分。并且,在提取过程中,还要针对HTTP协议处理返回的状态码。这章我们主要研究网页的架构问题,将在下一章详细研究从各种文件格式提取有效信息。 5、负责连接前处理模块,负责连接后处理模块,过滤器模块 如果只抓取某个网站的网页,则可以对URL按域名过滤。 6、多线程模块 爬虫主要消耗三种资源:网络带宽,中央处理器和磁盘。三者中任何一者都有可能成为瓶颈 - - 获得海量数据 ,其中网络带宽一般是租用,所以价格相对昂贵。为了增加爬虫效率,最直接的方法就是使用多线程的方式进行处理。在爬虫系统中,将要处理的URL队列往往是唯一的,多个线程顺序的从队列中取得URL,之后各自进行处理(处理阶段是并发进行)。通常,可以利用线程池来管理线程。程序中最大可以使用的线程数是可配置的。 7、分布式处理 分布式是当今计算的主流。这项技术也可以同时用在网络爬虫上面。下节介绍多台机器并行采集的方法。 2.2.2 分布式爬虫架构 把抓取任务分布到不同的节点分布主要是为了可扩展性,也可以使用物理分布的爬虫系统,让每个爬虫节点抓取靠近它的网站。例如,北京的爬虫节点抓取北京的网站,上海的爬虫节点抓取上海的网站。还比如,电信网络中的爬虫节点抓取托管在电信的网站,联通网络中的爬虫节点抓取托管在联通的网站。 图2-3是一种没有中央服务器的分布式爬虫结构。 Web 下载 网页 DNS 解析 页面 内容是否重复? URL Frontier URL 过滤 按域名 分割 URL 去重 文档 语义指纹 URL 集 发送到 其他 节点 来源于 其他 节点 图2-3 分布式爬虫结构图 要点在于按域名分配采集任务。每台机器扫描到的网址,不属于它自己的会交换给属于它的机器。例如,专门有一台机器抓取s开头的网站:http://www.sina.com.cn 和 http://www.sohu.com,而另外一台机器抓取q开头的网站:http://www.qq.com。 - - 获得海量数据 垂直信息分布式抓取的基本设计: 1. 要处理的信息根据首字母散列到26个不同的爬虫服务器,让不同的机器抓取不同的信息。 2. 每台机器通过配置文件读取自己要处理的字母。每台机器抓取完一条结果后把该结果写入到统一的一个数据库中。比如说有26台机器,第一台机器抓取字母a开头的公司,第二台机器抓取字母b开头的公司,依次类推。 3. 如果某一台机器抓取速度太慢,则把该任务拆分到其它的机器。 可以把检测重复内容的功能专门交给一个集群处理。解析页面的功能和下载网页的功能可以直接在同一台机器处理。 2.2.3 垂直爬虫架构 垂直爬虫往往抓取指定网站的新闻或论坛等信息。可以指定初始抓取的首页或者列表页,然后提取相关的详细页中的有效信息存入数据库,总体结构如图2-4所示。 新解析出的 详细页 解析 详细页 新解析出的 列表页 分析列表页 初始抓取的 列表页数据表 语义指纹 结果 数据表 集 图2-4 垂直爬虫结构图 垂直爬虫涉及的功能有: l 从首页提取不同栏目的列表页。 l 网页分类:把网页分类成列表页或详细页或者未知类型。 l 列表页链接提取:从列表页提取同一个栏目下的列表页,这些页面往往用“下一页”、“尾页”等信息描述。往往有个参数来控制翻页,把这个参数叫做翻页参数,例如:http://...&_pgn=2中的_pgn。有时候,列表页会显示总共有多少页,可以根据翻页参数列出所有的列表页。如果没有,还可以根据翻页参数一直往后翻页,直到找不到新信息或者出错为止。 - - 获得海量数据 l 详细页面内容提取:从详细页提取网页标题、主要内容、发布时间等信息。一般把每行数据封装到一个对象中。 可以每个网站用一个线程抓取,这样方便控制对被抓网站的访问频率。最好有通用的信息提取方式来解析网页,这样可以减少人工维护成本。同时,也可以采用专门的提取类来处理数据量大的网站,这样可以提高抓取效率。 解析详细页面成为结构化数据并存储结果的工作可以交给专门的机器去做。所以可以把下载的详细页面放入消息队列,然后再由解析详细页面的机器从消息队列中取出要分析的详细页面。 2.3 下载网络资源 做生意经常需要签协议,爬虫和网站打交道,把数据抓下来,也有一些相关的协议。首先介绍和网站打交道所使用的相关协议。然后用Java中现成的类实现最基本的下载网页功能。为了完成一些扩展功能,再介绍使用专门的开源项目下载网页。 2.3.1 下载网页的基本方法 Java语言中,java.net.URL类能够对实际的URL进行建模,通过这个类,可以对相应的Web服务器发出请求并且获得相应的文档。Java.net.URL类有一个默认的构造函数,使用URL地址作为参数,构造URL对象。 URL pageURL = new URL(path); 之后,可以通过获得的URL对象来取得输入流,进而像读入本地文件一样来下载网页。 InputStream stream = pageURL.openStream(); 然后可以将网页看做网络文件,然后按照文件读取的方式把它读出来,保存到本地。以下是一个下载网页的小程序,简单的说明网页下载的原理。 public class RetrivePage { public static String downloadPage(String path){ //根据传入的路径构造URL URL pageURL = new URL(path); //创建网络流 BufferedReader reader = new BufferedReader(new InputStreamReader( pageURL.openStream())); String line; //读取网页内容 - - 获得海量数据 StringBuilder pageBuffer = new StringBuilder(); while ((line = reader.readLine()) != null) { pageBuffer.append(line); } //返回网页内容 return pageBuffer.toString(); } /** * 测试代码 */ public static void main(String[] args) { //抓取lietu首页然后输出 System.out.println(RetrivePage.downloadPage("http://www.lietu.com")); } } 代码中的reader.readLine();有可能会抛出异常,因为网速可能不稳定,如果下载网页的过程中出现错误,还需要重试。如果重试仍然出错,下载线程可以调用sleep()方法休息片刻等待网速稳定。 用Scanner对象下载网页的例子: Scanner scanner = new Scanner(new InputStreamReader( pageURL.openStream(),"utf-8"));//指定编码格式utf-8 scanner.useDelimiter("\\z"); //可以用正则表达式分段读取网页 //读取网页内容 StringBuilder pageBuffer = new StringBuilder(); while (scanner.hasNext()){ pageBuffer.append( scanner.next() ); } 很多网页的编码格式是utf-8,如果读入有乱码,则可以填入FireFox识别出的编码。下一章会专门介绍如何自动识别网页的编码。 网络中的数据是通过TCP/IP协议来传输的。一般使用套接字来对TCP/IP协议编程。URL对象的openStream方法使用了HTTP的GET命令返回Web页的正文内容。下面是一个直接使用套接字(socket)向Web服务器发送GET命令并输出返回结果的例子: String host = "www.lietu.com"; //主机名 String file = "/index.jsp"; //网页路径 int port = 80; //端口号,默认是80 - - 获得海量数据 s = new Socket(host, port); OutputStream out = s.getOutputStream(); PrintWriter outw = new PrintWriter(out, false); outw.print("GET " + file + " HTTP/1.0\r\n"); outw.print("Accept: text/plain, text/html, text/*\r\n"); outw.print("\r\n"); outw.flush();//发送GET命令 InputStream in = s.getInputStream(); // InputStreamReader的构造方法的第二个参数可以指定下载网页的编码格式 InputStreamReader inr = new InputStreamReader(in); BufferedReader br = new BufferedReader(inr); String line; while ((line = br.readLine()) != null){ System.out.println(line); //输出返回的网页 } Web服务器返回的结果除了网页内容,在此之前还包括头域(header field)信息,例如: HTTP/1.1 200 OK Server: Apache-Coyote/1.1 Content-Type: text/html;charset=UTF-8 Content-Length: 5367 Date: Tue, 29 Jun 2010 00:32:12 GMT Connection: close 网页的编码方式由Content-Encoding或Content-Type定义,它的长度由Content-Length或Content-Range定义。 HttpURLConnection类把头信息封装成了HashMap的形式,例如:key是“Content-Length”,而value是“5367”。表2-4介绍了HttpURLConnection中的方法。 表2-4 HttpURLConnection中的方法 方法 描述 getHeaderField 取得头域信息 getContentType 取得网页类型 - - 获得海量数据 getLastModified 取得网页修改时间,如果没有则返回0 getContentEncoding 取得编码类型 getContentLength 取得网页长度 getContentType等方法的错误之处在于只识别头信息中小写的字符串,需要修改成大小写不敏感。 public String getHeaderField(String fieldKey) throws IOException { URLConnection con = url.openConnection(); header = con.getHeaderFields(); //返回一个HashMap Iterator i = getHeaderFields().keySet().iterator(); String key = null; while (i.hasNext()) { key = (String) i.next(); if (key == null) { if (fieldKey == null) { return (String) ((List) (getHeaderFields().get(null))).get(0); } } else { if (key.equalsIgnoreCase(fieldKey)) { return (String) ((List) (getHeaderFields().get(key))).get(0); } } } return null; } 2.3.2 HTTP协议 网络资源一般是Web服务器上的一些各种格式的文件。通过HTTP协议和Web服务器打交道,所以Web服务器又叫做HTTP服务器。HTTP服务器存储了互联网上的数据并且根据HTTP客户端的请求提供数据。网络爬虫也是一种HTTP客户端。 在Java中,一旦和被采集的Web服务器建立网络连接,对网络资源的操作就好像对本地文件的操作一样简单。通常,网络中使用URL(统一资源定位符)来标示网络资源的位置。可以直接通过Java中的URL类和存放网页的服务器建立连接,并且获得网页源代码。 - - 获得海量数据 Java中的URL类代表一个统一的资源定位符,资源可以是简单的文件或目录,也可以是对更为复杂的对象的引用。例如,以下是一个URL的例子。 http://www.lietu.com/index.jsp 通常,URL 可分成几个部分。上面的 URL 示例指示使用的协议为超文本传输协议(HTTP)并且该信息驻留在一台名为www.lietu.com 的主机上,可以调用URL类的getHost()方法取得URL地址的主机名。主机上的信息名称为 /index.jsp。主机上此名称的准确含义取决于协议和主机。该信息一般存储在文件中,但可以随时生成。该 URL 的这一部分称为路径部分,可以调用getPath()方法取得路径部分。 需要通过域名服务(Domain Name Service简称为DNS)取得域名对应的IP地址。爬虫程序首先连接到一个DNS服务器上,由DNS服务器返回域名对应的IP地址。使用不适当的DNS服务器可能导致无法解析有些域名。在Linux下DNS解析的问题可以用dig或nslookup命令来分析,例如: #dig www.lietu.com #nslookup www.lietu.com 如果需要更换更好的DNS域名解析服务器,可以编辑DNS配置文件“/etc/resolv.conf”。DNS解析是一个网络爬虫性能瓶颈。由于域名服务的分布式特点,DNS可能需要多次请求转发,并在互联网上往返,需要几秒有时甚至更长的时间解析出IP地址。一个补救措施是引入DNS缓存,这样最近完成DNS查询的网址可能会在DNS缓存中找到,避免了访问互联网上的DNS服务器。JDK1.6内部有个30秒的DNS缓存。通过sun.net.InetAddressCachePolicy.get()方法可以查看缓存时间设置。Java中要查找一个域名的IP地址最方便的办法就是调用java.net.InetAddress.getByName("www.lietu.com")。 URL 可选择指定一个“端口”,它是用于建立到远程主机 TCP 连接的端口号。如果未指定该端口号,则使用协议默认的端口。HTTP协议的默认端口号是一个很吉利的数字:80。可以在URL地址中显式的指定端口号,如下所示: http://www.lietu.com:80/index.jsp 为了深入的了解下载网页的原理,需要了解HTTP协议。HTTP是一个客户端和服务器端请求和应答的标准。HTTP的客户端往往是Web浏览器,但是在这里是网络爬虫。客户端也叫做用户代理(user agent)。服务器端是网站,例如Tomcat。客户端发起一个到服务器上指定端口(默认端口为80)的HTTP请求,服务器端按指定格式返回网页或者其他网络资源。 - - 获得海量数据 HTTP响应 好的,它是HTML格式的,有5367个字符长 www.lietu.com HTTP请求 给我名字叫作/index.jsp的文档 网络爬虫 HTTP服务器 图2-5 HTTP协议的原理 URI包括URL和URN。但是URN并不常用,很少有人知道URN。URL由3部分组成,如下图所示: http 协议名 www.lietu.com 主机名 /index.jsp 资源路径 http://www.lietu.com /index.jsp 图2-6 URL分为三部分 客户端向服务器发送的请求头包含请求的方法、URL、协议版本、以及包含请求修饰符、客户信息和内容。服务器以一个状态行作为响应,相应的内容包括消息协议的版本,成功或者错误编码加上包含服务器信息、实体元信息以及可能的实体内容。 HTTP请求格式是: [] 在HTTP请求中,第一行必须是一个请求行(request line),用来说明请求类型、要访问的资源以及使用的HTTP版本。紧接着是头信息(header),用来说明服务器要使用的附加信息。在头信息之后是一个空行,再此之后可以添加任意的其他数据,这些附加的数据称之为主体(body)。 HTTP规范定义了8种可能的请求方法。爬虫经常用到GET、HEAD和POST三种,分别说明如下: - - 获得海量数据 l GET:检索URI中标识资源的一个简单请求。例如爬虫发送请求:GET /index.html HTTP/1.1 l HEAD:与GET方法相同,服务器只返回状态行和头标,并不返回请求文档。例如用HEAD请求检查网页更新时间。 l POST:服务器接受被写入客户端输出流中的数据的请求。可以用POST方法来提交表单参数。 例如请求头: Accept: text/plain, text/html 客户端说明了可以接收文本类型的信息,最好不要发送音频格式的数据。 Referer: http://www.w3.org/hypertext/DataSources/Overview.html 代表从这个网页知道正在请求的网页。 Accept-Charset: GB2312,utf-8;q=0.7 每个语言后包括一个q-value。表示用户对这种语言的偏好估计。缺省值是1.0,这个也是最大值。 Keep-alive: 115 Connection: keep-alive Keep-alive是指在同一个连接中发出和接收多次HTTP请求,单位是毫秒。 介绍完客户端向服务器的请求消息后,然后再了解服务器向客户端返回的响应消息。这种类型的消息也是由一个起始行,一个或者多个头信息,一个指示头信息结束的空行和可选的消息体组成。 HTTP的头信息包括通用头,请求头,响应头和实体头四个部分。每个头信息由一个域名,冒号(:)和域值三部分组成。域名是大小写无关的,域值前可以添加任何数量的空格符,头信息可以被扩展为多行,在每行开始处,使用至少一个空格或制表符。 例如,爬虫程序发出GET请求: GET /index.html HTTP/1.1 服务器返回响应: HTTP /1.1 200 OK Date: Apr 11 2006 15:32:08 GMT Server: Apache/2.0.46(win32) Content-Length: 119 Content-Type: text/html - - 获得海量数据 因为HTTP使用TCP/IP,是基于文本的,和一些使用二进制格式的协议不同,直接和Web服务器交互很简单。 Telnet工具连接键盘输入到目的TCP端口,并且连接TCP端口输出返回到显示屏。Telnet本来是用于远程终端会话。但是可以用它连接到任何TCP服务器,包括HTTP服务器。 使用Telnet获取URL地址http://www.lietu.com:80/index.jsp所在的网页: # telnet www.lietu.com 80 Trying 211.147.214.145... Connected to www.lietu.com (211.147.214.145). Escape character is '^]'. GET /index.jsp HTTP/1.1 Host: www.lietu.com HTTP/1.1 200 OK Server: Apache-Coyote/1.1 Content-Type: text/html;charset=UTF-8 Content-Length: 5367 Date: Sat, 21 May 2011 09:59:09 GMT 在提交表单的时候,如果不指定方法,则默认为GET请求,表单中提交的数据将会附加在url之后,以?与url分开。字母数字字符原样发送,但空格转换为“+“号,其它符号转换为%XX,其中XX为该符号以16进制表示的ASCII值。GET请求请提交的数据放置在HTTP请求协议头中,而POST提交的数据则放在实体数据中。GET方式提交的数据最多只能有1024字节,而POST则没有此限制。 程序发出POST请求的例子: try { // 构造POST数据 String data = URLEncoder.encode("key1", "UTF-8") + - - 获得海量数据 "=" + URLEncoder.encode("value1", "UTF-8"); data += "&" + URLEncoder.encode("key2", "UTF-8") + "=" + URLEncoder.encode("value2", "UTF-8"); // 创建一个到Web服务器的套接字 String hostname = "hostname.com"; int port = 80; InetAddress addr = InetAddress.getByName(hostname); Socket socket = new Socket(addr, port); // 发送头信息 String path = "/servlet/SomeServlet"; BufferedWriter wr = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), "UTF8")); wr.write("POST "+path+" HTTP/1.0\r\n"); wr.write("Content-Length: "+data.length()+"\r\n"); wr.write("Content-Type: application/x-www-form-urlencoded\r\n"); wr.write("\r\n"); // 发送POST数据 wr.write(data); wr.flush(); // 取得响应 BufferedReader rd = new BufferedReader(new InputStreamReader(socket.getInputStream())); String line; while ((line = rd.readLine()) != null) { // 处理读入行… } wr.close(); rd.close(); } catch (Exception e) { } 例如,程序发出HEAD请求: HEAD /index.jsp HTTP/1.0 - - 获得海量数据 服务器返回响应: HTTP/1.1 200 OK Server: Apache-Coyote/1.1 Content-Type: text/html;charset=UTF-8 Content-Length: 5367 Date: Fri, 08 Apr 2011 11:08:24 GMT Connection: close 使用TCP/IP协议来传输数据。 2.3.3 使用HttpClient下载网页 在实际的项目中,网络环境比较复杂,因此,只用java.net包中的API来模拟浏览器客户端的工作,代码量非常大。例如,需要处理HTTP返回的状态码,设置HTTP代理,处理HTTPS协议,设置Cookie等工作。为了便于应用程序的开发,实际开发时常常使用开源项目HttpClient(http://hc.apache.org/httpcomponents-client-ga/)。HttpClient的JavaDoc API说明文档可以在httpcomponents-core-4.1-bin.zip找到。下载地址是http://hc.apache.org/downloads.cgi。 它完全能够处理HTTP连接中的各种问题,使用起来非常方便。只需在项目中引入HttpClient.jar包,就可以模拟浏览器来获取网页内容。例如: //创建一个客户端,类似于打开一个浏览器 DefaultHttpClient httpclient = new DefaultHttpClient(); //创建一个GET方法,类似于在浏览器地址栏中输入一个地址 HttpGet httpget = new HttpGet("http://www.lietu.com/"); //类似于在浏览器地址栏中输入回车,获得网页内容 HttpResponse response = httpclient.execute(httpget); //查看返回的内容,类似于在浏览器察看网页源代码 HttpEntity entity = response.getEntity(); if (entity != null) { //读入内容流,并以字符串形式返回,这里指定网页编码是UTF-8 System.out.println(EntityUtils.toString(entity,"utf-8")); EntityUtils.consume(entity);//关闭内容流 } - - 获得海量数据 //释放连接 httpclient.getConnectionManager().shutdown(); 在这个示例中,只是简单地把返回的内容打印出来,而在实际项目中,通常需要把返回的内容写入本地文件并保存。最后还要关闭网络连接,以免造成资源消耗。 这个例子是用GET方式来访问Web资源。通常,GET请求方式把需要传递给服务器的参数作为URL的一部分传递给服务器。但是,HTTP协议本身对URL字符串长度有所限制。因此不能传递过多的参数给服务器。为了避免这种问题,有些表单采用POST方法提交数据,HttpClient包对POST方法也有很好的支持。例如按城市抓取酒店信息。http://hotels.ctrip.com/Domestic/SearchHotel.aspx网页中包括如下源代码信息:
POST方法需要提交的三个参数包括:所在城市(cityId)、入住日期(checkIn)、离店日期(checkOut)。提交一个参数包括名字和值两项。NameValuePair是一个接口,而BasicNameValuePair则是这个接口的实现。使用BasicNameValuePair封装名字/值对。例如参数名cityId对应的值是1,可以这样写: new BasicNameValuePair("cityId", "1"); 模拟提交表单并返回结果的代码如下: HttpClient httpclient = new DefaultHttpClient(); HttpPost httppost = new HttpPost("http://hotels.ctrip.com/Domestic/ShowHotelList.aspx"); // POST数据 List nameValuePairs = new ArrayList(3);//3个参数 nameValuePairs.add(new BasicNameValuePair("checkIn", "2011-4-15")); //入住日期 nameValuePairs.add(new BasicNameValuePair("checkOut", "2011-4-25")); //离店日期 nameValuePairs.add(new BasicNameValuePair("cityId", "1")); //城市编码 httppost.setEntity(new UrlEncodedFormEntity(nameValuePairs)); // 执行HTTP POST请求 HttpResponse response = httpclient.execute(httppost); //取得内容流 InputStream is = response.getEntity().getContent(); BufferedInputStream bis = new BufferedInputStream(is); - - 获得海量数据 ByteArrayBuffer baf = new ByteArrayBuffer(20); //按字节读入内容流到字节数组缓存 int current = 0; while ((current = bis.read()) != -1) { baf.append((byte) current); } String text = new String(baf.toByteArray(), "gb2312"); // 指定编码 System.out.println(text); 上面的例子说明了如何使用POST方法来访问Web资源。与GET方法不同,POST方法可以提交二进制格式的数据,因此可以传递“无限”多的参数。而GET方法采用把参数写在URL里面的方式,由于URL有长度限制,因此传递参数的长度会有限制。 为了自动搜索Google,可以使用如下的GET请求: HttpGet httpget = new HttpGet ("http://www.google.com/search?hl=en&q=httpclient&btnG=Google+Search&aq=f&oq="); HttpClient提供了一系列实用的方法来简化创建和修改请求URI。URI可以组装编程: URI uri = URIUtils.createURI("http", "www.google.com", -1, "/search", "q=httpclient&btnG=Google+Search&aq=f&oq=", null); HttpGet httpget = new HttpGet(uri); //输出 http://www.google.com/search?q=httpclient&btnG=Google+Search&aq=f&oq= System.out.println(httpget.getURI()); 提交的参数可以分解成Key=Value的形式,叫做名字/值对。HttpClient使用专门的NameValuePair类来表示名字/值对。通过添加参数列表来生成Goole查询字符串的代码如下: List qparams = new ArrayList(); qparams.add(new BasicNameValuePair("q", "httpclient")); //查询词是httpclient qparams.add(new BasicNameValuePair("btnG", "Google Search")); //判断搜索用户是否是第一次查询,如果用户第一次进行查询,则aq=f qparams.add(new BasicNameValuePair("aq", "f")); qparams.add(new BasicNameValuePair("oq", null)); URI uri = URIUtils.createURI("http", "www.google.com", -1, "/search", URLEncodedUtils.format(qparams, "UTF-8"), null); HttpGet httpget = new HttpGet(uri); //输出 http://www.google.com/search?q=httpclient&btnG=Google+Search&aq=f&oq= - - 获得海量数据 System.out.println(httpget.getURI()); HttpClient是一个接口,用来代表基本的HTTP请求执行约定。而DefaultHttpClient是HttpClient接口的一个实现类。如果没有特别指定,DefaultHttpClient使用HTTP/1.1版本,设置如下的参数: l Version: HttpVersion.HTTP_1_1 l ContentCharset: HTTP.DEFAULT_CONTENT_CHARSET l NoTcpDelay: true l SocketBufferSize: 8192 l UserAgent: Apache-HttpClient/release (java 1.5) 此外还可以使用AbstractHttpClient定制HttpClient。可以用AbstractHttpClient代替DefaultHttpClient。 AbstractHttpClient httpImpl = new ContentEncodingHttpClient (); HttpGet httpget = new HttpGet(uri); httpget.addHeader ("User-Agent", "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)"); httpget.addHeader ("Accept-Encoding", "gzip,deflate"); ttpImpl.execute (httpget); HttpGet是HttpRequestBase的子类。使用HttpGet的例子: HttpClient httpclient = new DefaultHttpClient(); HttpHost targetHost = new HttpHost("www.google.cn"); HttpGet httpget = new HttpGet("/"); // 查看默认request头部信息 System.out.println("Accept-Charset:" + httpget.getFirstHeader("Accept-Charset")); //以下这条如果不加会发现无论你设置Accept-Charset为gbk还是utf-8,都会默认返回gb2312(本例针对google.cn来说) httpget.setHeader("User-Agent", "Mozilla/5.0 (Windows; U; Windows NT 5.1; zh-CN; rv:1.9.1.2)"); // 用逗号分隔显示可以同时接受多种编码 - - 获得海量数据 httpget.setHeader("Accept-Language", "zh-cn,zh;q=0.5"); httpget.setHeader("Accept-Charset", "GB2312,utf-8;q=0.7,*;q=0.7"); HttpResponse response = httpclient.execute(targetHost, httpget); EntityUtils提供了一些方法来读取HttpEntity实体中的内容。例如: EntityUtils.toString(entity,"utf-8"); 上面的代码读入实体中的内容流,并以字符串形式返回,这里指定实体中的内容以UTF-8编码。 有些网站页面内容返回格式为gzip压缩格式,所以在得到返回结果后要判断内容是否压缩过,如果是,则先要解压缩,然后再解析内容。这样的网页返回的头信息会说明:“Content-Encoding: gzip”。 public class ClientGZipContentCompression { public final static void main(String[] args) throws Exception { DefaultHttpClient httpclient = new DefaultHttpClient(); try { httpclient.addRequestInterceptor(new HttpRequestInterceptor() { public void process( final HttpRequest request, final HttpContext context)throws HttpException, IOException { if (!request.containsHeader("Accept-Encoding")) { request.addHeader("Accept-Encoding", "gzip"); } } }); httpclient.addResponseInterceptor(new HttpResponseInterceptor() { public void process( final HttpResponse response, final HttpContext context)throws HttpException, IOException { HttpEntity entity = response.getEntity(); Header ceheader = entity.getContentEncoding(); if (ceheader != null) { HeaderElement[] codecs = ceheader.getElements(); for (int i = 0; i < codecs.length; i++) { if (codecs[i].getName().equalsIgnoreCase("gzip")) { response.setEntity( - - 获得海量数据 new GzipDecompressingEntity(response.getEntity())); return; } } } } }); HttpGet httpget = new HttpGet("http://www.sohu.com"); // Execute HTTP request System.out.println("executing request " + httpget.getURI()); HttpResponse response = httpclient.execute(httpget); System.out.println("----------------------------------------"); System.out.println(response.getStatusLine()); System.out.println(response.getLastHeader("Content-Encoding")); System.out.println(response.getLastHeader("Content-Length")); System.out.println("----------------------------------------"); HttpEntity entity = response.getEntity(); if (entity != null) { String content = EntityUtils.toString(entity,"GBK"); System.out.println(content); System.out.println("----------------------------------------"); System.out.println("Uncompressed size: "+content.length()); } } finally { // When HttpClient instance is no longer needed, // shut down the connection manager to ensure // immediate deallocation of all system resources httpclient.getConnectionManager().shutdown(); } - - 获得海量数据 } static class GzipDecompressingEntity extends HttpEntityWrapper { public GzipDecompressingEntity(final HttpEntity entity) { super(entity); } @Override public InputStream getContent() throws IOException, IllegalStateException { // the wrapped entity's getContent() decides about repeatability InputStream wrappedin = wrappedEntity.getContent(); return new GZIPInputStream(wrappedin); } @Override public long getContentLength() { // length of ungzipped content is not known return -1; } } } 2.3.4 重定向 客户端浏览器必须采取更多操作来实现请求。例如,浏览器可能不得不请求服务器上的不同的页面,或通过代理服务器重复该请求。 HttpClient可以处理任何如下的和重定向相关的响应代码: l 301 永久移动。HttpStatus.SC_MOVED_PERMANENTLY l 302 临时移动。HttpStatus.SC_MOVED_TEMPORARILY l 303 See Other。HttpStatus.SC_SEE_OTHER l 307 临时重定向。HttpStatus.SC_TEMPORARY_REDIRECT 例如: redirect.cgi 发送一个 302 响应,同时提供一个Location头信息指向redirect2.cgi: - - 获得海量数据 HTTP/1.1 302 Moved Date: Sat, 15 May 2004 19:30:49 GMT Server: Apache/2.0.48 (Fedora) Location: /cgi-bin/jccook/redirect2.cgi Content-Length: 0 Content-Type: text/plain; charset=UTF-8 如果使用HttpClient,则可以通过如下代码得到跳转的网址: HttpResponse response = httpclient.execute(httppost); response.getLastHeader("Location"); //跳转的网址 response.getStatusLine().getStatusCode(); //返回的状态码 另外有些网页通过JavaScript跳转实现重定向。以如下这个页面为例: http://www.universidadperu.com/empresas/aliterm-sociedad-anonima-cerrada.php 查看这个网页的代码。重定向的代码就是这个JavaScript:
如果搜索“BANCO CONTINENTAL”这个公司名,则用正则表达式把“BANCO CONTINENTAL”中间的空格替换成-,然后小写化之后跳转到: http://www.universidadperu.com/empresas/busqueda/banco-continental 2.3.5 解决套结字连接限制 在Windows平台下,当爬虫运行多日以后,由于操作系统对于进程废弃的网络连接回收不完全,导致爬虫出错,只有重起操作系统后才能继续运行爬虫。所以要考虑使用连接池来重用网络连接。 在Linux下对连接socket数量也有限制,可以通过系统配置增加socket数量上限。当然也可以使用socket connection pool 连接池循环利用socket连接。 增加短暂的端口范围,并减少fin_timeout。用下面两个命令发现缺省值: #sysctl net.ipv4.ip_local_port_range #sysctl net.ipv4.tcp_fin_timeout - - 获得海量数据 短暂端口范围定义了一个主机可以从一个特定的IP地址创建的出去socket的最大的数量。fin_timeout定义了这些socket保持在TIME_WAIT状态的最小时间。 通常系统缺省值是: net.ipv4.ip_local_port_range = 32768 61000 net.ipv4.tcp_fin_timeout = 60 这基本上意味着在任何时候你的系统不能保证多于 (61000 - 32768) / 60 = 470 socket。如果觉得还不够多,可以从增加port_range开始。设置范围到15000 61000 很常见。可以通过减少fin_timeout进一步增加可获得的连接。同时做这两步,可以有多于1500个出连接。 sysctl是一个允许您改变正在运行中的Linux系统的接口。它包含一些 TCP/IP 堆栈和虚拟内存系统的高级选项, 这可以让有经验的管理员提高引人注目的系统性能。可以使用sysctl修改系统变量。 #sysctl -w net.ipv4.tcp_fin_timeout=30 #sysctl -w net.ipv4.ip_local_port_range="15000 61000" 也可以通过编辑sysctl.conf文件来修改系统变量。sysctl.conf 看起来很像rc.conf。它用variable=value的形式来设定值。修改配置文件,增加如下行到/etc/sysctl.conf: # increase system IP port limits net.ipv4.ip_local_port_range = 1024 65535 可以使用如下的命令检查是否已经正确的设置了这个值: #cat /proc/sys/net/ipv4/ip_local_port_range 当一个新的连接请求进来的时候,连接池管理器检查连接池中是否包含任何没用的连接,如果有的话,就返回一个。如果连接池中所有的连接都忙并且最大的连接池数量没有达到,就创建新的连接并且增加到连接池。当连接池中在用的连接达到最大值,所有的新连接请求进入队列,直到一个连接可用或者连接请求超时。 HttpClient有自己的连接池管理器。ThreadSafeClientConnManager管理一个连接池,能够为多个执行线程的连接请求提供服务。按路由提供连接缓存。 ThreadSafeClientConnManager维护最大的连接限制。每个路由不超过2个并行连接。总共不超过20个连接。可以使用HTTP参数调整连接限制。 public class HttpClientUtil { private static HttpClient httpClient = null; - - 获得海量数据 private static final Integer MAX_CONNECTIONS = 10; private static final String HTTP = "http"; private static final Integer HTTP_SCHEME_PORT = 80; private static final Integer HTTPS_SCHEME_PORT = 443; private static final String HTTPS = "https"; private static ThreadSafeClientConnManager threadSafeClientConnManager = null; public static HttpClient getMultiThreadedClient() { if (httpClient == null) { httpClient = new DefaultHttpClient(getThreadSaveConnectionManager(), getParams()); } return httpClient; } public static ThreadSafeClientConnManager getThreadSaveConnectionManager() { if (threadSafeClientConnManager == null) { // Create and initialize scheme registry SchemeRegistry schemeRegistry = new SchemeRegistry(); schemeRegistry.register(new Scheme(HTTP,HTTP_SCHEME_PORT,PlainSocketFactory.getSocketFactory())); schemeRegistry.register(new Scheme(HTTPS,HTTPS_SCHEME_PORT,SSLSocketFactory.getSocketFactory())); // Create an HttpClient with the ThreadSafeClientConnManager. // This connection manager must be used if more than one thread will // be using the HttpClient. threadSafeClientConnManager = new ThreadSafeClientConnManager(schemeRegistry); threadSafeClientConnManager.setMaxTotal(MAX_CONNECTIONS); } return threadSafeClientConnManager; } private static HttpParams getParams() { // Create and initialize HTTP parameters HttpParams params = new BasicHttpParams(); HttpProtocolParams.setVersion(params, HttpVersion.HTTP_1_1); - - 获得海量数据 return params; } } 使用连接池: HttpClient httpclient = HttpClientConnManager.getMultiThreadedClient(); HttpGet httpget = new HttpGet(url); HttpEntity entity = httpclient.execute(httpget).getEntity(); 2.3.6 下载图片 为了减少存储空间,需要把BMP格式的图片转换成JPG格式。javax.imageio.ImageIO类的write方法可以把BufferedImage对象保存成JPG格式。 public class ReadImage { public static void download(String imageUrl,String imageFileName) { URL url=new URL(imageUrl); Image src = javax.imageio.ImageIO.read(url); //构造Image对象 int wideth = src.getWidth(null); //得到源图宽 int height = src.getHeight(null); //得到源图长 BufferedImage thumb = new BufferedImage(wideth / 1, height / 1, BufferedImage.TYPE_INT_RGB); //绘制缩小后的图 thumb.getGraphics().drawImage(src, 0, 0, wideth / 1, height / 1, null); File file = new File(imageFileName); //输出到文件流 ImageIO.write(thumb,"jpg", file); } public static void main(String[] args) throws Exception { ReadImage.download("http://www.51766.com/img/jhzdd/1167040251883.bmp", "D:/HH.jpg"); } } 2.3.7 抓取FTP Commons VFS(http://commons.apache.org/vfs/index.html)提供一个统一的API用于处理各种不同的文件系统,包括FTP中的文件或者Zip压缩包中的文件。使用VFS遍历FTP中的文件的例子: - - 获得海量数据 FileSystemManager manager = VFS.getManager(); FileObject ftpFile = manager.resolveFile("ftp://hcl:hcl@localhost:21/loveapple"); FileObject[] children = ftpFile.getChildren(); System.out.println("Children of " + ftpFile.getName().getURI()); for (FileObject child : children) { String baseName = child.getName().getBaseName(); System.out.println("文件名:" + baseName + " -- " + new String(baseName.getBytes("iso-8859-1"), "UTF-8")); } 2.3.8 RSS抓取 因为XML比HTML更规范。所以出现了XML格式封装的数据源。RSS是对网站栏目的一种XML格式的封装。一些博客或者新闻网站提供RSS(Really Simple Syndication)格式的输出,方便程序快速访问更新的信息。RSS抓取的第一步是解析RSS数据源。Informa(http://informa.sourceforge.net/)提供了一个解析包。ROME(https://rome.dev.java.net/)是另外一个常用的解析包。WebNews Crawler(http://senews.sourceforge.net/)是一个在Informa基础上构架的新闻爬虫。 RSS种子(Feed)就是一个发布最新信息的URL地址。为了读取一个RSS种子,首先定义读取种子的源,然后定义构建新闻频道对象模型的ChannelBuilder。 ChannelBuilder builder = new ChannelBuilder(); String url = "http://rss.news.yahoo.com/rss/topstories"; // RSS种子 Channel channel = (Channel) FeedParser.parse(builder, url); System.out.println("标题: " + channel.getTitle()); System.out.println("描述: " + channel.getDescription()); System.out.println("内容:"); for (Object x : channel.getItems()) { //遍历最新的新闻条目 Item anItem = (Item) x; System.out.print("标题:"+anItem.getTitle() + " 描述: "); System.out.println(anItem.getDescription()); } 如何从网页发现RSS种子?下面是一个对RSS种子的描述: 根据type="application/rss+xml"可以确定link标签中包含的URI http://blog.csdn.net/myth1979/rss.aspx是RSS种子。利用HTMLParser来解析出有效的Feed的程序如下: - - 获得海量数据 TagNode tagNode = (TagNode)node; String name = ((TagNode)node).getTagName(); if (name.equals("LINK") && !tagNode.isEndTag() ) { String href=tagNode.getAttribute("HREF"); if(href!=null && (href.indexOf("rss")>=0 || href.indexOf("feed")>=0 || href.indexOf("rdf")>=0|| href.indexOf("xml")>=0|| href.indexOf("atom")>=0)) { //验证Feed的有效性。 boolean ret = rParser.valid(href); if(ret) rrsUrls.add(href); if("alternate".equals(tagNode.getAttribute("REL"))) { outURLs.clear(); System.out.println("end find"); return rrsUrls; } } } if( name.equals("A") ) { String href = tagNode.getAttribute("HREF"); if(href != null && (href.indexOf("rss")>=0 || href.indexOf("feed")>=0 || href.indexOf("rdf")>=0|| href.indexOf("xml")>=0|| href.indexOf("atom")>=0)) { boolean ret = rParser.valid(href); if(ret) rrsUrls.add(href); } if(href != null && outURLs!=null && href.startsWith("http://") && href.indexOf(domain)>=0) { - - 获得海量数据 outURLs.add(href); } } 从一个网页发现种子的步骤如下: 1. 首先,用一个函数来验证种子是否有效。 2. 如果这个URI已经指向一个种子,则只是返回它,否则分析这个页面。 3. 看这个页面的头信息是否包含LINK标签。 4. 链接到同一个服务器上以".rss"、".rdf"、".xml"或".atom"结尾的种子。 5. 链接到同一个服务器上包含".rss"、".rdf"、".xml"或".atom"的种子。 6. 链接到以".rss"、".rdf"、".xml"或".atom"结尾的外部服务器种子。 7. 链接到包含".rss"、".rdf"、".xml"或".atom"的外部服务器种子。 8. 尝试猜测一些可能有Feed的通用的地方,例如index.xml、atom.xml 等。 RSS抓取流程是:首先从网站中自动发现RSS。然后遍历每一个RSS,必要的时候分析详细页面。 2.3.9 网页更新 经常有人会问:“有没有什么新消息?”这说明人的大脑是增量获取信息的,对爬虫来说也是如此。网站中的内容经常会变化,这些变化经常在网站首页或者目录页有反应。为了提高采集效率,往往考虑增量采集网页。可以把这个问题看成是被采集的Web服务器和存储库同步的问题。更新网页内容的基本原理是:下载网页时,记录网页下载时的时间,增量采集这个网页时,判断URL地址对应的网页是否有更新。 网页更新过程符合泊松过程。泊松过程是指一种累计随机事件发生次数的最基本的独立增量过程。例如,随着时间增长累计某电话交换台收到的呼唤次数就构成一个泊松过程。网页更新时间间隔符合泊松指数分布。对于不同类型的网站采用不同的更新策略。例如.com域名的网站更新频率较高,而.gov域名的网站更新频率低。 对于一些不太可能会更新的网页,只抓取一遍即可。但有些网页,例如首页或者列表页更新频率较高,所以需要隔一段时间就检测这些网页是否更新。如果只是想看看网页是否有更新,可以用HTTP的HEAD命令查看网页的最后修改时间。 String host = "www.lietu.com"; //主机名 String file = "/index.jsp"; //网页路径 - - 获得海量数据 int port = 80; //端口号 Socket s = new Socket(host, port);//建立套接字对象 OutputStream out = s.getOutputStream(); PrintWriter outw = new PrintWriter(out, false); outw.print("HEAD " + file + " HTTP/1.0\r\n");//发送HEAD命令 outw.print("Accept: text/plain, text/html, text/*\r\n"); outw.print("\r\n"); outw.flush(); InputStream in = s.getInputStream();//返回头信息 InputStreamReader inr = new InputStreamReader(in); BufferedReader br = new BufferedReader(inr); String line; while ((line = br.readLine()) != null) { System.out.println(line); } 上面的程序在控制台打印结果: HTTP/1.1 200 OK Server: Apache-Coyote/1.1 ETag: W/"6810-1268491592000" Last-Modified: Sat, 13 Mar 2010 14:46:32 GMT Content-Type: text/html Content-Length: 6810 Date: Tue, 15 Jun 2010 01:48:27 GMT Connection: close 上面的输出结果中“Last-Modified”行记录了网页的修改时间。“Date”行返回的是Web服务器的当前时间。可以通过HttpURLConnection对象取得网页的修改时间,代码如下: URL u = new URL("http://www.lietu.com"); HttpURLConnection http = (HttpURLConnection) u.openConnection(); http.setRequestMethod("HEAD"); System.out.println(u + " 更新时间 " + new Date(http.getLastModified())); 有些网页头信息没有包括更新时间,这时候可以通过判断网页长度来检测网页是否有更新。当然也可能网页更新了,但是长度没变,但实际上,这种可能性非常小。 条件下载命令可以根据时间条件下载网页。再次请求已经抓取过的页面时,爬虫往Web - - 获得海量数据 服务器发送If-Modified-Since请求头,其中包含的时间是先前服务器端发过来的 Last-Modified 最后修改时间戳。这样让Web服务器端进行验证,通过这个时间戳判断爬虫上次抓过的页面是否有修改。如果有修改,则返回HTTP状态码200和新的内容。如果没有变化,则只返回 HTTP状态码304,告诉爬虫页面没有变化。这样可以大大减少在网络上传输的数据,同时也减轻了被抓取的服务器的负担。下面的代码通过套接字发送If-Modified-Since头信息。 outw.print("GET " + file + " HTTP/1.0\r\n"); outw.print("If-Modified-Since: Thu, 01 Jul 2011 07:24:54 GMT\r\n"); HTTP/1.1中还有一个Etag可以用来判断请求的文件是否被修改。可以把Etag看成网页的版本标志。Etag主要为了解决Last-Modified无法解决的一些问题: 1、一些文件也许会周期性的更改,但是它的内容并不改变(仅仅改变了修改时间),这个时候我们并不希望客户端认为这个文件被修改了,而重新下载。 2、某些文件修改非常频繁,比如在秒以下的时间内进行修改,(比方说1秒内修改了N次),If-Modified-Since能检查到的粒度是秒级的,无法判断这种修改。 3、不能精确的得到某些Web服务器的文件的最后修改时间。 在第一次抓取时记录网页的Etag。下次发起HTTP GET请求一个文件,同时发送一个If-None-Match头,这个头的内容就是我们第一次请求时Web服务器返回的Etag:6810-1268491592000。如果已经修改,则返回HTTP状态码200和新的内容。如果没有修改,则If-None-Match为False,返回HTTP状态码304。例如: outw.print("GET " + file + " HTTP/1.0\r\n"); outw.print("If-None-Match: \"1272af65f918cb1:84f\"\r\n"); 可以用Range条件下载部分网页。比如某网页的大小是 1000 字节,爬虫请求这个网页时用 “Range: bytes=0-500”,那么Web服务器应该把这个网页开头的 501 个字节发回给爬虫。 2.3.10 抓取限制应对方法 为了避免抓取的网站响应请求的负担过重,爬虫需要遵循礼貌性原则,不要同时发起过多的下载网页请求。这样才可能有更多的网站对爬虫友好。为了减少网站对爬虫的抱怨,建议每秒只抓取几次,把抓取任务尽量平均分配到每个时间段,并且避免高峰时段对访问的网站负担过重。 有些网站为了保护自己的内容,采取各种方法限制爬虫抓取,常见的方法有: l 判断HTTP请求的头信息,只有头信息和浏览器一样才正常返回结果。 l 当来自同一个IP的访问次数达到一定限制值后封IP,也就是把这个IP列入黑名单,超过一段时间 - - 获得海量数据 后再解封。 l 采用JavaScript动态显示内容。 有些网站中的网页在浏览器中可以正常访问,但却不能用程序正常下载,这时候需要模仿浏览器下载网页。可以用Firebug看到FireFox浏览器发送的头信息。 可以发送和浏览器类似的头信息来简单的模仿浏览器访问。使用HttpURLConnection下载网页时,设置请求头信息: HttpURLConnection urlConnection = (HttpURLConnection) pageURL.openConnection(); //设置和浏览器相同的头信息 urlConnection.setRequestProperty("User-Agent","MSIE 6.0"); urlConnection.setRequestProperty("Host", pageURL.getHost()); urlConnection.setRequestProperty("Accept-Language", "ru"); urlConnection.setRequestProperty("Accept-Charset","gb2312,utf-8;q=0.7,*;q=0.7n"); urlConnection.setRequestProperty("content-type", "text/html"); 使用HttpClient下载网页时,例子代码如下: DefaultHttpClient httpclient = new DefaultHttpClient(); BasicHttpParams hp = new BasicHttpParams(); hp.setParameter("http.useragent", "Mozilla/1.0 (compatible; linux 2015 plus; yep)"); // you oughta change this into your own UA string.... httpclient.setParams(hp); try { HttpGet httpget = new HttpGet("http://www.lietu.com/"); HttpContext HTTP_CONTEXT = new BasicHttpContext(); HttpResponse response = httpclient.execute(httpget, HTTP_CONTEXT); HttpEntity entity = response.getEntity(); if (entity != null) { System.out.println(EntityUtils.toString(entity,"utf-8")); EntityUtils.consume(entity); } } finally { httpclient.getConnectionManager().shutdown(); } 有些登陆后才能看到的信息可以人工登陆后,手工在程序中设置动态的Cookie值。 - - 获得海量数据 //假设Cookie值在cookieValue中 uc.setRequestProperty("Cookie", cookieValue); 有些网站对于同一个IP在一段时间内的访问次数有限制。可以使用Socket代理来更改请求的IP。这时可以通过大量不同的Socket代理循环访问网站。 首先建立有效代理列表,proxyIP.txt: 219.93.178.162:3128 222.135.79.253:8080 203.160.1.38:554 132.239.17.225:3124 169.229.50.5:3124 203.160.1.146:554 203.160.1.49:554 有些可以自动发现有效代理的软件,例如“花刺代理”等。 然后建立ProxyDB类,循环使用这些Socket代理: int pos = proxyIpList.get(count).toString().indexOf(":"); if (pos > 0) { String port = proxyIpList.get(count).toString().substring(pos+1); proxyAddr = proxyIpList.get(count).toString().substring(0,pos); proxyPort = Integer.parseInt(port); SocketAddress socketaddress = new InetSocketAddress(proxyAddr,proxyPort); proxy = new Proxy(Proxy.Type.HTTP,socketaddress); } 最后通过下载网页的URL的openConnection方法使用它: url.openConnection(ProxyDB.getProxy()); 通过Modem方式上网的计算机,每次上网所分配到的IP地址都不相同,这就是动态IP地址。因为IP地址资源很宝贵,大部分用户都是通过动态IP地址上网的。所谓动态就是指,当你每一次上网时,电信或网通会随机给你分配一个IP地址。重新启动上网的Modem就可以更换IP地址,使用新的IP地址继续抓取信息。使用浏览器Firefox下的插件Firebug分析出点击"重启路由器"时,浏览器向路由器发送的请求内容如下: GET /userRpm/SysRebootRpm.htm?Reboot=%D6%D8%C6%F4%C2%B7%D3%C9%C6%F7 HTTP/1.1 Host:192.168.1.1 Authorization:Basic YWRtaW46YWRtaW4= 其中,Authorization请求头的内容中,"Basic"表示"Basic authorization验证" ;"YWRtaW46YWRtaW4="是使用Base64编码后的用户名和密码,解密后是"admin:admin"。 - - 获得海量数据 同样,也可以用程序实现以上的HTTP请求: //modem的用户名和密码 String data = "admin:admin"; String authorization = Base64.encode(data.getBytes()); String host = "192.168.1.1"; //modem的ip地址 String file = "/userRpm/SysRebootRpm.htm?Reboot=%D6%D8%C6%F4%C2%B7%D3%C9%C6%F7"; //网页路径 int port = 80; //端口号 Socket s = new Socket(host, port); OutputStream out = s.getOutputStream(); PrintWriter outw = new PrintWriter(out, false); outw.print("GET " + file + " HTTP/1.1\r\n"); outw.print("Authorization:Basic "+authorization+"\r\n"); outw.print("\r\n"); outw.flush(); 为了找出抓取中的问题,可以采用网络工具wireshark追踪。 2.3.11 URL地址提取 在Windows的控制台窗口中,可以根据当前路径的相对路径转移到一个路径,例如cd ..转移到当前路径的上级路径。在HTML网页中也经常使用相对URL。 绝对URL就是不依赖其他的URL路径,例如:"http://www.lietu.com/index.jsp"。在一定的上下文环境下可以使用相对URL。网页中的URL地址可能是相对地址例如“./index.html”。可以在< A>和标签中使用相对URL。例如: 可以根据所在网页的绝对URL地址,把相对地址转换成绝对地址。为了灵活的引用网站内部资源,相对路径在网页中很常见。爬虫为了后续处理方便,需要把相对地址转化为绝对地址。URL对象的toString方法返回的是绝对地址,因此为了把相对地址转化成绝对地址,只需要生成相对地址对应的URL对象即可。new URL(fromUrl, url)方法可以实现从相对地址url和来源URL对象fromUrl生成新的URL对象。测试功能: - - 获得海量数据 URL fromUrl = new URL("http://www.lietu.com/news/"); String url = "../index.html";//网页中的相对超链接地址 String newUrl = (new URL(fromUrl, url)).toString();//转成字符串类型 System.out.println(newUrl);//打印http://www.lietu.com/index.html 封装成一个方法: public static String parseRealURL(String urlSource, String url) throws MalformedURLException { URL from = new URL(urlSource); return (new URL(from, url)).toString(); } 有些相对地址的解析不正确,例如,源地址是: http://www.bradfordexchange.com/mcategory/apparel-and-accessories_9750/womens-jackets.html 相对地址是: mcategory/apparel-and-accessories_9750/womens-jackets_pg2.html 采用如下的代码修正这个错误: char firstChar = s.charAt(0); if (firstChar >= 'a' && firstChar <= 'z' || firstChar >= 'A' && firstChar <= 'Z') { if (!s.startsWith("https:") && !s.startsWith("http:")) { s = "/" + s; URI newUri = baseURI.resolve(s); return newUri; } } 有些网址是通过提交表单,或者通过JavaScript来跳转的。可以参考HtmlUnit (http://htmlunit.sourceforge.net/)中的相关实现来获取。 2.3.12 抓取需要登录的网页 很多网站的内容都只是对注册用户可见的,这种情况下就必须要求使用正确的用户名和口令登录成功后,方可浏览到想要的页面。因为HTTP协议是无状态的,也就是连接的有效期只限于当前请求,请求内容结束后连接就关闭了。在这种情况下为了保存用户的登录信息必须使用到Cookie机制。以JSP/Servlet 为例,当浏览器请求一个JSP或者是Servlet的页面时,应用服务器会返回一个参数,名为jsessionid - - 获得海量数据 (因不同应用服务器而异),值是一个较长的唯一字符串的Cookie,这个字符串值也就是当前访问该站点的会话标识。浏览器在每访问该站点的其他页面时候都要带上jsessionid这样的 Cookie信息,应用服务器根据读取这个会话标识来获取对应的会话信息。 对于需要用户登录的网站,一般在用户登录成功后会将用户资料保存在服务器的会话中,这样当访问到其他的页面时候,应用服务器根据浏览器送上的Cookie中读取当前请求对应的会话标识以获得对应的会话信息,然后就可以判断用户资料是否存在于会话信息中,如果存在则允许访问页面,否则跳转到登录页面中要求用户输入帐号和口令进行登录。这就是一般使用 JSP开发网站在处理用户登录的比较通用的方法。 这样一来,对于HTTP的客户端来讲,如果要访问一个受保护的页面时就必须模拟浏览器所做的工作,首先就是请求登录页面,然后读取Cookie值;再次请求登录页面并加入登录页所需的每个参数;最后就是请求最终所需的页面。当然在除第一次请求外其他的请求都需要附带上Cookie信息以便服务器能判断当前请求是否已经通过验证。HttpClient(http://hc.apache.org/httpcomponents-client-ga/)自动管理了cookie信息,只需要先传递登录信息执行登录过程,然后直接访问想要的页面,跟访问一个普通的页面没有任何区别,因为HttpClient已经帮忙发送了Cookie信息。下面的例子实现了这样一个访问的过程。 public class RenRen { // 配置参数 private static String userName = "邮箱地址"; private static String password = "密码"; private static String redirectURL = "http://blog.renren.com/blog/304317577/449470467"; //要抓取的网址 // 登录URL地址 private static String renRenLoginURL = "http://www.renren.com/PLogin.do"; // 用于取得重定向地址 private HttpResponse response; // 在一个会话中用到的httpclient对象 private DefaultHttpClient httpclient = new DefaultHttpClient(); //登录到页面 private boolean login() { //根据登录页面地址初始化httpost对象 HttpPost httpost = new HttpPost(renRenLoginURL); //POST给网站的所有参数 List nvps = new ArrayList(); - - 获得海量数据 nvps.add(new BasicNameValuePair("origURL", redirectURL)); nvps.add(new BasicNameValuePair("domain", "renren.com")); nvps.add(new BasicNameValuePair("isplogin", "true")); nvps.add(new BasicNameValuePair("formName", "")); nvps.add(new BasicNameValuePair("method", "")); nvps.add(new BasicNameValuePair("submit", "登录")); nvps.add(new BasicNameValuePair("email", userName)); nvps.add(new BasicNameValuePair("password", password)); try { httpost.setEntity(new UrlEncodedFormEntity(nvps, HTTP.UTF_8)); response = httpclient.execute(httpost); } catch (Exception e) { e.printStackTrace(); return false; } finally { httpost.abort(); } return true; } //取得重定向地址 private String getRedirectLocation() { Header locationHeader = response.getFirstHeader("Location"); if (locationHeader == null) { return null; } return locationHeader.getValue(); } //根据重定向地址返回内容 private String getText(String redirectLocation) { HttpGet httpget = new HttpGet(redirectLocation); // 创建一个响应处理器 ResponseHandler responseHandler = new BasicResponseHandler(); String responseBody = ""; try { //取得网页内容 - - 获得海量数据 responseBody = httpclient.execute(httpget, responseHandler); } catch (Exception e) { e.printStackTrace(); responseBody = null; } finally { httpget.abort(); httpclient.getConnectionManager().shutdown();//关闭连接 } return responseBody; } public void printText() { if (login()) { String redirectLocation = getRedirectLocation(); if (redirectLocation != null) { System.out.println(getText(redirectLocation)); } } } public static void main(String[] args) { RenRen renRen = new RenRen(); renRen.printText(); } } 抓取需要ASP.NET表单验证的网页更加复杂,涉及到网页自动生成的一些隐藏字段。可以在ASP.NET的页面中的 控件中放置一个名字叫做VeiwState的隐藏域来定义页面的状态。 源代码可能类似这样: .....some code - - 获得海量数据 在配置中使用 或在页面中使用 <%@ Page EnableEventValidation="true" %> 将启用事件验证。出于安全目的,此功能验证回发或回调事件的参数是否来源于最初呈现这些事件的服务器控件。此时会在页面中生成一个__EVENTVALIDATION隐藏字段。所以需要提交__VIEWSTATE和__EVENTVALIDATION两个隐藏字段。抓取网页的主要代码如下: String url = "http://www.2552.net/Book/LC/1.aspx"; // 构造HttpClient的实例 HttpClient httpClient = new DefaultHttpClient(); HttpPost httpost = new HttpPost(url); List nvps = new ArrayList(); nvps.add(new BasicNameValuePair("__VIEWSTATE", "省略"));//这里填实际的值 nvps.add(new BasicNameValuePair("__EVENTTARGET", "_ctl0:pager")); nvps.add(new BasicNameValuePair("__EVENTARGUMENT", "2")); httpost.setEntity(new UrlEncodedFormEntity(nvps, HTTP.UTF_8)); ResponseHandler responseHandler = new BasicResponseHandler(); String responseBody = ""; try { responseBody = httpClient.execute(httpost, responseHandler); } catch (Exception e) { e.printStackTrace(); responseBody = null; } finally { httpost.abort(); httpClient.getConnectionManager().shutdown(); } System.out.println(responseBody); 查询__VIEWSTATE和__EVENTVALIDATION两个隐藏字段的值。 String url = "http://www.925city.cn/cityadmin/StockSearch.aspx"; HttpClient httpClient = new DefaultHttpClient(); HttpPost httpost = new HttpPost(url); ResponseHandler responseHandler = new BasicResponseHandler(); String responseBody = ""; try { responseBody = httpClient.execute(httpost, responseHandler); } catch (Exception e) { - - 获得海量数据 e.printStackTrace(); responseBody = null; return; } finally { httpost.abort(); } //从网页提取值 int state = responseBody.indexOf("id=\"__VIEWSTATE\""); String viewState = responseBody.substring(state + 24); viewState = viewState.substring(0, viewState.indexOf("\"")); int c = responseBody.indexOf("id=\"__EVENTVALIDATION\""); String validation = responseBody.substring(c + 30); validation = validation.substring(0, validation.indexOf("\"")); System.out.println("VIEWSTATE:"+viewState); System.out.println("VALIDATION:"+validation); //查询商品库存情况 httpost = new HttpPost(url); List nvps = new ArrayList(); nvps.add(new BasicNameValuePair("__VIEWSTATE", viewState)); nvps.add(new BasicNameValuePair("__EVENTVALIDATION", validation)); nvps.add(new BasicNameValuePair("txtbn", "b11312")); //商品编号 nvps.add(new BasicNameValuePair("btnSearch", "查 询")); httpost.setEntity(new UrlEncodedFormEntity(nvps, HTTP.ASCII)); try { responseBody = httpClient.execute(httpost, responseHandler); } catch (Exception e) { e.printStackTrace(); responseBody = null; } finally { httpost.abort(); httpClient.getConnectionManager().shutdown(); } - - 获得海量数据 System.out.println(responseBody); 2.3.13 抓取JavaScript动态页面 很多网页中包含JavaScript代码。例如一些网页链接嵌入在JavaScript代码中,因此爬虫最好能够识别JavaScript。虽然JavaScript从语义上看来和Java非常相近,但实际上JavaScript来自一个和Java完全不同的编程语言家族。JavaScript是Smalltalk和Lisp语言的一个直系后裔。 Mozilla 发布了纯Java 语言编写的JavaScript 脚本解释引擎Rhino(http://www.mozilla.org/rhino/)。Rhion包含所有javaScript 1.7的特征。Rhion附带一个可以运行JavaScript脚本的JavaScript外壳。有一个JavaScript编译器把JavaScript源文件翻译成Java的class文件。有一个JavaScript调试器用于Rhino执行脚本。Rhino 可以实现从Java 对象到动态页面脚本片段常用语言———JavaScript 对象的直接映射,这有利于简化脚本解析环境的构建工作,减少脚本解释引擎与脚本片段在实现语言方面的差异。同时,在脚本解析的过程中,Rhino对于JavaScript 对象的操作结果可以通过访问已经本地创建的、与其一一对应的Java 对象直接获得,示例代码如下: ScriptEngineManager factory = new ScriptEngineManager(); ScriptEngine engine = factory.getEngineByName("JavaScript"); String s = GetJSText.getAllJS().toString(); // 读取JavaScript的文本 engine.eval(s); // 加载JavaScript // engine.eval(new FileReader("HelloWorld.js"));//从文件读入JavaScript脚本 // Invocable inv = (Invocable) engine; // Object obj = inv.invokeFunction("getI"); //调用JavaScript的function getI() // System.out.println(obj); Rhino的主要功能是脚本执行时的运行环境管理。运行环境是指用来保存所要执行的脚本中的变量、对象和执行上下文的空间。运行环境中的变量和对象由运行环境内所有的执行上下文共享,即一个执行上下文创建的变量或对象其他上下文也可以访问,运行环境负责处理变量或对象访问时的同步和互斥问题。运行环境和执行上下文是执行脚本语句的场所,因此在应用程序中应首先调用Rhino提供的API(应用程序接口)建立一个运行环境和若干个执行上下文,然后调用相应的API 建立脚本语言的内置对象。 HTML DOM 中只有Window 和Document 对象的方法参数中含有超链接网络地址信息和页面主体内容。因此在进行HTML DOM 对象本地创建时,将其余对象的属性和方法简单地设置为空(NULL)。 在Window 和Document 对象的方法参数中,与超链接网络地址、页面主体内容相关的函数可以分为两类:第一类以Window 对象的open方法为代表,open方法的参数是动态页面中的超链接网络地址,参数类型是JavaScript 语言内置String 类型。在引擎外创建该类方法时,声明该方法的行为是把参数,即超链接网络地址送入信息采集环节的待获取URL 队列中;第二类以Document 对象的write 方法为代表,write 方法的参数(同为JavaScript 语言内置String 类型) 是一段表达脚本片段最终在浏览器中呈现内容的静态网页源文件。类似于常见的静态网页,在作为write方法参数的网页源文件中,超链接网络地址和页面主体内容被分别以URL 和文本信息蹬方式直接嵌入HTML 标记中。在引擎外创建该类方法时,声明该方法的行为是把参数,即静态网页源文件写入位于本地特定的文件中。 - - 获得海量数据 由于Rhino 能够自动在Java 对象和JavaScript 对象之间根据“对象名称一致性”的原则实现一一对应。因此,当Rhino 在执行脚本片断中的“Window.open()”与“Document .write () ”时,实际上是分别调用在Java 语言作用域中定义的,与这两个方法同名的Java 函数,执行函数体中关于函数行为的描述。 在完成脚本片断提取和HTML DOM 本地创建后,就可以调用Rhino 提取JavaScript 动态页面中的超链接网络地址及页面主体内容。当遇到脚本片段中的HTML DOM 时,Rhino 根据引擎外创建的同名函数体中的行为描述执行相应动作。 根据HTML DOM 本地创建结果,Rhino将脚本片段中Window 对象方法open 参数体现的超链接网络地址直接送入信息采集环节的待获取URL 队列中,实现动态页面内含超链接的递归获取功能。与其类似,把脚本片段中Document 对象方法write 参数所指向的静态网页源文件写入本地特定的文件中。在此基础上,使用传统的HTML 标记识别方法,提取得到的静态网页源文件中的超链接网络地址与页面主体内容,将前者送入信息采集环节的URL 队列,把后者交信息采集环节统一实现数据存储。下面的网页按钮上存在的JavaScript形式的跳转链接: 提取这个跳转链接可以采用HtmlUnit (http://htmlunit.sourceforge.net/)实现。HtmlUnit底层也是调用了Rhino,但是绕过了Rhino的一些错误。主要代码如下: public static void main(String[] args) throws Exception { WebClient webClient = new WebClient(); - - 获得海量数据 String url = "http://www.lietu.com/lab/location.html"; HtmlPage page = (HtmlPage) webClient.getPage(url); DomNodeList nodeList = page.getChildNodes(); for (Object node : nodeList) { HtmlElement n = (HtmlElement) node; extract(n); } } public static void extract(HtmlElement node) throws IOException { if ("input".equals(node.getNodeName())) { HtmlInput inputHtml = (HtmlInput) node; HtmlPage newPage = inputHtml.click(); System.out.println(newPage.toString());//按钮跳转的新页面 } DomNode childNode = node.getFirstChild(); if (childNode instanceof HtmlElement) { HtmlElement child = (HtmlElement) childNode; while (child != null) { // 递归调用extract方法 if(child == childNode) { extract(child); } childNode = childNode.getNextSibling(); if (childNode == null) { break; } if (childNode instanceof HtmlElement) { child = (HtmlElement) childNode; } } } } 但是HtmlUnit对于JavaScript框架jQuery的支持不太好。 - - 获得海量数据 另外一种方式是用selenium(http://seleniumhq.org)抓取JavaScript动态信息。有广泛的用户群和活跃的开发团队,Google公司资助并且使用Selenium。selenium的核心代码通过JavaScript完成,可以运行在FireFox或IE等浏览器中。Watir(http://watir.com/)是一个控制浏览器行为的类似项目。虽然可以利用jrex提取渲染后的html文件,但推荐用c#来实现。 2.3.14 抓取即时信息 信息的即时性往往很重要。例如,刚出现的经济信息可能在几秒钟以后就会影响相关股价。 为了加快获取信息的速度,可以先只抓取标题及URL地址。对于首页,通过HTTP协议的HEAD命令判断页面是否已经更新。在不同的时间段,用不同的频率来检查网页更新。 很多行业网站的首页是按板块组织的,可以按板块提取URL地址。有的板块全部是新的,也就是说,新的信息从这个板块溢出了。对于从板块溢出的地址,再从该版块对应的索引页补全信息。查找一个板块对应的索引页的方法是:查找该板块的“更多”链接对应的URL地址。 为了节省带宽,抓取到某个页面时,如果已经没有新的信息,可以不再继续往下检查这个页面的后续网页是否有更新。许多Web服务器具有发送压缩数据的能力,这可以将网络线路上传输的大量数据消减 60% 以上。 假设一个网站的首页有90K字节*8位=720K位,每秒需要同步200个网站,则需要大约140M带宽。 2.3.15 抓取暗网 互联网就像是深不可测的大海。在大海中,很多鱼深藏在水下。很多有用的信息隐藏在互联网中。 暗网的表现形式一般是:前台是一个表单来获取,提交后返回一个列表形式的搜索结果页,它们是由暗网后台数据库动态产生的。 为了用程序提交表单,这里用到Web操作录制工具badboy(http://www.badboy.com.au/)和Web应用测试工具JMeter(http://jakarta.apache.org/jmeter/)。使用badboy录制出登录时向网站发出的请求参数。然后导出成JMeter文件,在JMeter中就可以看到登录时向网站发送的参数列表和相应的值。 搜索引擎本身也可以看作是一个暗网。搜索结果页包含了指向详细内容页的链接。下面讨论抓取搜索引擎中的内容。例如,有一个乡镇词表。“郭河”是一个小地名,但不知道它是一个镇还是一个村。把这个词提交给搜索引擎,搜索引擎返回的结果中可能包含了“郭河镇”这样的结果,这样就知道了“郭河”可能是一个镇。例如,把公司名称作为查询词提交给Google并从返回结果中提取联系方式: - - 获得海量数据 String companyName = "SILK INDIA"; String searchWord = URLEncoder.encode(companyName, "utf-8");//返回编码后的查询词 String searchURL = "http://www.google.com/search?q="+searchWord; String content = RetrivePage.downloadPage(searchURL); 如果要提取公司的网址,可以提交搜索“website:www 公司名”给Google。但是同一个IP每天Google最多只返回1000次查询。 以搜农网(www.sounong.net)为例,按照作物分类,将作物名称,供求类型等信息组装出查询URL地址,获得该URL的返回页面内容,匹配出当前查询的作物信息共有多少条,然后计算出页面数量,这样组装URL时,动态替换页码进行翻页。代码如下: final static String ORDER_FORMAT = "http://www.sounong.net/newsounong/gq.jsp?q=%s&flag=show&adr=&sa%2F=%B9%A9%C7%F3%CB%D1%CB%F7&flt=1&type=%C7%F3%B9%BA&sort=2"; String searchWord = URLEncoder.encode(keyword, "GBK"); String url = ORDER_FORMAT.replace("%s", searchWord);//执行参数替换 此外还可以抓取手机号码和所属地区的对应关系。提交一个手机号码到网站,返回所属地区。一般来说,前7位相同的手机都是属于同一个地区,所以可以根据手机号码的前7位来判断地区。 对不同的语言,可以考虑抓取当地的搜索引擎网站,例如Google的葡萄牙文网站是http://www.google.pt。 对于暗网内容,有时候可以直接循环编码。例如,抓取酒店信息: http://hotels.ctrip.com/hotel/58.html http://hotels.ctrip.com/hotel/5829.html http://hotels.ctrip.com/hotel/55829.html 直接循环遍历酒店编码。 2.3.16 信息过滤 有时候可能需要按一个关键词列表来过滤信息,例如过滤黄色或其它非法信息。 调用indexOf方法来查找关键词集合看起来效率不高,Aho-Corasick算法可以用来在文本中搜索多个关键词。当有一个关键词的集合,想发现文本中的所有出现关键词的位置,或者检查是否有关键词集合中的任何关键词出现在文本中时,这个实现代码是有用的。有很多不经常改变的关键词时,可以使用Aho-Corasick算法。其运行时间是输入文本的长度加上匹配关键词数目的线性和。Aho-Corasick算法根据两个发明人Alfred V. Aho 和Margaret J. Corasick命名。 - - 获得海量数据 Aho-Corasick把所有要查找的关键词构建一个Trie树。Trie树包含后缀树一样的链接集合,从代表字符串的每个节点(例如abc)到最长的后缀(例如如果词典中存在bc,则链接到bc,否则如果词典中存在c,则链接到c,否则链接到根节点)。每个节点的失败匹配属性(failure)存储这个最长后缀节点。也就是说,还包含一个从每个节点到存在于词典中的最长后缀节点链接。 假设词典中存在词:a, ab, bc, bca, c, caa。6个词组成如下的Trie树结构如图2-4: root a b c b c a a a 图2-4 Trie树结构 由指定的字典构造的Aho-Corasick算法的数据结构如表2-1,表里面的每一行代表树中的一个节点,表里的列路径用从根到节点的唯一字符序列说明。 路径 是否在字典里 后缀链接 词典后缀链接 () 否 (a) 是 () (ab) 是 (b) (b) 否 () - - 获得海量数据 (bc) 是 (c) (c) (bca) 是 (ca) (a) (c) 是 () (ca) 否 (a) (a) (caa) 是 (a) (a) 表2-1 Trie树结构 每取一个待匹配的字符后,当前的节点通过寻找它的孩子来匹配,如果孩子不存在,也就是匹配失败,则试图匹配该节点后缀的孩子,如果这样也没有匹配上,则匹配该节点的后缀的后缀的孩子,最后如果什么也没有匹配上,就在根节点结束。 分析输入字符串“abccab”的匹配过程如表2-2: 节点 剩余的字符串 输出:结束位置 跳转 输出 () abccab   从根节点开始 (a) bccab a:1 从根节点()转移到孩子节点 (a) 当前节点 (ab) ccab ab:2 节点(a) 转移到孩子节点(ab) 当前节点 (bc) cab bc:3, c:3 节点(ab) 转到后缀 (b) ,再跳转到孩子节点 (bc) 当前节点,词典后缀节点 (c) ab c:4 节点(bc) 转到后缀节点 (c) ,再跳转到根节点(),再跳转到孩子节点(c) 当前节点 (ca) b a:5 节点(c) 跳转到孩子节点(ca) 词典后缀节点 (ab) ab:6 节点(ca) 跳转到后缀节点(a),再跳转到孩子节点(ab) 当前节点 表2-2 Aho-Corasick算法匹配过程 - - 获得海量数据 首先定义Trie树结点类: private final class TreeNode { private char _char;//节点代表的字符 private TreeNode _parent;//该节点的父节点 private TreeNode _failure;//匹配失败后跳转的节点 private ArrayList _results;//存储模式串的数组变量 private TreeNode[] _transitionsAr; //存储孩子节点的哈希表 private Hashtable _transHash; public TreeNode(TreeNode parent, char c) { _char = c; _parent = parent; _results = new ArrayList();//存储所有没有重复的模式串的数组 _transitionsAr = new TreeNode[] {}; _transHash = new Hashtable(); } //将模式串中不在_results中的模式串添加进来 public void addResult(String result) { if (_results.contains(result))//如果已经包含该模式则不增加到模式串中 return; _results.add(result); } public void addTransition(TreeNode node) {//增加一个孩子节点 _transHash.put(node._char, node); TreeNode[] ar = new TreeNode[_transHash.size()]; Iterator it = _transHash.values().iterator(); for (int i = 0; i < ar.length; i++) { if (it.hasNext()) { ar[i] = it.next(); } } _transitionsAr = ar; } public TreeNode getTransition(char c) { - - 获得海量数据 return _transHash.get(c); } public boolean containsTransition(char c) { return getTransition(c) != null; } public char getChar() { return _char; } public TreeNode parent() { return _parent; } public TreeNode failure(TreeNode value) { _failure = value; return _failure; } public TreeNode[] transitions() { return _transitionsAr; } public ArrayList results() { return _results; } } Trie树的构建过程由构建树本身和增加失败匹配属性两步构成: public StringSearch(String[] keywords) { buildTree(keywords);//构建树 addFailure();//增加失败匹配属性 } 构建树的过程: void buildTree() { _root = new TreeNode(null, ' '); - - 获得海量数据 for (String p : _keywords) { TreeNode nd = _root; for(char c : p.toCharArray()){ TreeNode ndNew = null; for (TreeNode trans : nd.transitions()) if (trans.getChar() == c) { ndNew = trans; break; } if (ndNew == null) { ndNew = new TreeNode(nd, c); nd.addTransition(ndNew); } nd = ndNew; } nd.addResult(p); } } 增加失败匹配属性的过程: private void addFailure(){ //所有词的第n个字节点的集合,n从2开始 ArrayList nodes = new ArrayList(); //所有词的第2个字节点的集合 for (TreeNode nd : _root.transitions()) { nd.failure(_root); for (TreeNode trans : nd.transitions()){ nodes.add(trans); } } //所有词的第n+1个字节点的集合 while (nodes.size() != 0) { - - 获得海量数据 ArrayList newNodes = new ArrayList(); for (TreeNode nd : nodes) { TreeNode r = nd.parent()._failure; char c = nd.getChar(); //如果在父节点的失败节点的孩子节点中没有同样字符结尾的 //,则继续在失败节点的失败节点中找 while (r != null && !r.containsTransition(c)) r = r._failure; if (r == null) nd._failure = _root; else { nd._failure = r.getTransition(c); for (String result : nd._failure.results()) { nd.addResult(result); } } for (TreeNode child : nd.transitions()){ newNodes.add(child); } } nodes = newNodes; } _root._failure = _root; } 为了加深理解,可以打印生成的Trie树。遍历二叉树的方法有:先序遍历、后序遍历、中序遍历。深度遍历、广度遍历是针对普通树。因为这是一棵普通的树,所以采用深度遍历的方式打印树的结构: //深度遍历的递归函数 public void depthSearch(TreeNode node, StringBuilder ret,int deapth) { if (node != null) { for (int i = 0; i < deapth; i++) ret.append("| "); ret.append("|——"); ret.append(node._char + "\n"); for (TreeNode child : node.transitions()) { - - 获得海量数据 int childDeapth = deapth + 1;//计算深度并赋值 depthSearch(child, ret, childDeapth); } } } //打印树节点 public String toString() { StringBuilder ret = new StringBuilder(); ret.append("打印树节点:\n"); int deapth = 0; depthSearch(_root, ret,deapth); return ret.toString(); } 从输入文本查找关键词集合的过程: public StringSearchResult[] findAll(String text) { ArrayList ret = new ArrayList(); TreeNode ptr = _root; int index = 0; while (index < text.length()) { TreeNode trans = null; while (trans == null) { trans = ptr.getTransition(text.charAt(index)); if (ptr == _root) break; if (trans == null) { ptr = ptr._failure; } } if (trans != null) ptr = trans; //增加找到的每一个词到结果中 for (String found : ptr.results()) ret.add(new StringSearchResult(index - found.length() + 1, found)); - - 获得海量数据 index++; } return ret.toArray(new StringSearchResult[ret.size()]); } 2.4 URL地址查新 在科技论文发表时,为了避免重复研究和抄袭,需要到专门的科技情报所做论文查新。为了避免重复抓取,URL地址也需要查新。判断解析出的URL是否已经遍历过也叫做URLSeen测试。URLSeen测试对爬虫性能有重要的影响。本节介绍两种实现快速URLSeen测试的方法。 在介绍爬虫架构的时候,我们讲解了Frontier组件的作用。它作为一个基础的组件,为爬虫提供URL。因此,在Frontier中有一个数据结构来存储URL。在一些小的爬虫程序中,使用内存队列(ArrayList、HashMap或Queue)或者优先级队列来存储URL,但内存是有限的。在通常商业应用中,URL地址数据量非常大。早期的爬虫经常把URL地址放在数据库表中,但数据库对于这种简单的结构化存储来说效率太低。因此,考虑使用内存数据库来存储。BerkeleyDB就是一种常用的内存数据库。 2.4.1 BerkeleyDB BerkeleyDB(http://www.oracle.com/database/berkeley-db/index.html)是一个嵌入式数据库。这里的嵌入式和嵌入式系统无关,嵌入式数据库的意思是不需要通过JDBC访问数据库,也不单独启动进程来管理数据。BerkeleyDB中的一个数据库只能存储一些键/值对,也就是键和值两列。BerkeleyDB底层实现采用B树,可以把它看成可以存储大量数据的HashMap。BerkeleyDB的c++版本首先出现,然后在此基础上又实现了Java本地版本。但本书中只用到Java版本。可以用它来存储已经抓取过的URL地址。如果使用Berkeley DB Java Edition 4.0.103,在Eclipse项目中只需要导入一个jar包——je-4.0.103.jar。BerkeleyDB的数据库需要在一个环境类的实例中打开。所以首先要实例化一个环境类: //创建一个环境配置对象 EnvironmentConfig envConfig = new EnvironmentConfig(); envConfig.setTransactional(false); //不需要事务功能 envConfig.setAllowCreate(true); //允许创建新的数据库文件 //在路径envDir下创建数据库环境 Environment exampleEnv = new Environment(envDir, envConfig); 释放环境变量的方法: exampleEnv.sync(); //同步内存中的数据到硬盘 exampleEnv.close(); //关闭环境变量 - - 获得海量数据 创建数据库,键是字符串,值是一个叫做NewsSource的类: String databaseName= "ToDoTaskList.db"; //创建一个数据库配置对象 DatabaseConfig dbConfig = new DatabaseConfig(); dbConfig.setAllowCreate(true); //允许创建新的数据库文件 dbConfig.setTransactional(false); //不需要事务功能 // 打开用来存储类信息的数据库 dbConfig.setSortedDuplicates(false); //用来存储类信息的数据库不要求能够存储重复的关键字 Database myClassDb = exampleEnv.openDatabase(null, "classDb", dbConfig); //初始化用来存储序列化对象的catalog类 catalog = new StoredClassCatalog(myClassDb); TupleBinding keyBinding = TupleBinding.getPrimitiveBinding(String.class); //把值作为对象的序列化方式存储 SerialBinding valueBinding = new SerialBinding(catalog, NewsSource.class); store = exampleEnv.openDatabase(null, databaseName, dbConfig); store.close();//关闭存储数据库 myClassDb.close();//关闭元数据库 建立数据的映射: // 创建数据存储的映射视图 this.map = new StoredSortedMap(store, keyBinding, valueBinding, true); 然后可以像操作普通的散列表一样,对StoredSortedMap用put方法写入数据,get方法读出数据,也通过containsKey方法判断某个关键字是否存在,还可以通过迭代器遍历访问StoredSortedMap中的元素。StoredSortedMap和HashMap的区别是,HashMap只能把所有的数据存储在内存,而StoredSortedMap则会通过内外存交换支持更多的数据存取。实现URLSeen测试的完整的例子如下: String dir = "./db/"; EnvironmentConfig envConfig = new EnvironmentConfig(); envConfig.setTransactional(false); envConfig.setAllowCreate(true); Environment env = new Environment(new File(dir), envConfig); //使用一个通用的数据库配置 DatabaseConfig dbConfig = new DatabaseConfig(); - - 获得海量数据 dbConfig.setTransactional(false); dbConfig.setAllowCreate(true); //如果有序列化绑定则需要类别数据库 Database catalogDb = env.openDatabase(null, "catalog", dbConfig); ClassCatalog catalog = new StoredClassCatalog(catalogDb); //关键字数据类型是字符串 TupleBinding keyBinding = TupleBinding.getPrimitiveBinding(String.class); //值数据类型也是字符串 SerialBinding dataBinding = new SerialBinding(catalog, String.class); Database db = env.openDatabase(null, "url", dbConfig); //创建一个映射 SortedMap map = new StoredSortedMap (db, keyBinding, dataBinding, true); //把抓取过的URL地址作为关键字放入映射 String url = "http://www.lietu.com"; map.put(url, null); if(map.containsKey(url)){ System.out.println("已抓取"); } 为了能正确处理大量数据,需要增加Java虚拟机运行的内存使用量,否则会出现内存溢出的错误。例如增加最大内存用量到800M,可以使用虚拟机参数“-Xmx800m”。 2.4.2 布隆过滤器 判断URL地址是否已经抓取过还可以借助于布隆过滤器(Bloom Filter)。布隆过滤器的实现方法是:利用内存中的一个长度是m的位数组B,对其中所有位都置0,如图2-5。 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 图2-5 位数组B的初始状态 - - 获得海量数据 然后对每个遍历过的URL根据k个不同的散列函数执行散列,每次散列的结果都是不大于m的一个整数a。根据散列得到的数在位数组B对应的位上置1,也就是让B[a]=1。图2-6显示了放入3个URL后位数组B的状态,这里k=3。 0 1 0 0 1 0 1 0 0 1 1 1 0 1 1 0 URL1 URL2 URL3 图2-6 放入数据后位数组B的状态 每次插入一个URL,也执行k次散列,只有当全部位都已经置1了才认为这个URL已经遍历过。如下是使用布隆过滤器(http://code.google.com/p/java-bloomfilter/)的一个例子: // 创建一个100位的布隆过滤器,优化成包含4个项目 BloomFilter urlSeen = new BloomFilter(100, 4); // 增加内容到布隆过滤器 urlSeen.add("www.lietu.com"); urlSeen.add("www.sina.com"); urlSeen.add("www.qq.com"); urlSeen.add("www.sohu.com"); // 测试布隆过滤器是否记得这个项目 if (urlSeen.contains("www.lietu.com")) { System.out.println("已经存在的概率 " + (1 - urlSeen.expectedFalsePositiveProbability())); } else { System.out.println("一定不存在"); } 布隆过滤器如果返回不包含某个项目,那肯定就是没往里面增加过这个项目,如果返回包含某个项目,但其实可能没有增加过这个项目,所以有误判的可能。对爬虫来说,使用布隆过滤器的后果是可能导致漏抓网页。如果想知道需要使用多少位才能降低错误概率,可以从表2-1的存储项目和位数比率估计布隆过滤器的误判率。 表2-1 布隆过滤器误判率表 比率(items:bits) 误判率 1:1 0.63212055882856 - - 获得海量数据 比率(items:bits) 误判率 1:2 0.39957640089373 1:4 0.14689159766038 1:8 0.02157714146322 1:16 0.00046557303372 1:32 0.00000021167340 1:64 0.00000000000004 为每个URL分配两个字节就可以达到千分之几的冲突。例如一个比较保守的实现,为每个URL分配了4个字节,项目和位数比是1:32,误判率是0.00000021167340。对于5000万数量级的URL,布隆过滤器只占用了200M的空间,并且排重速度超快,一遍下来不到两分钟。 SimpleBloomFilter中实现的把对象映射到位集合的方法: public void add(E element) throws UnsupportedEncodingException { long hash; String valString = element.toString(); for (int x = 0; x < k; x++) { hash = createHash(valString + Integer.toString(x)); hash = hash % (long)bitSetSize; bitset.set(Math.abs((int)hash), true); } } 这个实现方法计算了k个相互独立的散列值,因此误判率较低。 为了支持重启动后运行,把布隆过滤器的状态保存到文件中: public void saveBit(String filename) { File file = new File(filename); ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file, false)); oos.writeObject(bitSet); oos.flush(); oos.close(); } - - 获得海量数据 如下的代码把布隆过滤器的状态从先前保存的文件中读出来: public void readBit(String filename) { File file = new File(filename); if (!file.exists()) { return; } bitSet.clear(); ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file)); bitSet = (BitSet)ois.readObject(); ois.close(); } 2.5 增量抓取 专门的库保存一个网站的所有目录页的首页。看首页中是否包含新的商品信息。如果目录页全部是新的商品信息,则抓取下一页,否则不抓取下一页。 考虑用TreeSet保存同一个栏目下的所有目录页。例如,TreeSet保存目录页。到一定页面编号以后的页面就不用抓取了。TreeSet中的元素要实现Comparable接口。 public class IndexPage implements Comparable { String url; //目录页的URL地址 int pageNo; //页面编号,有翻页参数的可以直接用翻页参数 public int compareTo(IndexPage o) { return pageNo - o.pageNo; } } 2.6 并行抓取 单机并行抓取往往采用多线程同步IO或者单线程异步IO。为了同时抓取多个网站的信息,可以考虑并行抓取。 在Java中,为了爬虫稳定性,也可以用多线程来实现爬虫。一般情况下,爬虫程序需要能在后台长期稳定运行。下载网页时,经常会出现异常。有些异常无法捕获,导致爬虫程序退出。为了主程序稳定,可以把下载程序放在子线程执行,这样即使子线程因为异常退出了,但是主线程并不会退出。测试代码如下: public class MyThread extends Thread{ public void run() { - - 获得海量数据 System.out.println("Throwing in " + "MyThread"); throw new RuntimeException(); } } public class ThreadTest { public static void main(String[] args) { MyThread t = new MyThread(); t.start(); try { Thread.sleep(2000); } catch (Exception x) { System.out.println("Caught it"); } System.out.println("Exiting main"); } } 2.6.1 多线程爬虫 利用多线程进行抓取是当前搜索引擎中普遍采用的架构模式,一个多线程爬虫架构如图2-5所示: - - 获得海量数据 线程管理器 存储页面 URL Frontier URL管理器 取出URL 页面获取 提取URL加入队列 完成? 线程1 种子URL 线程N 网页库 否 图2-5 并行爬虫架构 JDK1.5以后的版本提供了一个轻量级线程池ThreadPool。可以使用线程池执行一组任务,最简单的任务不返回值给主调线程。要返回值的任务可以实现Callable接口,线程池执行任务并通过Future的实例获取返回值。 Callable是类似于Runnable的接口,在其中定义可以在线程池中执行的任务。Future表示异步计算的结果,它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。Future的cancel方法取消任务的执行,cancel方法有一个布尔参数,参数为true表示立即中断任务的执行,参数为false表示允许正在运行的任务运行完成。Future的get方法等待计算完成,获取计算结果。 下面使用ThreadPool实现并行下载网页。在继承Callable方法的任务类中下载网页: public class DownLoadCall implements Callable { private URL url; //待下载的url public DownLoadCall(URL u) { - - 获得海量数据 url = u; } @Override public String call() throws Exception { String content = null; //下载网页 return content; } } 主线程类创建ThreadPool并执行下载任务的实现如下: int threads = 4; //并发线程数量 ExecutorService es = Executors.newFixedThreadPool(threads);//创建线程池 Set> set = new HashSet>(); for (final URL url : urls) { DownLoadCall task = new DownLoadCall(url); Future future = es.submit(task);//提交下载任务 set.add(future); } //通过future对象取得结果,这一步不能省略,如果不需要返回值可以调用Runnable for (Future future : set) { String content = future.get(); //处理下载网页的结果 } 采用线程池可以充分利用多核CPU的计算能力,并且简化了多线程的实现。 2.6.2 垂直搜索的多线程爬虫 垂直行业网站一般不是太多,为了及时采集每个网站,可以每个线程抓取一个指定的网站,然后把抓取的信息直接写入到索引。对Lucene来说,同一个时刻只能由一个线程写索引,而可以多个抓取线程同时并行下载。套用线程的生产者和消费者模型,这里索引线程是消费者,抓取线程是生产者。为了简化实现,以传递整数为例。 使用BlockingQueue存储待索引的文档。take()方法会让调用的线程等待新的元素。 - - 获得海量数据 public class Indexer implements Runnable {//索引类 private BlockingQueue dataQueue; public Indexer(BlockingQueue dataQueue) { this.dataQueue = dataQueue; } @Override public void run() { Integer i; while (!Thread.interrupted()){ try { i = dataQueue.take(); System.out.println("索引:" + i); } catch (InterruptedException e) { e.printStackTrace(); } } } } public class Spider implements Runnable {//爬虫类 private BlockingQueue dataQueue; private static int i = 0; public Spider(BlockingQueue dataQueue) { this.dataQueue = dataQueue; } @Override public void run() { while (!Thread.interrupted()){ try { dataQueue.add(new Integer(++i)); System.out.println("抓取:" + i); //可以把每个线程休眠的时间根据抓取的线程数量动态调整, //如果同时抓取的线程数量多,则延长线程休眠的时间。 TimeUnit.MILLISECONDS.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); - - 获得海量数据 } } } } 测试方法如下: public static void main(String[] args) { BlockingQueue dataQueue = new LinkedBlockingQueue(); Thread pt = new Thread(new Spider(dataQueue));//爬虫线程 pt.start(); Thread ct = new Thread(new Indexer(dataQueue));//索引线程 ct.start(); } 这里的爬虫线程可以有多个,而只有一个索引线程把抓取下来的数据写入索引。如果需要结束,可以调用thread.interrupt()方法终止索引线程。 take方法会等待,而poll方法则会立即返回。下面是采用poll方法的实现。 public void run() { while (keepRunning || !documents.isEmpty()) { Document d = (Document) documents.poll(); try { if (d != null) { writer.addDocument(d); } else { //队列是空的,所以等待 Thread.sleep(sleepMilisecondOnEmpty); } } catch (ClassCastException e) { e.printStackTrace(); throw new RuntimeException(e); } catch (InterruptedException e) { e.printStackTrace(); throw new RuntimeException(e); } catch (CorruptIndexException e) { e.printStackTrace(); - - 获得海量数据 throw new RuntimeException(e); } catch (IOException e) { e.printStackTrace(); throw new RuntimeException(e); } } isRunning = false; } Logback记录爬虫运行的日志。 SiftingAppender它可以用来分割日志文件根据任何一个给定的运行参数。如,SiftingAppender能够根据爬虫的线程编号区别日志事件,然后每个线程会有一个日志文件。 SiftingAppender包含和管理多个根据判别值动态创建的appender。SiftingAppender缺省情况下使用MDC键/值对作为判别器。在配置logback后,SiftExample应用程序记录一条日志表明应用程序已经启动。然后把MDC键"spiderid"设置成为"lietu",并且记录一条消息。代码如下: logger.debug("爬虫启动"); MDC.put("spiderid", "lietu"); logger.debug("开始抓取lietu"); 配置文件如下: spiderid unknown ${spiderid}.log false - - 获得海量数据 %d [%thread] %level %mdc %logger{35} - %msg%n 2.6.3 异步IO 对单线程并行抓取来说,异步(asynchronous)I/O是很重要的基本功能。异步 I/O 模型大体上可以分为两种,反应式(Reactive)模型和前摄式(Proactive)模型。传统的 select/epoll/kqueue模型,以及 Java NIO 模型,都是典型的反应式模型,即应用代码对 I/O 描述符进行注册,然后等待 I/O 事件。当某个或某些 I/O 描述符所对应的 I/O 设备上产生 I/O 事件(可读、可写、异常等)时,系统将发出通知,于是应用便有机会进行 I/O 操作并避免阻塞。由于在反应式模型中应用代码需要根据相应的事件类型采取不同的动作,最常见的结构便是嵌套的 if {…} else {…} 或 switch,并常常需要结合状态机来完成复杂的逻辑。前摄式模型则恰恰相反。在前摄式模型中,应用代码主动地投递异步操作而不管 I/O 设备当前是否可读或可写。投递的异步 I/O 操作被系统接管,应用代码也并不阻塞在该操作上,而是指定一个回调函数并继续自己的应用逻辑。当该异步操作完成时,系统将发起通知并调用应用代码指定的回调函数。在前摄式模型中,程序逻辑由各个回调函数串联起来:异步操作A的回调发起异步操作B,B的回调再发起异步操作C,以此往复。 Java6版本开始引入的NIO包,通过Selectors提供了非阻塞式的IO。 Java7附带的NIO.2文件系统中包含了异步I/O支持。也可以使用框架实现异步I/O,例如: l Mina(http://mina.apache.org/)为开发高性能和高可用性的网络应用程序提供了非常便利的框架。当前发行的 MINA 版本支持基于Java的NIO 技术的TCP/UDP应用程序开发。MINA 是借由Java的NIO的反应式实现的模拟前摄式模型。 l Grizzly是Web服务器GlassFish 的I/O核心。Grizzly还是一个独立于GlassFish的框架结构,可以单独用来扩展和构建自己的服务器软件。Grizzly通过队列模型提供异步读/写。 l Netty(http://www.jboss.org/netty)是一个NIO客户端服务器框架。 l Naga (http://naga.googlecode.com)是一个很小的库,提供了一些Java类把普通的Socket和ServerSocket封装成支持NIO的形式。 从性能测试上比较,Netty和Grizzly都很快,而Mina稍慢一些。 JDK1.6内部并不使用线程来实现非阻塞式I/O。在Windows平台下,使用select(),在新的Linux核下,使用epoll工具。 - - 获得海量数据 Niocchi(http://www.niocchi.com)是Java实现的开源异步I/O爬虫。 在爬虫中使用NIO的时候,最主要用到的就是下面2个类: l java.nio.channels.Selector:Selector类通过调用select方法,将注册的channel中有事件发生的SelectionKey取出来进行处理。如果想要把管理权交到Selector类手中,首先就先要在Selector对象中注册相应的Chanel。 l java.nio.channels.SocketChannel:SocketChannel用于和Web服务器建立连接。 下面是使用NIO下载网页的例子,代码结构如图2-6: 创建一个新的selector选择器 创建多个新的 非阻塞连接 进入消息循环 建立一个socketchannel对象 设置该socket成非阻塞状态 注册该socket到选择器 绑定该socket到哈希 完成一个新的socket 查看是否有新的消息到达 取出该消息 调用方法processSelectionKey处理该消息 当对方关闭对应的socket的时候从选择器中删除该通道 选择器中的通道为空时退出该循环 图2-6 异步IO 使用NIO下载网页的具体实现如下: public static Selector sel = null; public static Map sc2Path = new HashMap(); public static void setConnect(String ip, String path, int port) { try { - - 获得海量数据 SocketChannel client = SocketChannel.open(); client.configureBlocking(false); client.connect(new InetSocketAddress(ip, port)); client.register(sel, SelectionKey.OP_CONNECT | SelectionKey.OP_READ | SelectionKey.OP_WRITE); // sc2Path.put(client, path); } catch (Exception e) { e.printStackTrace(); System.exit(0); } } public static void main(String args[]) { try { sel = Selector.open(); setConnect("www.lietu.com", "/index.jsp", 80); //添加一个下载网址 setConnect("hao123.com", "/book.htm", 80); //添加另一个下载网址 while (!sel.keys().isEmpty()) { if (sel.select(100) > 0) { Iterator it = sel.selectedKeys().iterator(); while (it.hasNext()) { SelectionKey key = it.next(); it.remove(); try { processSelectionKey(key); } catch (IOException e) { key.cancel(); } } } } } catch (IOException e) { e.printStackTrace(); System.exit(0); } } - - 获得海量数据 public static void processSelectionKey(SelectionKey selKey) throws IOException { SocketChannel sChannel = (SocketChannel) selKey.channel(); if (selKey.isValid() && selKey.isConnectable()) { boolean success = sChannel.finishConnect(); if (!success) { selKey.cancel(); } sendMessage(sChannel, "GET " + sc2Path.get(sChannel) + " HTTP/1.0\r\nAccept: */*\r\n\r\n"); } else if (selKey.isReadable()) { String ret = readMessage(sChannel); if (ret != null && ret.length() > 0) { System.out.println(ret); } else { selKey.cancel(); } } } //下载网页 public static String readMessage(SocketChannel client) { String result = null; ByteBuffer buf = ByteBuffer.allocate(1024); try { int i = client.read(buf); buf.flip(); if (i != -1) { result = new String(buf.array(), 0, i); } } catch (IOException e) { e.printStackTrace(); } return result; } - - 获得海量数据 //发送HTTP请求 public static boolean sendMessage(SocketChannel client, String msg) { try { ByteBuffer buf = ByteBuffer.allocate(1024); buf = ByteBuffer.wrap(msg.getBytes()); client.write(buf); } catch (IOException e) { return true; } return false; } 2.7 Web结构挖掘 有一些垃圾网页,虽然包含大量的查询词,但却并非满足用户需要的文档。网页本身的重要性在网页排序中起着很重要的作用。PageRank和HITs等算法可以根据Web结构自动学习出网页的重要性。在根据超链接生成的Web结构图中,每个节点代表一个网页。 2.7.1 存储Web图 挖掘网页之间的相互引用关系可以用来提高搜索结果排序或分析相似网页等。互联网搜索引擎需要连接服务器,能支持在网络图上快速的查询连接。典型的连接查询是哪些URL链接到一个指定的URL。为此,希望在内存中存储URL外指链接和指向该URL的链接的映射。连接查询的应用包括爬虫控制,网络图分析,复杂的抓取优化和链接分析。 可以把每个网页看成一个节点,网页间的链接关系是有向边。几百万以上的网页将会抽象成一个几百万节点以上的图,因此不能简单的使用邻接表或矩阵完全在内存中处理这样大的图,往往需要通过压缩文件来存储Web图。 因为BerkeleyDB可以用来快速高效的存储海量数据。先采用它实现一个简单的Web图。来源URL和目的URL可以定义成一个多对多的映射。 @Entity public class Link { @PrimaryKey public String fromURL; @SecondaryKey(relate=Relationship.MANY_TO_MANY) public HashSet toURL = new HashSet(); } 在WebGraph类中定义从源URL到目标URL的主索引和目标URL到源URL的二级索引。 - - 获得海量数据 private PrimaryIndex outLinkIndex; private SecondaryIndex inLinkIndex; 记录链接到WebGraph。 public void addLink (String fromLink, String toLink) throws DatabaseException { Link outLinks = new Link(); outLinks.fromURL = fromLink; outLinks.toURL = new HashSet(); outLinks.toURL.add(toLink); boolean inserted = outLinkIndex.putNoOverwrite(outLinks); if(!inserted){ outLinks = outLinkIndex.get(fromLink); outLinks.toURL.add(toLink); outLinkIndex.put(outLinks); } } 最后实现功能:取得指向一个URL地址的Link列表。 public String[] inLinks ( String URL ) throws DatabaseException { EntityIndex subIndex = inLinkIndex.subIndex(URL); String[] linkList = new String[(int)subIndex.count()]; int i=0; EntityCursor cursor = subIndex.entities(); try { for (Link entity : cursor) { linkList[i++] = entity.fromURL; } } finally { cursor.close(); } return linkList; } 假设有四十亿的网页,每个网页有十个链接到其他页面。需要32位或4个字节来指定每个链接的源和目标,要求内存的总字节数为:。 - - 获得海量数据 可以利用Web图的一些基本性质使内存的使用要求降低到10%。初看起来,这似乎是一个数据压缩的问题,对于这个问题有一些标准的解决方案。然而,我们的目标不是简单地压缩网络图以便内存能放下,还需要有效地支持连接查询。索引压缩和Web图的压缩问题类似。 假设每个网页用一个唯一的整数表示,最简单的存储方法是如果有网页->,则用一行“ ”表示。还有一种方法是对Web图建立一个邻接表,类似反向索引:每行对应一个页面,行之间用网页对应的整数排序。任何网页p的对应行包含一个排好序的整数数组,每一个整数表示对应的网页链接到p。这个表方便查询哪些网页链接到p。用类似的方式,还可以建立由包含p指向的页面的条目组成的表。这种邻接表的表现形式所占空间比最简单的表现形式减少一半。 下面的描述将集中讨论从每一个页面发出的链接表,把这些技术应用到指向每个网页的链接也是一样的。为进一步减少表的存储空间,我们利用如下几点: 1. 数组相似性:因为表中的许多行中有许多项是相同的,所以如果我们为几个相似行用一个原型行来代表,那么剩下的差异就可以根据原型行简洁的表现出来。 2. 局域性:从一个网页发出的许多链接都是到邻近的网页。例如,在同一台主机上的网页,因此建议在编码目标连接的时候,使用小的整数来节约空间。可以使用差分编码的排序数组来实现这一点。不是直接存储每一条链接的终点网页的编号,而是存储与前一项的偏移量。 现在来实现这些压缩技术。先把每个URL作为一个字符串并对这些字符串排序。图2.8显示了这些排好序了的表的一部分。对于一个网页字典排序,URL的域名部分应该被反转,因此ww.lietu.com变为com.lietu.www,但是如果主要关心的是和本地一台主机上的链接有关,就没有必要这样做。 1:www.lietu.com 2:www.lietu.com/demo 3:www.lietu.com/english 4:www.lietu.com/news 5:www.lietu.com/job 6:www.lietu.com/train 图2.8 URL字典排序 对每个URL,取得它在排序后的URL地址列表中的位置作为表示它的唯一的整数。图 - - 获得海量数据 2.9显示这样编号后的Web图的例子。在这个例子的序列中www.lietu.com/demo分配给整数2,因为它在数组中排第二。 1:1,2,4,8,16,32,64 2:1,4,9,16,25,36,49,64 3:1,2,3,5,8,13,21,34,55,89,144 4:1,4,8,16,25,36,49,64 图2.9 链接表的四行片断 下面来分析Web图的相似性和局域性。许多网站都有模板,站点的每个网页链接到一个固定的网页集合,固定的网页集合常常包括:版权说明页、网站首页、站点地图页等。在这种情况下,Web图中的相应网页的行有许多项是共同的。此外,在按照URL字典的排序下,从一个网站出来的网页及有可能作为Web图中临近的行。 我们采取引用压缩(Reference compression)策略:从上往下遍历每一行,根据前面7行来编码当前行。在图2.9的例子中,可以编码第4行和偏移量为2的行相同,只需要用8代替9。仅使用前面7行有两个有利条件: 1. 仅用3位来表示偏移量;这种选择在概率上是最优化的。 2. 固定的最大偏移量为一个像7这样的小的值,避免了在许多可能表示当前行的候选原型中选择,因为这是代价昂贵的搜索。 有时候前面7行没有一个能很好的表达当前行的原型,例如在每个不同站点的边界。这时候,简单的表示该行作为空行开始并向其中添加每一个整数。Web图的每行中,使用差分编码(gap encoding)来存储增量,而不是实际值,基于值的分布来编码这些增量,可以获得更多的空间减少。这里提到的一系列技术使平均每个链接所需空间减少到3位,而不是最简单方法的64位。 虽然这些方法能够把Web图的表示松存入内存中,但是仍然需要支持连通性查询的要求。如何查询一个网页指向哪些网页?首先,我们需要从哈希表中查询URL对应的行号。接下来,需要跟踪偏移量指示的其他行来重建这些被压缩了的项,而且,这个间接过程可能重复多次。 然而,在实践中不会经常发生这样的事情。Web图的构建过程中可以引入启发式控制:检查前面7行哪个作为当前行的原型时,当前行和候选原型之间有个相似性的阀值。必须谨慎的选择阈值,如果设置的太高,会很少使用原型并重新表示许多行。如果设置的太低,许多行将会用原型项表示,在查询时的重建速度就会变慢。 WebGraph(http://webgraph.dsi.unimi.it/)是一个开源项目,它采用了上面介绍的引用压缩技术把图压缩成BV格式。 - - 获得海量数据 下面分析BV压缩格式的使用。因为WebGraph存储的节点是整型,不是直接的URL地址,首先需要把Web网页映射成0到n的节点编号。然后形成有向边的图,例如example.arcs文件中包括(0,1,2,3,4)5个节点组成的图,这个文件的内容如下: 0 2 1 2 1 4 2 3 3 4 下面这个命令将会产生一个压缩图到bvexample文件: #java it.unimi.dsi.webgraph.BVGraph -g ArcListASCIIGraph example.arcs bvexample 生成压缩图以后我们可以统计图的出度: #java it.unimi.dsi.webgraph.examples.OutdegreeStats -g BVGraph bvexample 执行结果是: The minimum outdegree is 0, attained by node 4 The maximum outdegree is 2, attained by node 1 The average outdegree is 1.0 2.7.2 PageRank算法 为了得到更好的搜索结果,使搜索引擎自动抵制那些堆砌关键词的垃圾网页,需要计算网页本身的重要性。假设用户在随机访问互联网的每个页面。看完一个页面后,可能再通过这个页面的链出链接访问其他的网页。为了在搜索结果中更好的对网页排序,考虑把用户更有可能访问的页面排在前面。有的网页被链接的次数多,所以用户访问的可能性更大。 一个简单的方法是用一个网页的入链数量通常表示此网页的重要程度。对应的概念叫做链接人气值。一般来说,如果从其他网页链接到一个网页的数量越多,那么这个网页就越重要。链接人气值的概念通常可以避免那些只被创造出来欺骗搜索引擎并且没有任何实际意义的网页得到好的等级,然而,许多网站管理员为了一个网页取得更好的排名,他们从大量其他没有意义的网页链接到该网页。链接人气值的问题在于没有考虑入站链接本身的权威性。 与链接人气值相比较,PageRank的概念并不是简单地根据入站链接的总数评价一个网页的重要度。PageRank对入站链接做加权计算,越是重要的网页通过链接指向一个网页,则这个网页就越重要。 - - 获得海量数据 首先介绍简化版本的PageRank计算方法。假设有n个网页指向网页A,这n个网页分别是T1,…Ti,… Tn。则网页A的PageRank值计算方法是: PR(A) =PR(T1)/C(T1) + ... + PR(Tn)/C(Tn) 这里,PR(Ti)是链接到网页A的网页Ti的PageRank值;C(Ti)表示网页Ti的出度链接数量。可以把链接比做互联网中的钞票,如果某个网站的钞票发行太多,那么它的钞票就要贬值,所以这里要除以C(Ti)。 图6-6 Web图 例如图6-6中的第一个节点的PageRank值 =(ID=2发出的Rank) +(ID=3发出的Rank) +(ID=5发出的Rank) +(ID=6发出的Rank) - - 获得海量数据 = 0.166+0.141/2+0.179/4+0.045/2 = 0.30375 用矩阵形式表示PageRank计算过程。Google矩阵是一个随机矩阵用来表示一个图,图中的边表示网页之间的连接。随机矩阵的特点是每行值的和是1。 Am*n=(aij) 矩阵中的元素aij的取值方法是:如果存在从网页i指向网页j的超级链接,则aij=1/N(i),这里N(i)表示从网页i向外的链接数目。如果不存在这样的链接,则aij=0。例如图6-6 表示的Web图的Google矩阵是: 0 1/5 1/5 1/5 1/5 0 1/5 1 0 0 0 0 0 0 1/2 1/2 0 0 0 0 0 0 1/3 1/3 0 1/3 0 0 1/4 0 1/4 1/4 0 1/4 0 1/2 0 0 0 1/2 0 0 0 0 0 0 1 0 0 令 x= PageRank,M=AT,则可以通过迭代的方法计算: x= M×x 用Google矩阵计算PageRank的实现代码如下: //p是A矩阵的转置 double[][] p = Matrix.transpose(counts); //初始化pagerank向量 double[] rank = new double[N]; for (int i = 0; i < N; i++) { rank[i] = 1.0/N; //N是网页的总数,给所有的页面rank赋初始值1/N - - 获得海量数据 } int T=100; //执行乘积的T次迭代 for (int t = 0; t < T; t++) { rank = Matrix.multiply(p,rank); } 标准的PageRank算法是: PR(A) = (1-d)/N + d (PR(T1)/C(T1) + ... + PR(Tn)/C(Tn)) 这里,N表示网页总数;PR(A)表示网页A的PageRank值;PR(Ti)表示链接到A的网页Ti的PageRank值;C(Ti)表示网页Ti的出站链接数量;d是阻尼系数,取值范围是:0 0) { page.recalculatePageRank(decayFactor); } } } //输出最终结果 Iterator pageIterator = this.graph.vertexSet().iterator(); while (pageIterator.hasNext()) { RankablePage page = (RankablePage) pageIterator.next(); System.out.println(page.getUrl() + " (" + page.getPageRank()+ ")"); } } 图6.8显示了如何在搜索引擎中使用计算出来的PageRank来改进搜索结果排序。在搜索系统实际运行时,因为Web图是动态变化的,所以网页的PageRank值也是动态变化的。 - - 获得海量数据 离线 查询时 Web 索引文档() PageRank() 倒排索引 网页->Rank 查询 查询处理器 排序后的结果 图6.8在搜索引擎中使用PageRank 当把PageRank值大的网页排在前面的搜索引擎本身对用户访问网页的行为影响很大时,要减小PageRank值对于搜索结果排序的影响。因为考虑搜索引擎本身的影响后,PageRank值大的网页访问量过高,用户过多的访问了这些网页。而引入PageRank只是为了让用户更快的访问到通过多次点击后会访问到的页面。 PageRank除了可以用于评价网页的重要度,还可以评价学术论文的重要性。根据论文间的引用关系,模仿网页间的链接关系,构建论文间的引用关系图,利用PageRank的思想,计算每一篇论文的重要性。 网络爬虫可以利用PageRank值决定某个URL所需要抓取的网页数量和深度。重要性高的网页抓取的页面数量相对多一些,反之,则少一些。 用于关键词抽取:节点是词,边是词间的共现关系。即共现的词之间,判断为互相链接,这样组成一个无向图。某个词出现的次数越多,则链向它的词可能也就越多,这样的词可能就越重要。被重要的词所链向的词,也越重要。这种方法叫做WordRank。 可以把PageRank算法用于句子抽取(文本摘要):节点是句子,边是句子间的相似度。此外,还有用于文档评分的DocRank。 2.7.3 HITs算法 PageRank算法中对于向外链接的权值贡献是平均的,也就是不考虑不同链接的重要性。而有些链接具有注释性,也有些链接是起导航或广告作用。有注释性的链接才用于权威判断。 - - 获得海量数据 下面介绍的HITs算法允许链接本身带权重。HITs算法是由康奈尔大学的Jon Kleinberg博士于1998年首先提出的,HITS的英文全称为Hypertext-Induced Topic Search。 HITS算法的实现代码如下: public class HITS { /** 存储Web图的数据结构 */ private WebMemGraph graph; /** 包含每个网页的评分 */ private Map hubScores; // /** 包含每个网页的Authority */ private Map authorityScores;// /** *构造函数 */ public HITS ( WebGraph graph ) { this.graph = graph; this.hubScores = new HashMap(); this.authorityScores = new HashMap(); int numLinks = graph.numNodes(); for(int i=1; i<=numLinks; i++) { hubScores.put(new Integer(i),new Double(1)); authorityScores.put(new Integer(i),new Double(1)); } computeHITS(); } /** * 计算网页的 Hub 和 Authority 值 */ public void computeHITS() { computeHITS(25); } /** - - 获得海量数据 *计算网页的 Hub 和 Authority值 */ public void computeHITS(int numIterations) { while(numIterations-->0 ) { for (int i = 1; i <= graph.numNodes(); i++) { Map inlinks = graph.inLinks(new Integer(i)); Map outlinks = graph.outLinks(new Integer(i)); double authorityScore = 0; double hubScore = 0; for (Integer id:inlinks.keySet()) { authorityScore += (hubScores.get(id)).doubleValue(); } for (Integer id:outlinks.keySet()) { hubScore += (authorityScores.get(id)).doubleValue(); } authorityScores.put(new Integer(i),new Double(authorityScore)); hubScores.put(new Integer(i),new Double(hubScore)); } normalize(authorityScores); normalize(hubScores); } } public void computeWeightedHITS(int numIterations) { while(numIterations-->0 ) { for (int i = 1; i <= graph.numNodes(); i++) { Map inlinks = graph.inLinks(new Integer(i)); Map outlinks = graph.outLinks(new Integer(i)); double authorityScore = 0; double hubScore = 0; for (Entry in:inlinks.entrySet()) { authorityScore += (hubScores.get(in.getKey())).doubleValue() * in.getValue(); } - - 获得海量数据 for (Entry out:outlinks.entrySet()) { hubScore += (authorityScores.get(out.getKey())).doubleValue() * out.getValue(); } authorityScores.put(new Integer(i),new Double(authorityScore)); hubScores.put(new Integer(i),new Double(hubScore)); } normalize(authorityScores); normalize(hubScores); } } /** * 归一化集合 */ private void normalize(Map scoreSet) { Iterator iter = scoreSet.keySet().iterator(); double summation = 0.0; while (iter.hasNext()) summation += ((scoreSet.get((Integer)(iter.next())))).doubleValue(); iter = scoreSet.keySet().iterator(); while (iter.hasNext()) { Integer id = iter.next(); scoreSet.put(id , (scoreSet.get(id)).doubleValue()/summation); } } /** * 返回与给定链接关联的Hub 值 */ public Double hubScore(String link) { return hubScore(graph.URLToIdentifyer(link)); } /** - - 获得海量数据 *返回与给定链接关联的Hub 值 */ private Double hubScore(Integer id) { return (Double)(hubScores.get(id)); } /** *初始化与给定链接关联的Hub 值 */ public void initializeHubScore(String link, double value) { Integer id = graph.URLToIdentifyer(link); if(id!=null) hubScores.put(id,new Double(value)); } /** *初始化与给定链接关联的Hub 值 */ public void initializeHubScore(Integer id, double value) { if(id!=null) hubScores.put(id,new Double(value)); } /** *返回与给定链接关联的 Authority 值 */ public Double authorityScore(String link) { return authorityScore(graph.URLToIdentifyer(link)); } /** *返回与给定链接关联的 Authority 值 */ private Double authorityScore(Integer id) { return (Double)(authorityScores.get(id)); } /** *初始化与给定链接关联的 Authority 值 - - 获得海量数据 */ public void initializeAuthorityScore(String link, double value) { Integer id = graph.URLToIdentifyer(link); if(id!=null) authorityScores.put(id,new Double(value)); } /** *初始化与给定链接关联的 Authority 值 */ public void initializeAuthorityScore(Integer id, double value) { if(id!=null) authorityScores.put(id,new Double(value)); } } 2.7.4 主题相关的PageRank 如果不考虑出现在页面中或者指向该网页的锚点文本中的关键词,某个领域重要的页面可能在其他领域不重要。因此产生了主题相关的PageRank(Topic-Sensitive PageRank)的想法。 在基本的PageRank 算法中,使用Web链接结构计算出一个PageRank向量(向量的长度是网页的数量)来获取相对重要的网页。网页的重要性不依赖于任何特定的搜索查询词。为了达到更准确的搜索结果,可以基于一个话题集合计算一组PageRank向量来更精确的捕捉和一个特定主题相关的重要性的概念。通过使用这些预计算的有偏见的PageRank向量,在查询时生成和查询相关的每个网页的重要性分值。这样可以比一个单一的通用PageRank向量生成更准确的打分。使用查询关键词的话题为满足查询词的页面计算出话题相关的PageRank分值。 主题相关的PageRank结构如图6.9所示。其中需要用到已经把网站分好类的目录Yahoo!或ODP。 - - 获得海量数据 离线 查询时 Web 索引文档() TSPageRank() 倒排索引 网页->Rank 查询 查询处理器 排序后的结果 Yahoo! 或ODP 分类器 上下文 图6.9在搜索引擎中使用主题相关的PageRank 改动主要在两个阶段:主题相关的PageRank向量集合的计算和在线查询时主题的确定。首先使用一个有偏见的话题集生成一个PageRank向量的集合。这一步是在爬虫抓取网页时离线计算的。计算网页i在第j个类别上的PageRank值时,使用阻尼系数: 这里的Tj是ODP类别cj下的URL地址的集合。 在线查询时,对每个类别cj,计算: 为了计算P(cj),对某个用户k,可以使用分布Pk(cj)来反映用户k的兴趣。 最后,对于在搜索结果集中的网页d计算相关性分值: - - 获得海量数据 这里,rankjd是网页d对于话题cj的PageRank值。 2.8 部署爬虫 为了抓取更加稳定,往往让爬虫运行在一个独立的控制台进程中。可以在MANIFEST.MF文件中声明要运行的类。例如假设com.lietu.crawler.Spider包含main方法。 Manifest-Version: 1.0 Sealed: true Main-Class: com.lietu.crawler.Spider Class-Path: nekohtml.jar lucene-core-3.0.2.jar . 其中的Class-Path声明了依赖的jar包,最后的点代表当前路径。 通过Ant执行的build.xml来自动生成可执行的jar包。Ant通过调用target树,就可以执行各种task。每个task实现了特定接口对象。由于Ant构建文件是XML格式的文件,所以很容易维护和书写,而且结构很清晰。Ant可以集成到开发环境中。由于Ant的跨平台性和操作简单的特点,Eclipse已经集成了Ant。 crawler.jar包 里面要正好包含有用的class文件,既不能包含测试部分代码,也不能包含源文件。如果Java源代码文件编码不一致可能会出错,可以把编码统一成GBK或者UTF-8。 完成打包后,执行crawler.jar。 java -jar crawler.jar 安装lrzsz之后可以使用rz上传文件: #yum install lrzsz 运行spider.sh # cat ./spider.sh - - 获得海量数据 nohup java -Xmx1000m -jar crawler.jar & #chmod +x ./spider.sh 2.9 本章小结 有人说:知识就是力量,爬虫就是自动获取知识的第一步。在互连网出现以前,大量收集信息是很困难的事情。公元前的埃及托勒密一世有一个梦想:要把世界上所有的文献、所有值得记下来的东西全部收藏。托勒密一世和他的后代托勒密二世、托勒密三世在港口城市亚历山大兴建图书馆,雇人抄写图书。所有到亚历山大港的船只都要把携带的书交出供检验,如发现有图书馆没有的书,则马上抄录,留下原件,将复制件奉还原主。还有重金收购等方法,建立起当时世界上最大的图书馆。在这个图书馆建立以前,知识在很大程度上是区域性的,但自建成第一座世界性图书馆后,知识也随之成为世界性的了。 没做过爬虫,可能以为爬虫只是一个广度有限遍历的应用。做过爬虫的可能就不这么想了。本章首先熟悉了网络爬虫工作的基本原理。然后了解抓取网页的实现。网络爬虫在某种程度上可以看成是一个HTTP客户端应用。首先介绍和网站打交道所使用的HTTP协议以及TCP/IP协议。然后用最简单的方法下载网页。再介绍实际下载网页所使用的HTTPClient。 除了网页这样的文本信息,还有图片、FTP等。介绍了比普通网页更规范的RSS种子下载方法。对于网页中的JavaScript的处理是难点问题。此外,还介绍了Web图的存取和挖掘算法。 可以用布隆过滤器或者BerkeleyDB实现URL地址查新。还可以使用BerkeleyDB实现网页快照功能,根据URL地址查询网页原文。 接下来简单回顾下网络爬虫的开发历史: 1994年,休斯敦大学的Eichmann(http://slis.uiowa.edu/eichmann/index.shtml)在美国航空航天局的资助下开发了互联网爬虫RBSE(Repository Based Software Engineering)。RBSE将爬虫和索引程序分离,这样不需要重新抓取就可以更新索引。爬虫执行宽度优先遍历或者有限深度优先的遍历。Spider程序创建和操作存储在Oracle数据库中的Web图。一个修改后的ASCII字符浏览器(mite)根据URL下载指定的网页存储到本地文件,并把这个网页中的链接提取出来。 1994年,华盛顿大学计算机系的学生Pinkerton开发了分布式爬虫WebCrawler。运行在多个机器上的爬虫代理(Agent)用HTTP协议下载网页并把HTML格式的网页解析成纯文本。WebCrawler使用简单的广度优先遍历方法抓取部分互联网中的网页。因为早期开源数据库不稳定,为了避免数据库服务器崩溃,它使用商业的Oracle数据库作为存储核心。1997年Excite收购了WebCrawler。Pinkerton后来担任为Lucene和Solr提供商业服务的Lucid Imagination公司的首席架构师。 1998年,斯坦福大学的学生Brin和Page用Python开发了分布式爬虫系统Google Crawler。URL服务器给几个爬虫程序发送要抓取的URL列表。在解析文本的时候,把新发现的 URL传送给URL服务器并检测这个URL是不是已经存在,如果不存在的话,就把该URL加入到URL服务器。爬虫程序使用了DNS缓存来提高DNS查询效率。 - - 获得海量数据 1999年,Compaq公司的Heydon和Najork开发了Mercator。C语言的网络IO速度比Java快,因此直到今天很多在线运行的爬虫采用C或C++开发,但Mercator却是一个用Java实现的成功的网络爬虫。Mercator是分布式的、模块化的。Mercator用做Alta Vista搜索引擎的网络爬虫。Mercator的模块包括可互换的的“协议模块”和“处理模块”。“协议模块”负责怎样获取网页(例如使用HTTP协议),“处理模块”负责怎样处理页面。标准处理模块仅仅包括了解析页面和抽取新的URL,可以用其他处理模块来索引网页中的文本,或者搜集Web统计数据。Mercator使用64位的文档语义指纹来判断网页内容是否重复。因为布隆过滤器存在误判的情况,Mercator没有采用Internet Archive爬虫中曾经采用的布隆过滤器。Mercator用带缓存的checksum来判断URL是否抓取过。Najork后来加入微软研究院,担任研究员。 2001年,IBM的Almaden研究中心的Edwards等人开发了一个与Mercator类似的分布式的模块化的爬虫WebFountain。它的特点是一个“控制者”机器控制一系列的“蚂蚁”机器,可以实现增量抓取。经过多次下载页面后,可以推测出每个页面的变化率,然后可以获得一个最大的新鲜度的访问策略。商业信息网站Factiva使用WebFountain收集企业信用信息。WebFountain是使用C++实现的。 2002年,FAST公司的Risvik和Michelsen开发了分布式爬虫FAST Crawler,在Fast Search&Transfer和www.AllTheWeb.com网站使用。节点之间通过distributor模块交换发现的链接。每个机器有一个“文档调度器”来维护一个要下载的文档队列。“文档处理器”下载完网页后把网页存储在本地存储子系统。FAST Crawler实现了增量式抓取,优先抓更新活跃的网页。2004年,Yahoo收购AllTheWeb网站。Risvik后来在Yahoo和Google工作过。 Cho和Garcia-Molina在论文"Parallel Crawlers"研究了如何设计有效的并行爬虫,在论文"Effective Page Refresh Policies For Web Crawlers"研究了网页更新方法。 2003年初,为了对网上的资源进行归档,建立网络数字图书馆,开发了开源网络爬虫Heritrix。这个系统以运行时高可配置性的模块式开发,所以非常适合做实验。 早期的互联网搜索研究中有很多是关于主题爬虫的。Menczer和Belew在论文“Adaptive information agents in distributed textual environments”中设想了一种为个人用户服务的,由自主软件代理的主题爬虫。用户同时输入网址以及关键字,代理就会尝试寻找对用户有用的网页,而且用户也可以对这些网页进行评估并反馈给系统。 Chakrabarti在论文“Focused crawling: a new approach to topic-specific web resource discovery”中主要研究了主题爬虫,他们的爬虫使用分类来判断抓取的网页的主题。他们认为对于提供主题链接方面来说,非主题爬虫是无法与主题爬虫相比较。主题爬虫可以成功截获主题,广泛的网络连接结构却使非主题爬虫迅速地移动到其他主题上。 Unicode 规范是一项难以置信的复杂的工作,包含了上万的字符。因为一些非西方语言特性的原因,许多象形文字都是一组Unicode字符组成的。所以这个规范的细节不仅说明这些字是什么,而且解释他们是如何组合的。新的字符仍然在源源不断地加入到Unicode。 - - 获得海量数据 Bergman的论文"The Deep Web: Surfacing Hidden Value"是对深网的一个深入的研究。尽管这项研究和Web标准一样的古老。它向我们展示了如何通过搜索引擎进行的抽样才能对在网络中的量指数挂钩有帮助。这个研究估算了一下大概有5500亿个网页存在于深网中,而与此同时却只有10亿个网页出现在可见网络中。He在一个更近期的研究调查表明,深网近几年一直在持续地迅速地发展状态中。于是一种由Ipeirotis与Gravano所提出来的叫做查询探测的用于对深网的数据库进行探测分析的模型就出现了。抓取暗网可以参考一些Web应用自动测试工具。 网站地图、robots.txt文件、RSS feeds和Atom feeds都有它们自己的规范。这些格式说明那些成功的Web标准一般都是很简单的。 对于一些应用程序来说,可以用数据库系统来存储爬虫下载的文件。这方面有一些教科书,例如Garcia-Molina的著作《Database Systems: The Complete Book》提供了许多数据库如何运行的信息,也包含一些重要功能,例如查询语句、锁、恢复等。Chang在论文“Bigtable:一个结构化数据的分布式存储系统”描述了Bigtable。 另外大型互联网公司出于类似的目的:大规模分布、高吞吐量、不需要昂贵的查询语句或者详细的事务支持,纷纷建立了自己的数据库系统。DeCandia在论文“Dynamo: Amazon’s Highly Available Key-value Store”描述的亚马逊公司的Dynamo系统拥有低延迟的保证。Yahoo! 使用UDB系统处理巨大的数据量。 DEFLATE和LZW是2个文本压缩工具。DEFLATE是现在流行的Zip、gzip和zlib这些压缩工具的基础,LZW是Unix压缩命令的基础,同样也适用于GIF,Postscript,以及PDF等文件形式。Witten写的《Managing Gigabytes: Compressing and Indexing Documents and Images》提供了一些关于文本和图片压缩算法的详细讨论。 Yu的论文“Improving Pseudo-Relevance Feedback in Web. Information Retrieval Using Web Page Segmentation”和Gupta的论文“DOM-based Content Extraction of HTML Documents”是对从网页中提取正文非常有用的文献。关于基于内容的网页排重将在后续章节介绍。 - - 获得海量数据 第3章 索引内容提取 搜索引擎经常要处理的文档格式包括HTML、Word、PDF等。这些文档格式中如Word和PDF是专有和非公开的格式,HTML虽然是公开的标准,但是具体的实现却千差万别。而且这些文档格式往往存在不同的版本,比如Word包括doc和docx格式,PDF有从1.0到1.7及其扩展版等9种不同的格式。 3.1 从HTML文件中提取文本 从HTML提取有效的文本,首先需要判断网页编码,这样才能确保不出现乱码。然后还需要从中提取需要的信息,因为很多网页中包括对用户不想要搜索的信息,例如广告、版权信息、导航条等。可以把网页保存成本地的文本文件,然后再开发提取信息的程序,这样开发的时候就不用担心网速不稳定。 有很多种方法实现网页信息提取,一般可以分为两种类型:一种是针对特定的网页特征提取结构化信息;还有一种就是通用的信息提取方法,例如网页去噪。下面首先介绍字符集编码以及判断网页编码的的基本过程,然后介绍网页信息提取所用到的一些工具和方法,例如HTMLParser和NekoHTML。网页提取的正确率是正确提取的文档数量除以测试集中的文档总数。 3.1.1 字符集编码 ASCII码(American Standard Code for Information Interchange,美国信息交换标准码)是目前计算机中用得最广泛的字符集及其编码,由美国国家标准局(ANSI)制定。它已被国际标准化组织(ISO)定为国际标准,称为ISO 646标准。ASCII字符集由控制字符和图形字符组成。 在计算机的存储单元中,一个ASCII码值占一个字节(8个二进制位),其最高位(b7)用作奇偶校验位。所谓奇偶校验,是指在代码传送过程中用来检验是否出现错误的一种方法,一般分奇校验和偶校验两种。奇校验规定:正确的代码一个字节中1的个数必须是奇数,若非奇数,则在最高位b7添1。偶校验规定:正确的代码一个字节中1的个数必须是偶数,若非偶数,则在最高位b7添1。 ISO 8859,全称ISO/IEC 8859,是国际标准化组织(ISO)及国际电工委员会(IEC)联合制定的一系列8位字符集的标准,已经定义了15个字符集。 ASCII收录了空格及94个“可印刷字符”,足够英语使用。但是,其他使用拉丁字母的语言(主要是欧洲国家的语言),都有一定数量的变音字母,故可以使用ASCII及控制字符以外的区域来储存及表示。 - - 获得海量数据 除了使用拉丁字母的语言外,使用西里尔字母的东欧语言、希腊语、泰语、现代阿拉伯语、希伯来语等,都可以使用这个形式来储存及表示。 l ISO 8859-1 (Latin-1) - 西欧语言。 l ISO 8859-2 (Latin-2) - 中欧语言。 l ISO 8859-3 (Latin-3) - 南欧语言。世界语也可用此字符集显示。 l ISO 8859-4 (Latin-4) - 北欧语言。 l ISO 8859-5 (Cyrillic) - 斯拉夫语言。 l ISO 8859-6 (Arabic) - 阿拉伯语。 l ISO 8859-7 (Greek) - 希腊语。 l ISO 8859-8 (Hebrew) - 希伯来语(视觉顺序)。 l ISO 8859-8-I - 希伯来语(逻辑顺序)。 l ISO 8859-9 (Latin-5 或 Turkish) - 它把Latin-1的冰岛语字母换走,加入土耳其语字母。 l ISO 8859-10 (Latin-6 或 Nordic) - 北日耳曼语支,用来代替Latin-4。 l ISO 8859-11 (Thai) - 泰语,从泰国的 TIS620 标准字集演化而来。 l ISO 8859-13 (Latin-7 或 Baltic Rim) - 波罗的语族。 l ISO 8859-14 (Latin-8 或 Celtic) - 凯尔特语族。 l ISO 8859-15 (Latin-9) - 西欧语言,加入Latin-1欠缺的法语及芬兰语重音字母,以及欧元符号。 l ISO 8859-16 (Latin-10) - 东南欧语言。主要供罗马尼亚语使用,并加入欧元符号。 很明显,ISO8859-1编码表示的字符范围很窄,无法表示中文字符。但是,由于是单字节编码,和计算机最基础的表示单位一致,所以很多时候,仍旧使用ISO8859-1编码来表示。而且在很多协议上,默认使用该编码。 通用字符集(Universal Character Set,UCS)是由ISO制定的ISO 10646(或称ISO/IEC 10646)标准所定义的字符编码方式,采用4字节编码。 UCS包含了已知语言的所有字符。除了拉丁语、希腊语、斯拉夫语、希伯来语、阿拉伯语、亚美尼亚语、格鲁吉亚语,还包括中文、日文、韩文这样的象形文字,UCS还包括大量的图形、印刷、数学、科学符号。 - - 获得海量数据 l UCS-2:与Unicode的2字节编码基本一样。 l UCS-4:4字节编码,目前是在UCS-2前加上2个全零的字节。 Unicode是一种在计算机上使用的字符编码。它是http://www.unicode.org制定的编码机制,要将全世界常用文字都函括进去。Unicode为每种语言中的每个字符设定了统一并且唯一的二进制编码,以满足跨语言、跨平台进行文本转换、处理的要求。1990年开始研发,1994年正式公布。随着计算机计算能力的增强,Unicode也在面世以来的十多年里得到普及。 但自从Unicode2.0开始,Unicode采用了与ISO 10646-1相同的字库和字码,ISO也承诺ISO10646将不会给超出0x10FFFF的UCS-4编码赋值,使得两者保持一致。 Unicode的编码方式与ISO 10646的通用字符集(Universal Character Set,UCS)概念相对应,目前的用于实用的Unicode版本对应于UCS-2,使用16位的编码空间。也就是每个字符占用2个字节,基本满足各种语言的使用。实际上目前版本的Unicode尚未填充满这16位编码,保留了大量空间作为特殊使用或将来扩展。 UTF是Unicode 的实现方式,不同于编码方式。一个字符的Unicode编码是确定的,但是在实际传输过程中,由于不同系统平台的设计不一定一致,以及出于节省空间的目的,对Unicode编码的实现方式有所不同。Unicode的实现方式称为Unicode转换格式(Unicode Translation Format,简称为 UTF)。UTF的两种实现方式是: l UTF-8: 8位变长编码,对于大多数常用字符集(ASCII中0~127字符)它只使用单字节,而对其它常用字符(特别是朝鲜和汉语会意文字),它使用3字节。 l UTF-16: 16位编码,是变长码,大致相当于20位编码,值在0到0x10FFFF之间,基本上就是Unicode编码的实现,与CPU字序有关。 汉字编码有如下4种: l GB2312字集是简体字集,全称为GB2312(80)字集,共包括国标简体汉字6763个。 l BIG5字集是台湾繁体字集,共包括国标繁体汉字13053个。 l GBK字集是简繁字集,包括了GB字集、BIG5字集和一些符号,共包括21003个字符。 l GB18030是国家制定的一个强制性大字集标准,全称为GB18030-2000,它的推出使汉字集有了一个“大一统”的标准。 在Windows系统中保存文本文件时通常可以选择编码为ANSI、Unicode、Unicode big endian和UTF-8,这里的ANSI和Unicode big endia是什么编码呢? - - 获得海量数据 ANSI使用2个字节来代表一个字符的各种汉字延伸编码方式,称为ANSI编码。在简体中文系统下,ANSI编码代表GB2312编码,在日文操作系统下,ANSI编码代表JIS编码。 UTF-8以字节为编码单元,没有字节序的问题。UTF-16以两个字节为编码单元,在解释一个UTF-16文本前,首先要弄清楚每个编码单元的字节序。 Unicode规范中推荐的标记字节顺序的方法是BOM(Byte Order Mark)。在UCS编码中有一个叫做"ZERO WIDTH NO-BREAK SPACE"的标记,它的编码是FEFF。而FFFE在UCS中是不存在的字符,所以不应该出现在实际数据中。UCS规范建议在传输字节流前,先传输标记"ZERO WIDTH NO-BREAK SPACE"。这样如果接收者收到FEFF,就表明这个字节流是Big-Endian的;如果收到FFFE,就表明这个字节流是Little-Endian的。因此标记"ZERO WIDTH NO-BREAK SPACE"又被称作BOM。Windows就是使用BOM来标记文本文件的编码方式的。 3.1.2 识别网页的编码 在实现从Web网页提取文本之前,首先要识别网页的编码,有时候还需要进一步识别网页所使用的语言。因为同一种编码可能对应多种语言,例如UTF-8编码可能对应英文或中文等任何语言。识别编码整体流程如下: 1. 从Web服务器返回的content type 头信息中提取编码,如果是GB2312类型的编码要当成GBK处理。 2. 从网页的Meta标签中识别字符编码,如果和content type中的编码不一致,以Meta中声明的编码为准。 3. 如果仍然无法确定网页所使用的字符集,需要从返回流的二进制格式判断。 4. 确定网页所使用的语言,往往采用统计的方法来估计网页的语言。 从Web服务器返回的头信息中提取网页编码的代码如下。 final String CHARSET_STRING = "charset"; //输入头信息,返回网页编码 public static String getCharset (String content){ int index; String ret = null; if (null != content) { index = content.indexOf (CHARSET_STRING); if (index != -1) { content = - - 获得海量数据 content.substring (index + CHARSET_STRING.length ()).trim (); if (content.startsWith ("=")) { content = content.substring (1).trim (); index = content.indexOf (";"); if (index != -1) content = content.substring (0, index); //从字符串开始和结尾处删除双引号 if (content.startsWith ("\"") && content.endsWith ("\"") && (1 < content.length ())) content = content.substring (1, content.length () - 1); //删除围绕字符串的任何单引号 if (content.startsWith ("'") && content.endsWith ("'") && (1 < content.length ())) content = content.substring (1, content.length () - 1); ret = findCharset (content, ret); } } } return (ret); } 有的页面在头信息中不包括编码格式内容,需要从网页内部的meta标签中提取编码。例如下面这个: 下面利用HTMLParser包提取网页中的meta信息,关于HTMLParser将在后面的小节详细介绍。 String contentCharSet = tagNode.getAttribute("CONTENT"); 另外一个和字符编码相关的问题是,有时候碰到GB2312编码的网页会有乱码问题,因为浏览器能正常显示包含GBK字符的GB2312编码网页。把org.htmlparser.lexer.InputStreamSource中的设置编码方法修改一下,把设置字符集为GB2312改成GBK: - - 获得海量数据 public void setEncoding (String character_set){ if(character_set!= null && character_set.toLowerCase().equals("gb2312")){ character_set = "GBK"; } … JuniversalCharDet(http://code.google.com/p/juniversalchardet/)可以根据读入的字节流自动猜测页面或文件使用的字符集。实现原理是基于统计学的字符特征分析,统计哪些字符是最常见的字符。JuniversalCharDet可以检测的字符编码有:中文、日文、韩文、西里尔文(Cyrillic)、希腊文、希伯来文。下面的代码调用JuniversalCharDet来自动判断文件的编码。 byte[] buf = new byte[4096]; String fileName = args[0]; java.io.FileInputStream fis = new java.io.FileInputStream(fileName); //构建一个org.mozilla.universalchardet.UniversalDetector对象的实例 UniversalDetector detector = new UniversalDetector(null); int nread; while ((nread = fis.read(buf)) > 0 && !detector.isDone()) { detector.handleData(buf, 0, nread);//给编码检测器提供数据 } //通知编码检测器数据已经结束 detector.dataEnd(); //取得检测出的编码名 String encoding = detector.getDetectedCharset(); if (encoding != null) { System.out.println("Detected encoding = " + encoding); } else { System.out.println("No encoding detected."); } //在再次使用编码检测器之前,先调用UniversalDetector.reset() detector.reset(); 此外还有Jchardet和cpdetector。Jchardet是基于比较老的chardet模块,而JuniversalCharDet基于新的UniversalCharDet模块,因此检测结果更准确。 - - 获得海量数据 3.1.3 网页编码转换为字符串编码 网页中的字符可能被转义成英文字符表示,例如"网站导航"是“网站导航”的转义。这类符号以“&”开始,以“;”结束。http://commons.apache.org项目中的StringEscapeUtils.unescapeHtml(String str)可以把HTML编码的字符串转换成原文。下面的代码把输入的转义后的字符串转换成原文。 String htmlStr = "网站导航"; String textStr = Entities.HTML40.unescape(htmlStr); 3.1.4 使用正则表达式提取数据 正则表达式可以定义一些概率无关的提取模式。java.util.regex包提供了对正则表达式的支持。其中的Pattern类代表一个编译后的正则表达式。通过Matcher类根据给定的模式查找输入字符串。通过调用Pattern对象的matcher方法得到一个Matcher对象。使用正则表达式提取字符串的例子如下: String example = "This is my small example string which I'm going to use for pattern matching."; Pattern pattern = Pattern.compile("\\w+"); Matcher matcher = pattern.matcher(example); // 检查所有的出现 while (matcher.find()) { System.out.print("开始位置: " + matcher.start()); System.out.print(" 结束位置: " + matcher.end() + " "); System.out.println(matcher.group()); } 下面的例子提取网页中的链接: String pageContents = "
猎兔"; Pattern p = Pattern.compile("]", Pattern.CASE_INSENSITIVE); //忽略大小写 Matcher m = p.matcher(pageContents); while (m.find()) {//打印网页中所有的链接 String link = m.group(1).trim(); System.out.println(link); } 有些链接的形式是: 猎兔 - - 获得海量数据 为了更好的匹配单引号,可以把模式修改成: "]" 例如“2009-12-6”这样的格式用正则表达式匹配如下: Pattern p = Pattern.compile("\\d{2,4}-\\d{1,2}-\\d{1,2}"); Matcher m = p.matcher(inputStr); if(m.find()){ String strDate = m.group(); } 其他的一些匹配日期的正则表达式有:“\\d{2,4}/\\d{1,2}/\\d{1,2}”和“\\d{2,4}年\\d{1,2}月\\d{1,2}日”以及“\\d{2,4}\\.\\d{1,2}\\.\\d{2,4}”。 图3-1 地址页面 图3-1是一个包含地址信息的网页。利用HTMLParser解析包可以方便的解析网页,提取想要的网页信息,但是在有些情况下利用正则表达式可能更方便(如果对正则表达式不熟悉请参考相关Java 正则表达式教程)。同样以上述网页为例,利用正则表达式提取网页表格数据如下: 首先,根据要提取的网页信息查看网页源代码,这里我们提取的是网页表格中地名相关的信息。查看网页源码如下:
(.+?)" 在这里要注意“(.+?)”用法,括号在这里表示分组,即每一个数据项表示一组,“?”表示非贪懒匹配,仅匹配到紧跟其后“”为止。 其次,根据读取的网页字符流和正则表达式提取表格信息,其相关代码如下: //输入参数input是当前网页的内容字符串 public static void parserHtml(String input) { String regex = ""; Pattern p = Pattern.compile(regex); //编译正则表达式 Matcher m = p.matcher(input); //匹配模式 while (m.find()) {//按行提取表格数据 String province = m.group(1); String area = m.group(2); String city= m.group(3); String village = m.group(4); String postcode = m.group(5); String code = m.group(6); System.out.println(province);//打印出山西 System.out.println(area);//打印出朔州 System.out.println(city);//打印出怀仁县 System.out.println(villag);//打印出鹅毛口 System.out.println(postcode); //打印出038301 System.out.println(code); //打印出0349 } } - - 获得海量数据 3.1.5 使用HTMLParser实现定向抓取 正则表达式中按字符处理网页源代码,但网页源代码是按标签组织的。有专门的程序包可以把源代码字符串转换成一个一个的标签,例如HTMLParser。 HTMLParser(http://htmlparser.sourceforge.net/)是一个良好实现的提取HTML文件解析程序库。它的主要功能是对纯HTML文档和内嵌的HTML进行语法分析。可以使用它完成对非规范的HTML文件解析。HTMLParser主要包括两个类:在Lexer类中实现了对HTML语法的基本解析,也就是把页面中的字符组装成标签并封装成节点(Node)对象;在Parser类中实现了对节点的过滤和选择。 为了调用HTMLParser,需要添加htmllexer.jar或者htmlParser.jar到项目的类路径中。htmllexer.jar提供一个底层接口,可以通过线性,平坦且连续的方式遍历节点。htmlparser.jar能够提供一系列嵌套标签节点,例如字符串,备注和其他标记节点。 虽然可以由一个URL对象直接构造Lexer对象,但是为了避免读取页面的乱码问题,可以直接由包含HTML页面内容的字符串构造Lexer对象: //用HTML页面内容做参数 Lexer le=new Lexer(contentStr); //生成一个网页解析器 Parser parser=new Parser(le); HTMLParser将网页转换成一个个串联的Node。例如下面把一个URL指定的网页中的标签都打印出来。 Lexer lexer = new Lexer (url.openConnection ()); Node node; while (null != (node = lexer.nextNode()))//取得下一个节点 System.out.println (node.toString()); 在上面这个循环中,每次调用Lexer的nextNode()方法返回下一个Node节点,直到没有节点的时候返回空值。 各种不同的HTML标签则用不同的Node类型来区分。例如超链接标签是LinkTag,而图像标签则是ImageTag,这些都是Node的子类。可以通过getTagName()判断标签的类型。注意,返回的标签名字都是大写的。 Node有三个直接的子类,分别是: l RemarkNode:代表HTML中的注释。 l TagNode:标签节点,TagNode有很多继承的子类,是种类最多的节点类型,例如LinkTag和ImageTag等具体的节点类都是TagNode的实现。TagNode经常有各种各样的属性,可以通过 - - 获得海量数据 getAttribute方法得到属性值。例如通过ImageTag.getAttribute("src")取得图像节点中的图像地址。 l TextNode:文本节点。 例如:“广告业务”由下面三个Node组成: l 是TagNode。 l “广告业务”是TextNode。 l 也是TagNode,不过它是结束标签,它的isEndTag()是真。 再如: 这整段就是一个Remark Node。 Node RemarkNode TextNode TagNode LinkTag ImageTag DivTag 节点类型继承关系图 有的网址位于iframe标签中,这样可以把几个网址中的内容同时在浏览器中呈现。例如: 为了追踪抓取嵌套在iframe标签中的网页,通过遍历节点寻找iframe中的URL地址: String path = "http://tianya.cn"; URL urlPage = new URL(path); Lexer lexer = new Lexer(urlPage.openConnection()); // 解析网页 Node node; while (null != (node = lexer.nextNode())) { if (!(node instanceof TagNode)) {// 判断node是否TagNode类型的节点 continue; } TagNode tagNode = (TagNode) node; // 强制转换类型 String name = tagNode.getTagName();// 根据名字判断是否IFRAME if (name.equals("IFRAME") && !tagNode.isEndTag()) { String newURL = tagNode.getAttribute("src"); URL u = new URL(new URL(path), newURL);// 取得URL的绝对地址 System.out.println(u.toString()); } } 下面的例子读入并通过Lexer分析本地硬盘文件中的HTML页面。 //读入HTML文件中的内容 File htmlFile = new File("D:/content.htm"); Scanner scanner = new Scanner(htmlFile ,"GBK"); scanner.useDelimiter("\\z"); StringBuilder buffer = new StringBuilder(); while (scanner.hasNext()){ buffer.append(scanner.next()); } scanner.close(); //分析HTML中的内容 Node node; Lexer lexer = new Lexer(buffer.toString()); lexer.setNodeFactory(new PrototypicalNodeFactory ());//设置要分析的节点类型 - - 获得海量数据 while (null != (node = lexer.nextNode ())){ //打印Node节点类型 System.out.println(node.getClass().toString()); } Lexer可以识别的Node类型在NodeFactory的子类中定义,PrototypicalNodeFactory是HTMLParser自带的工厂类,通过setNodeFactory方法设置Node工厂类: lexer.setNodeFactory(new PrototypicalNodeFactory ()); 缺省情况下HTMLParser所有能够识别的HTML标签都在PrototypicalNodeFactory中注册对应的Node类型。可以通过在PrototypicalNodeFactory注册新的TagNode来让HTMLParser识别新的Node。比如为了单独识别Strong标签,可以先新建一个Strong类。 public class Strong extends CompositeTag{ private static final String[] mIds = new String[] {"STRONG"}; //标签名称 public Strong () { } public String[] getIds () { return (mIds); } } 然后在 Node工厂类中注册这个类: PrototypicalNodeFactory factory = new PrototypicalNodeFactory (); factory.registerTag (new Strong()); 使用Parser类可以提取出符合特定条件的Node的集合。以提取出网页中所有的“”标签为例。Parser类的方法extractAllNodesThatAre(LinkTag.class)将HTML内容中存在的所有的标签LinkTag给解析出来放到一个Node数组NodeList。或者可以创建new NodeClassFilter(LinkTag.class),然后把它作为提取的依据。 NodeClassFilter linkFilter = new NodeClassFilter(LinkTag.class); // 得所有过滤后的标签 NodeList list = parser.extractAllNodesThatMatch(linkFilter); 打印包含属性class=l的“”标签中的链接地址: NodeFilter filter=new AndFilter(new NodeClassFilter(LinkTag.class), new HasAttributeFilter("class","l")); NodeList nodelist=parser.extractAllNodesThatMatch(filter); - - 获得海量数据 int listCount=nodelist.size(); for(int i=0;i"; Lexer lexer = new Lexer(content); // 解析网页 - - 获得海量数据 Parser parser = new Parser(lexer); NodeFilter filter=new AndFilter(new NodeClassFilter(LinkTag.class), new HasAttributeFilter("class","title")); NodeList nodelist=parser.extractAllNodesThatMatch(filter); System.out.println(nodelist.size()); System.out.println(nodelist.elementAt(0).toHtml()); HTMLParser几乎解析不出来没有闭合的标签,而NekoHTML则可以做到。 3.1.6 结构化信息提取 一般把要提取的数据结构定义成为一个类,然后有一个解析网页的方法根据输入网页返回解析出来的类实例。例如,需要实现一个招聘职位搜索引擎。从招聘网页提取职位信息。 首先定义好用来接收网页数据的POJO(Plain Old Java Object)类Job。这个类用来描述职位信息: public class Job { public String comName = null; public String positionName = null; public String email = null; public String releaseData = null; public String city = null; public String number = null; public String experience = null; public String salary = "面议"; public String knowledge = null; public String acceptResumeLanguage = null; public String positionDescribe = null; public String comintro = null; public String comHomePage = null; public String comAddress = null; public String postCode = null; public String fax = null; public String connectPerson = null; public String telephone = null; public String languageAbility = null; public String funtype_big = null; public String funtype = null; - - 获得海量数据 public String province = null; public String toString(){ return "公司名称:"+comName+"\n职位名称:"+positionName+ "\n电子邮箱:" +email+"\n发布日期:"+releaseData+ "\n工作地点:"+city+"\n招聘人数:"+number+ "\n工作年限:"+experience+"\n薪水范围:"+salary+ "\n学历:"+knowledge+ "\n接受简历语言:"+acceptResumeLanguage+ "\n职位描述:"+positionDescribe+"\n公司简介:"+comintro+ "\n公司网站:"+comHomePage+"\n地址:"+comAddress+ "\n邮政编码:"+postCode+"\n传真:"+fax+ "\n联系人:"+connectPerson+"\n电话:"+telephone+ "\n外语要求:"+languageAbility; } 然后从下面这个职位发布的网页提取公司名称。查看公司名称周围的特征: 可以利用AndFilter来处理: //提取公司名字的Filter NodeFilter filter_title = new AndFilter(new TagNameFilter("TD"), new HasAttributeFilter("class","title02")); 提取公司名称的实现如下: NodeList nodelist = parser.extractAllNodesThatMatch(filter_title); Node node_title = nodelist.elementAt(0); String comName = extractText(node_title.toHtml()); comName = comName.replaceAll("[ \t\n\f\r ]+","");//去掉多余的空格 从职位详细页面URL地址提取职位信息的完整的过程如下: //输入网址,返回和该网址正文信息对应的职位对象 public Job extractContent(String url){ Job position = new Job(); Parser parser = new Parser(url); //设置编码方式 parser.setEncoding("GB2312"); //提取公司名字的Filter NodeFilter filter_title = new AndFilter(new TagNameFilter("TD"), - - 获得海量数据 new HasAttributeFilter("class","title02")); //提取公司名称 NodeList nodelist = parser.extractAllNodesThatMatch(filter_title); Node node_title = nodelist.elementAt(0); position.comName = extractText(node_title.toHtml()); position.comName = position.comName.replaceAll("[ \t\n\f\r ]+",""); //提取职位名称的Filter NodeFilter filter_job_name = new AndFilter(new TagNameFilter("td"), new HasAttributeFilter("bgcolor","#FFEEE0")); NodeFilter job_description_end1 = new AndFilter(new TagNameFilter("table"), new HasAttributeFilter("width","100%")); NodeFilter job_description_end2 = new HasAttributeFilter("cellpadding","5"); NodeFilter company_description = new AndFilter(new TagNameFilter("table"), new HasAttributeFilter("width","98%")); //提取职位的名称 parser.reset(); nodelist.removeAll(); nodelist = parser.extractAllNodesThatMatch(filter_job_name); Node node_job_name = nodelist.elementAt(0); position.positionName = extractText(node_job_name.toHtml()); //去掉空格 position.positionName = position.positionName.toString().replaceAll("[ \t\n\f\r ]+",""); //提取职位描述 NodeFilter job_description_end = new AndFilter(job_description_end1,job_description_end2); parser.reset(); NodeList nodelist_description = parser.extractAllNodesThatMatch(job_description_end); Node node_job_description = nodelist_description.elementAt(0); position.positionDescribe = extractText(node_job_description.toHtml()); position.positionDescribe = position.positionDescribe.replaceAll("[ \t\n\f\r ]+"," "); //提取公司简介 parser.reset(); nodelist_description.removeAll(); nodelist_description = parser.extractAllNodesThatMatch(company_description); - - 获得海量数据 Node node_company_description = nodelist_description.elementAt(0); //处理公司简介 position.comintro = doCompanyDescription(node_company_description); return position; } 3.1.7 网页的DOM结构 虽然表示网页的HTML文档格式不如XML规范,但是仍然可以把它转换成DOM(文档对象模型)树组织的结构。其中标签是DOM树内部的节点,而详细的文本、图象或者超链接则是叶节点。图3-3展示了HTML的一部分以及它相应的DOM树。
省名地区县市乡镇村邮政编码区号
- - 获得海量数据 山西朔州怀仁县鹅毛口0383010349
(.+?)(.+?)(.+?)(.+?)(.+?)
(.+?)(.+?)(.+?)(.+?)(.+?)(.+?)
北京亿达网通科技发展有限责任公司
HTML BODY TABLE TABLE IMG 图3-3网页DOM树 在Firefox浏览器中,使用DOM 查看器(DOM Inspector)和Firebug这两个插件工具都可以看到网页的DOM树。 访问http://www.lietu.com/。在浏览器菜单中,选择“工具”→ “DOM 查看器”,打开 DOM 查看器。在 DOM 查看器的左侧视图中,会看到 DOM 节点的树状图。 如果觉得依次展开 DOM 节点树中每层很不方便,可以使用 Inspect Element 扩展,它能迅速找到 DOM 查看器中的指定元素。XPath Helper(http://xpathhelper.mozdev.org/)可以扩展DOM查看器。它在DOM查看器上增加了一个可以输入XPath的工具条,可以在当前网页计算XPath表达式。 Firebug(http://getfirebug.com/)是Firefox的一个插件。通过Firebug,可以在Firefox中查看任何页面的已解析的文档对象模型(DOM)。可以获得每个 HTML 元素、属性和文本节点的详细信息;也可以看到每个页面样式表中的所有 CSS 规则;也可以看到每个对象的所有脚本属性。在Firefox中安装Firebug插件后,输入网址“http://www.lietu.com”即可看到网页DOM树 - - 获得海量数据 。比较而言,DOM 查看器中显示的网页DOM树概念更符合下面介绍到的网页解析器NekoHTML所生成的DOM树。 org.w3c.dom是操作DOM的标准接口。其中的基本概念有: l Node表示DOM树中的节点; l Document表示整个HTML文档,也就是DOM树中的根节点,所以是Node的子类; l Attr表示节点中的属性,例如,IMG类型的节点包括src属性; l Comment表示一个注释类型的节点,所以它也是Node的子类。 3.1.8 使用NekoHTML提取信息 NekoHTML(http://nekohtml.sourceforge.net/)可以解析HTML文档并形成标准的DOM树。因为HTML文档不如XML规范,可能存在一些格式不完整的元素,例如有些标签没有对应的结束标签。NekoHTML可以通过补偿标签来整理这些有缺陷的网页,也就是说把HTML文档转换成XML格式。然后就可以通过XML解析器xerces访问NekoHTML解析出的网页DOM树。使用NekoHTML的Java项目中需要导入的包是nekohtml.jar、xercesImpl.jar和xalan.jar。 解析某些网站可能会出现HIERARCHY_REQUEST_ERR错误。这是因为NekoHTML不能解析有命名空间的xhtml页面。 DOMParser parser = new DOMParser(); //不执行命名空间处理 parser.setFeature("http://xml.org/sax/features/namespaces", false); 得到一个网页的DOM树的流程如下: DOMParser parser = new DOMParser(); //创建DOM解析器 //设置参数 parser.setFeature("http://xml.org/sax/features/namespaces", false); parser.parse("http://www.lietu.com"); //解析网页 Document doc = parser.getDocument(); //得到根节点 节点有三种类型:元素节点、注释节点和文本节点。判断各种节点类型的代码如下: int type = node.getNodeType();// 取得节点类型 if (type == Node.ELEMENT_NODE) { System.out.print("元素节点"); } else if(type == Node.COMMENT_NODE){ - - 获得海量数据 System.out.print("注释节点"); } else if(type == Node.TEXT_NODE){ System.out.println("文本节点"); //打印文本节点中的文字信息 System.out.println(node.getNodeValue()); } 以一个显示图片的IMG标签节点为例,Node的基本概念如图3-6所示。节点中的属性和属性值可以看成键/值对。比如要得到图片节点中的src属性中的值,可以调用node.getAttributes().getNamedItem("src")方法。 节点名字 节点属性 节点属性值 图3-6 Node的基本概念 可以修改节点,比如要移除节点属性,可以用下面的方法: private void removeAttribute(final Node iNode, final String iAttr) { iNode.getAttributes().removeNamedItem(iAttr); } 每一个节点在DOM树中都有一个特定的位置。Node接口中有一些发现一个指定节点的周边节点的方法,如图3-7所示,说明如下: l Node getFirstChild(); //得到第一个孩子节点 l Node getLastChild(); //得到最后一个孩子节点 l Node getNextSibling(); //得到下一个兄弟节点 l Node getPreviousSibling(); //得到上一个兄弟节点 l Node getParentNode(); //得到父亲节点 l NodeList getChildNodes(); //得到所有的孩子节点 - - 获得海量数据 getFirstChild() getPreviousSibling() getChildNodes() getNextSibling() getLastChild() getParentNode() 图3-7 发现周边节点 下面的程序使用一个递归方法来遍历DOM树并打印出节点内容: public static void main(String[] argv) throws Exception { DOMParser parser = new DOMParser(); //创建DOM解析器 parser.parse("http://www.lietu.com"); //解析网页 print(parser.getDocument(), "");//从根节点开始遍历 } public static void print(Node node, String indent){ if (node.getNodeValue() != null){ if(!"".equals(node.getNodeValue().trim())){ System.out.print(indent); System.out.println(node.getNodeValue());//打印节点内容 } } Node child = node.getFirstChild(); //取得第一个孩子节点 while (child != null){ //递归调用print方法 print(child, indent+" "); child = child.getNextSibling();//取得孩子节点的下一个兄弟节点 } } HTMLParser中的TextExtractingVisitor实现了提取文本,但NekoHTML中却没有现成的实现。使用NekoHTML从网页中抽取文本的实现如下: - - 获得海量数据 public static String TextExtractor(Node root){ //若是文本节点的话,直接返回 if (root.getNodeType() == Node.TEXT_NODE) { return root.getNodeValue().trim(); } if(root.getNodeType() == Node.ELEMENT_NODE) { Element elmt = (Element) root; //风格节点STYLE 和 脚本节点SCRIPT下也可能包括了文本节点类型的子节点 //往往需要跳过这样的文本节点 if (elmt.getTagName().equals("STYLE") || elmt.getTagName().equals("SCRIPT")) return ""; NodeList children = elmt.getChildNodes(); StringBuilder text = new StringBuilder(); for (int i = 0; i < children.getLength(); i++) { text.append(TextExtractor(children.item(i)));// 递归调用TextExtractor } return text.toString(); } //对其它类型的节点,返回空值 return ""; } 下面的代码打印出网页中所有的链接: public static void main(String[] argv) throws Exception { DOMParser parser = new DOMParser(); parser.parse("http://www.lietu.com"); Set urlSet = new HashSet();// 存储网页中的Url extract(parser.getDocument(), urlSet); for (String url : urlSet) {//打印出所有的网页链接 System.out.println(url); } } //提取链接 public static void extract(Node node, Set urlSet) { if (node instanceof HTMLAnchorElementImpl) { - - 获得海量数据 //添加到集合中 urlSet.add(((HTMLAnchorElementImpl)node).getAttribute("href")); } Node child = node.getFirstChild();//取得第一个孩子节点 while (child != null) { // 递归调用extract方法 extract(child, urlSet); child = child.getNextSibling();//取得兄弟节点 } } xerces-2_9_1包括了一个LSSerializer对象可以保存DOM解析后的文档或节点,下面的代码将iNode节点的内容输出到控制台: registry = DOMImplementationRegistry.newInstance(); DOMImplementationLS impl = (DOMImplementationLS)registry.getDOMImplementation("LS"); LSSerializer writer = impl.createLSSerializer(); LSOutput output = impl.createLSOutput(); output.setByteStream(System.out); output.setEncoding(System.getProperty("file.encoding")); writer.write(iNode, output); 还可以使用 writeToString 方法将文档或文档中的节点所代表的一段网页写入字符串: String nodeString=domWriter.writeToString(iNode); 找出符合条件的DIV节点下的URL链接。网页的DOM如图3-8所示: 图3-8选取DIV下的节点 - - 获得海量数据 DOMParser parser = new DOMParser(); parser.parse("http://news.steelhome.cn/zhzx/"); Node divNode = visit(parser.getDocument());//取得符合条件的DIV节点 visitDiv(divNode); //取得DIV节点下的URL地址 visit方法通过从网页根节点递归遍历返回符合条件的DIV节点。 public static Node visit(Node node) { int type = node.getNodeType(); if (type == Node.ELEMENT_NODE && "DIV".equals(node.getNodeName())) { Node att = node.getAttributes().getNamedItem("style"); if (att != null && "float:left;width:464px;margin:1px;border: 1px solid #ccc;" .equals(att.getNodeValue())) { return node; } } Node child = node.getFirstChild(); while (child != null) { // 递归调用 Node t = visit(child); if (t != null) { return t; } child = child.getNextSibling(); } return null; } visitDiv方法通过从DIV节点递归遍历,打印出DIV节点下的URL地址。 public static void visitDiv(Node node){ int type = node.getNodeType(); if (type == Node.ELEMENT_NODE && "A".equals(node.getNodeName())) { Node t = node.getAttributes().getNamedItem("title"); if(t!=null) { String title = t.getNodeValue(); - - 获得海量数据 Node h = node.getAttributes().getNamedItem("href"); String url = h.getNodeValue(); System.out.println(title+"\t"+url); } } Node child = node.getFirstChild(); while (child != null) { // 递归调用 Node t = visitDiv(child); child = child.getNextSibling(); } } 提取"更多"文本对应的链接: public static String getMoreUrl(Node node) throws Exception { int type = node.getNodeType(); if(type == Node.TEXT_NODE && "更多".equals(node.getNodeValue())) { return node.getParentNode().getAttributes().getNamedItem("href").getNodeValue(); } Node child = node.getFirstChild(); while (child != null) { // 递归调用 String t = getMoreUrl(child); if (t != null) { return t; } child = child.getNextSibling(); } return null; } 在HTMLParser中,经常使用NodeFilter来选择出想要的节点,也可以使用另外一个NodeFilter筛选DOM树上的节点。这里的NodeFilter是一个接口。在自己定义的类中实现这个接口。acceptNode()方法包含自定义的逻辑决定节点是否通过过滤器。 // 接收标签名叫做'A'的DOM元素 - - 获得海量数据 private static final class LinkFilter implements NodeFilter { // 检查每个节点,看是否符合条件 public short acceptNode(Node n) { if (n instanceof Element) { if (((Element) n).getTagName().equals("A")) { return NodeFilter.FILTER_ACCEPT; //接收 } } return NodeFilter.FILTER_REJECT; //拒绝 } } 可以使用文档遍历器(DocumentTraversal)遍历DOM树,并返回符合条件的节点。并没有显式的get方法可以得到文档遍历器,通过强制类型转换得到文档遍历器。代码如下: DOMParser parser = new DOMParser(); parser.parse("http://www.lietu.com"); Document document = parser.getDocument(); //把一个文档实例转换成文档遍历器 DocumentTraversal traversal = (DocumentTraversal) document; NodeIterator iterator = traversal.createNodeIterator(document.getDocumentElement(), NodeFilter.SHOW_ALL, new LinkFilter(), true); //遍历符合条件的节点 for (Node n = iterator.nextNode(); n != null; n = iterator.nextNode()) { String href = ((HTMLAnchorElementImpl) n).getAttribute("href"); System.out.println(href); } 假设有几种类型的节点需要处理,其中一种包含图片,另外一种不包含。 private static enum NodeType { Good, //商品 Good_No_Img//没有图片的商品 } 节点过滤器修改成: private static final class GoodFilter implements NodeFilter { - - 获得海量数据 public NodeType nodeType = null; @Override public short acceptNode(Node n) { if (n instanceof Element) { if (((Element) n).getTagName().equals("IMG") && ((Element) n).hasAttribute("alt") && ((Element) n).getAttribute("alt").equals( "Product Details")) { nodeType = NodeType.Good; return NodeFilter.FILTER_ACCEPT;// 接受节点 } if (((Element) n).getTagName().equals("A") && ((Element) n).hasAttribute("class") && ((Element) n).getAttribute("class").equals("title")) { nodeType = NodeType.Good_No_Img; return NodeFilter.FILTER_ACCEPT;// 接受节点 } } } 提取文本类型的节点 //过滤出想要的文本节点 private static final class TextFilter implements NodeFilter { @Override public short acceptNode(Node n) { if (n instanceof Text && n.getNodeValue().equals("搜索产品")) { return NodeFilter.FILTER_ACCEPT;// 接受节点 } return NodeFilter.FILTER_REJECT;// 拒绝节点 } } 下面的代码移除节点iNode: iNode.getParentNode().removeChild(iNode); 通过如下三行代码创建节点: Document mTree = parser.getDocument(); //取得根节点 - - 获得海量数据 Element nDiv = mTree.createElement("DIV"); //创建一个DIV节点 parentNode.appendChild(nDiv); //增加子节点 为了避免乱码问题,可以先下载整个网页缓存到字符串,然后再解析。代码如下: //content是包含网页内容的字符串 InputSource inputsource=new InputSource(new StringReader(content)); parser.parse(inputsource); 或者使用HTTPClient下载网页,然后交给NekoHTML解析: String pageUrl = "http://www.lietu.com/"; HttpGet httpget = new HttpGet(pageUrl); HttpEntity entity =httpclient.execute(httpget).getEntity(); InputSource is =new InputSource(entity.getContent()); is.setEncoding("utf-8"); parser.parse(is);// 解析网页 Document doc = parser.getDocument(); 3.1.9 使用XPath提取信息 NekoHTML可以把HTML文档转换成XML文档。XML文档文档的某一部分可以用XPath来描述。可以用计算节点特征的方法找到覆盖主要正文的内容节点。找到这样一个节点后,可以把这个节点用XPath表示出来,例如: /HTML[1]/BODY[1]/DIV[1]/TABLE[1]/TBODY[1]/TR[1]/TD[1]/TABLE[1]/TBODY[1]/TR[1]/TD[1] 但是这种XPath的绝对路径表示方式当DOM树的节点有删除或修改后就失效了。还可以用XPath中的相对路径来选择节点,例如选择网页中所有链接的XPath是“//a”。选择一个标题DIV可能是“//div[@class='title']”。如果路径以单斜线 / 开始,那么该路径就表示到一个元素的绝对路径。如果路径以双斜线 // 开头,则表示相对路径,选择文档中所有满足双斜线//之后规则的元素。 星号 * 表示选择所有由星号之前的路径所定位的元素,例如:“/HTML[1]/BODY[1]/*”。 为了支持通过XPath取得节点,Java项目必须导入xalan.jar。下面的例子提取当当网图书的ISBN信息: DOMParser parser = new DOMParser(); parser.setProperty("http://cyberneko.org/html/properties/default-encoding","gb2312"); parser.setFeature("http://xml.org/sax/features/namespaces", false); String bookURL = - - 获得海量数据 "http://product.dangdang.com/product.aspx?product_id=20733895"; parser.parse(bookURL); Document doc = parser.getDocument(); String isbnXpath = "/HTML/BODY/DIV[3]/DIV[6]/DIV[2]/DIV/DIV[3]/UL/LI[9]"; NodeList products; products = XPathAPI.selectNodeList(doc, isbnXpath); System.out.println("found: " + products.getLength()); Node node = null; for (int i = 0; i < products.getLength(); i++) { node = products.item(i); System.out.println(i + ":\n" + node.getTextContent()); } 3.1.10 网页去噪 一个页面中的经常包括导航栏或底部的公司介绍等信息,这样的信息在很多页面都会出现,可以看作噪音信息。一般情况下可以去掉网页的DOM树中的FORM、Select、IFRAME、INPUT、STYLE中的节点。 不同的页面类型可以使用不同的去噪方法。常见的两种网页类型是目录导航式页面(List Page)和详细页面(Detail Page)。详细页面需要抽取的正文信息包括标题和内容等。 详细页面的特征有: l 非锚点文本较多。 l 一般都有明显的文本段落,文字较多,相应的标点符号也较多。 l URL较长。在一般的Web网站链接导航树上,主题型网页主要分布于底层,多为叶节点。对于同一网站而言,主题型网页的URL相对较长。URL体现了网站内容管理的层次,对于大型网站而言,URL往往非常有规律。 l 链接较少。主题型网页的主体在于“文字”,相对于导航型网页,其链接数较少。 l 主体文字在DOM树中的层次较深。 l 网页标题可能出现在Title标签或H1标签中,H2标签可能表示文章的段落标题。 详细页面中网页噪音特征: - - 获得海量数据 l 多以链接的形式出现,链接到别的相关页面。 l 有很多锚文本,但标点符号较少,锚文本往往是对其他链接页面的说明。 l 有许多常见的噪音文本,如版权声明等。在视觉上,多出现于网页的边缘。 网页去噪的方法基本方法是利用各种通用的特征来区分有效的正文和页眉,页脚,广告等其他信息。其中一个常用的特征是链接文字比率。可以把Html转换成DOM树,对每个节点计算链接文字比率。如果该节点的链接文字比率高于1/4左右,就把这个节点去掉。 下面首先用递归调用的方法计算一个节点下的链接数。 /** * 计算一个节点下的链接数 * * @param iNode 开始计算的节点 * @return 链接数 */ public static int getNumLinks(final Node iNode) { int links = 0; if (iNode.hasChildNodes()) { Node next = iNode.getFirstChild(); while (next != null) { Node current = next; next = current.getNextSibling(); //递归调用计算链接数的方法 links += getNumLinks(current); } } if (isLink(iNode)) links++; return links; } 计算一个节点下的有效正文长度,忽略锚点上的字。 /** * 计算一个节点下的单词数 * - - 获得海量数据 * @param iNode 开始计算的节点 * @return 单词数 */ private int getNumWords(final Node iNode) { int words = 0; if (iNode.hasChildNodes()) { Node next = iNode.getFirstChild(); while (next != null) { Node current = next; next = current.getNextSibling(); //如果当前节点是一个链接,则不往下深入 if (!isLink(current)) words += getNumWords(current); } } //检查节点是文本节点还是元素节点 int type = iNode.getNodeType(); //文本节点 if (type == Node.TEXT_NODE) { String content = iNode.getNodeValue(); words += getHTMLLen(content); } return words; } 计算一个文本中大概包括的正文实际长度的方法如下: private int getHTMLLen(String text) { int len = 0; for(int i=0;i settings.linkTextRatio) {//如果链接文字比大于指定值则删除该节点 iNode.getParentNode().removeChild(iNode); } } 3.1.11 网页结构相似度计算 自动提取结构化信息的关键是从同样类型的实例中发现编码模版。为了确定两个网页是否由同一个网页模板生成,需要计算两个网页的结构相似度。一个自然的方法是从HTML编码字符串检测重复的模式。检测的方法有最长公共子序列和树编辑距离。 举例说明两个序列s1 和s2的最长公共子序列(Longest Common Subsequence简称LCS)。s1 = { a , b , c , b , d , a , b },s2 = { b , d , c , a , b , a },则从前往后找,s1和s2的最长公共子序列为 LCS(s1, s2) = { b , c , b , a },如图3-7 所示。 a , b , c , b , d , a , b b , d , c , a , b , a 图3-7 最长公共子序列 LCS问题的最优解只取决于其子序列LCS问题的最优解,而非最优解对问题的求解没有影响。同时子序列LCS的值和当前对比字符的值一旦确定,则此后过程的LCS计算不再受此前各状态及决策的影响。因为满足动态规划的最优化原理和无后效性原则,因此使用动态规划的思想计算 - - 获得海量数据 LCS的方法:引进一个二维数组num[][],用num[i][j]记录s1的前i个长度的子串与s2的前j个长度的子串的LCS 的长度。 自底向上进行递推计算,那么在计算num[i][j]之前,num [i-1][j-1],num [i-1][j]与num [i][j-1]均已计算出来。此时再根据s1[i-1]和s2[j-1]是否相等,就可以计算出num[i][j]。 采用动态规划的方法计算两个序列的最长公共子序列的实现代码如下: public static List longestCommonSubsequence(E[] s1, E[] s2){ int[][] num = new int[s1.length+1][s2.length+1]; //二维数组 //实际算法 for (int i = 1; i <= s1.length; i++) for (int j = 1; j <= s2.length; j++) if (s1[i-1].equals(s2[j-1])) num[i][j] = 1 + num[i-1][j-1]; else num[i][j] = Math.max(num[i-1][j], num[i][j-1]); System.out.println("length of LCS = " + num[s1.length][s2.length]); int s1position = s1.length, s2position = s2.length; List result = new LinkedList(); while (s1position != 0 && s2position != 0) { if (s1[s1position - 1].equals(s2[s2position - 1])) { result.add(s1[s1position - 1]); s1position--; s2position--; } else if (num[s1position][s2position - 1] >= num[s1position - 1][s2position]) { s2position--; } else { s1position--; } } Collections.reverse(result); - - 获得海量数据 return result; } 比较网页结构相似度的基本流程是:首先把网页抽象成一个Node数组,然后比较两个Node数组的最长公共子序列。输入两个URL地址,返回网页相似度的实现代码如下: public static double getPageDistance(String urlStr1,String urlStr2) { ArrayList pageNodes1 = new ArrayList();//网页转换成Node数组 URL url = new URL(urlStr1); Node node; Lexer lexer = new Lexer (url.openConnection ()); lexer.setNodeFactory(new PrototypicalNodeFactory ()); while (null != (node = lexer.nextNode ())) { pageNodes1.add(node); } ArrayList pageNodes2 = new ArrayList(); URL url2 = new URL(urlStr2); lexer = new Lexer (url2.openConnection ()); lexer.setNodeFactory(new PrototypicalNodeFactory ()); while (null != (node = lexer.nextNode ())) { pageNodes2.add(node); } int lcs = longestCommonSubsequence (pageNodes1, pageNodes2); return lcs / (double)Math.min(pageNodes1.size(), pageNodes2.size()) ; } 3.1.12 提取标题 有两种提取标题的方式:一种是利用锚点描述文字;还有一种是利用标题中的描述信息。例如: 中国燃气总经理和执行总裁疑被警方带走_网易新闻中心 干部考核不宜唯“德孝”是举 - 第一视点 - 华声评论 - 华声在线 在“-”或“_”等分隔符前面的文字是真正的标题。 - - 获得海量数据 网页链接中的锚点文本是对目标网页主题内容的概括,所以往往用它作为一个网页的标题描述。图3-12显示了来源于新华网的两个页面之间的对应关系: 图3-12网页标题和锚点文本的对应关系 先把列表页上的连接url对应的 标签的“title”属性获取下来,称为列表页描述标题。详细页页面的标题属性称为内部标题。比较列表页描述标题和内部标题,估计出详细页的标题。由于这两个标题的标题字符串不是全部相同,例如列表页描述标题如果较长就可能以省略号结尾或者末尾截短字,而内部标题可能包含“_网站名”等冗余信息。所以可以考虑最长公共子串的方式比较两个标题进行相似度判断。 取得了网页标题以后,还可以利用标题信息计算网页的内容和标题之间的距离。 public static double getSimiarity(String title,String body){ int matchNum = 0; for(int i=0;i=0) { ++matchNum; } } - - 获得海量数据 double score = (double)matchNum/( (double)title.length() ); return score; } 可以对每个文本类型的节点根据内容分类。例如是标题、正文、或者来源,控制页面的文本或者广告文本、版权信息文本等。 把所有文本类型的节点收集到一个数组中。然后对每一个节点应用规则提取内容,并且组织成树型结构,最后再按节点解析成一个大的树型结构。 3.1.13 提取日期 经常需要从网页提取日期来判断网页的新旧程度等,可以用正则表达式提取。但日期格式很灵活,有很多种,不能用一个正则表达式模式完全覆盖,所以考虑直接写有限状态机提取日期。 很多新闻网站的新闻都是按发布日期分目录存放的,例如http://finance.sina.com.cn/g/20101226/09339163619.shtml是2010年12月26日的新闻。所以还可以从URL地址提取日期。 3.2 从非HTML文件中提取文本 在很多知识库系统中,为了查询大量积累下来的文档,需要从PDF、Word、Rtf、Excel和PowerPoint等格式的文档提取可以描述文档的文字。其中Word、Rtf、Excel和PowerPoint格式都来自微软。POI项目(http://poi.apache.org/)是一个专门处理微软文档格式的开源项目,是一个纯Java的实现,可以在Linux平台运行。S1000D是一种XML文档格式,主要用于航空航天和国防业(军品和民品)中的技术出版物。 有很多文件名的命名没有意义,比如说是类似“20090224153208138.pdf”由数字组成的文件名。在搜索结果中显示一个有意义的文件标题而不是文件名能够改进用户的搜索体验。这里首先从一般意义上来讨论从文件内容提取标题的方法。然后按文件类型分别具体实现提取标题。 设计一个通用的接口来处理待索引的文档。 //处理文档 public interface IFilter { String getTitle(File file);//返回标题 String getBody(File file);//返回内容 IDocument getDocument(File file);//返回全部索引信息 } - - 获得海量数据 3.2.1 提取标题的一般方法 为了从正文比较合理的提取标题,设计提取标题流程如图3-13: 候选标题生成 候选标题评估 标题输出模块 搜索结果中显示的标题 图3-13提取标题流程 l 候选标题生成模块:首先从提取出来的原子文本组织成文档结构树。构建文档结构树既可以用自底向上的方法,从原子节点构造起,也可以用自顶向下的方法从根节点首先将文字划分成大的单元,然后逐步从大块文字细分。如果采用自底向上的方法,如果文字在同一行,并且字体、字体大小、颜色都一致,则视为不可拆分。然后根据字体、字体大小、颜色、位置等信息再次合并文字。从文档结构树给出几个可能的候选标题。 l 候选标题评估模块:对每个候选标题,按照特征打分。可以把要用到的特征都作为候选标题类的属性。其中,最主要的特征是标题字符串,此外还有字符串所在位置,字体大小,字体颜色等信息。可以根据标题字符串对整个文章的概括程度和通顺性与意义完整性打分。从对文章的概括程度考虑,可以按照TF*IDF 等方法选取重要性较高的词作为关键词,然后根据关键词对每个候选标题字符串给出可能性权重。还可以比较候选标题字符串和首页中的其它文字,看候选标题相对其它文字的代表度或者说是相对其它文字的可替代性,也就是说候选标题对其它文字的覆盖度。从通顺性与意义完整性考虑,可以考虑准备一个标题语料库,提取出词法规则和作为标题常用的搭配规则,也可以对大量标题训练一个HMM模型。或者用信息提取的方法来评估候选标题字符串。 l 标题输出模块:把权重最大的候选标题挑出来,按照可读的方式输出。 作为标题的文字长度往往既不是太长,也不会太短,大部分标题的长度在10到30个字之间。超长的标题往往会被搜索引擎截短,网站制作者为了优化搜索排名,也倾向于采用这样的标题长度。可以用sweetSpot来对候选标题的长度打分。 private static int ln_min = 10; //标题最短长度 private static int ln_max = 30; //标题最长长度 private static float ln_steep = 0.5f; //如果长度在10到30之间,则sweetSpotScore返回值是1, //否则返回值会低于1,返回值随长度降低的程度由ln_steep决定。 public static double sweetSpotScore(int length) { return (float) (1.0f / - - 获得海量数据 Math.sqrt((ln_steep * (float) ( Math.abs(length - ln_min) + Math.abs(length - ln_max) - (ln_max - ln_min)) ) + 1.0f) ); } ln_steep 这个值一般取值在0到1之间,ln_steep越小,则返回值下降幅度越小。 政府公文有比较固定的格式,如图3-14所示。有“发文单位”,“文号”,“接收单位”等。例如上面那个文件“发文单位”是“合肥市物价局文件”,“文号”是“合价服〔2009〕219号”,“接收单位”是“三县四区物价局”。 图3-14政府公文 判断是否发文机关: public static boolean isProSendGovUnit(String s,Color tcolor){ double pro = 0; if(tcolor.equals(Color.RED)) { pro+=0.2; } if(s.endsWith("文件")){ - - 获得海量数据 pro+=0.4; } int numBlank = 0; int numGovUnit = 0; for(int i=0; i 0) { pro += (0.6*((double)numGovUnit+(double)numBlank)/((double)numBlank+s.length())); } if(pro > 0.6) return true; return false; } 判断是否收文机关: public static boolean isProReceiGovUnit(String s,Color tcolor){ if(!tcolor.equals(Color.BLACK)) { return false; } float pro =0 ; if(s.endsWith(":") || s.endsWith(":")) { pro += 0.5; } - - 获得海量数据 for(int i=0; i= 0.07) return true; return false; } 判断文号,例如“川国税发(2007)065 号”: public static boolean isFileNum(String s,Color tcolor){ if(!tcolor.equals(Color.BLACK)) { return false; } boolean isSymbol = false; //如果碰到开始符号,则匹配对应的结束符号 for(int index=0; index='0'&& s.charAt(i)<='9')||s.charAt(i) ==' ' ||(s.charAt(i)>='0'&& s.charAt(i)<='9')) {} else if(s.charAt(i) == endMark) { markEndSym = true; i++; } else { markBegSym = false; markEndSym = false; } } if(markBegSym && markEndSym) { if((s.charAt(i)>='0'&& s.charAt(i)<='9')||s.charAt(i) ==' ' ||(s.charAt(i)>='0'&& s.charAt(i)<='9')) {} else if(s.charAt(i) == '号') { isSymbol = true; index = s.length(); } - - 获得海量数据 } } return isSymbol; } 3.2.2 PDF文件 PDF是Adobe公司开发的电子文件格式。这种文件格式与操作系统的平台无关,可以在多数操作系统上通用。我们经常会遇到这种情况,就是想把PDF文件中的文字复制下来,可是会发现经常不能复制,因为这个PDF文件的内容可能加密了。那么怎么把PDF文件中的内容提取出来?这就是我们这一节要解决的问题。现在已经有很多工具可以让我们利用来完成这项任务了,PDFBox(http://pdfbox.apache.org/)就是专门用来解析PDF文件的Java项目。 下载文件pdfbox-app-1.5.0.jar,然后在Eclipse项目中引入这个文件。提取文本只需要三行代码: //加载fileName代表的PDF文件 PDDocument doc = PDDocument.load(fileName); //用PDFTextStripper提取文本 PDFTextStripper stripper = new PDFTextStripper(); //返回提取的文本字符串 String content = stripper.getText(doc); 这样可以抽取PDF文件中的文本,但是对于PDF文件中的图片PDFBox是无能为力的。对于包含文字的图片,需要借助OCR软件从图片中识别出文字。 需要说明的是这个版本支持中文。使用早期版本的PDFBox抽取中文PDF文本时,可能会遇到Unknown encoding for 'GBK-EUC-H'错误。因为PdfReader内部缺省支持一些中文字体,但PDFBox内部却没有对'GBK-EUC-H'字体的支持。 因为PDF文件中有可能存在重叠位置的文字,PDFTextStripper有对于重叠位置文字的判断。如果两个TextPosition位于差不多同样的位置,则不重复抽取重叠的TextPosition代表的文字。 private boolean overlap( TextPosition tp1, TextPosition tp2 ){ if(!within(tp1.getHeight(),tp2.getHeight(), 1.1f)) return false; float diff = (tp1.getHeight() + tp2.getHeight())*0.02f; return within( tp1.getX(), tp2.getX(), diff) && within( tp1.getY(), tp2.getY(), diff) ; } - - 获得海量数据 在提取PDF文件中可搜索的文字的问题解决后,下一个问题是提取标题。例如,从Google 搜索“filetype:pdf”可以看到Google提取的PDF文件标题。 如果PDF元数据中已经存储了文档标题,可以通过元数据取得文档标题: //取得文档元数据 PDDocumentInformation info = document.getDocumentInformation(); this.title = info.getTitle(); 但是很多PDF文件中元数据并没有存储任何内容。所以在大多数情况下,仍然需要从内容中分析出标题。 可以利用文本的位置或字体等信息帮助选取标题。例如在首页字体最大的文字有可能是PDF文件的标题。可以通过TextPosition对象的getFontSizeInPt方法返回文字字体大小。 为了取得文本的位置信息和颜色等,需要更加深入的了解PDFBox的运行原理。PDF规范可以从http://www.adobe.com/devnet/pdf/pdf_reference.html下载。PDF 内容流中包含许多操作符(Operator)。在PDFBox中,每种操作符都有专门对应的处理类,记录在配置文件Resources/PageDrawer.properties中。例如其中的一行: BT=org.apache.pdfbox.util.operator.BeginText 表示BT操作的处理类是org.apache.pdfbox.util.operator.BeginText PDFBox中的应用程序PageDrawer实现输出PDF文件到可视化窗口。PageDrawer有完整的对于文字显示方面的处理。为了提取标题,可以通过在PageDrawer的实现基础上,简化一些与Path和Line相关的操作符的处理,只把位置信息和颜色等信息提取出来。 文字的颜色信息并不能直接得到,而是依赖于上下文的。PageDrawer类包含了对图像的处理。 if( this.getGraphicsState().getTextState().getRenderingMode() == PDTextState.RENDERING_MODE_FILL_TEXT ){ graphics.setColor( this.getGraphicsState().getNonStrokingColorSpace().createColor() ); } else if( this.getGraphicsState().getTextState().getRenderingMode() == PDTextState.RENDERING_MODE_STROKE_TEXT ) { graphics.setColor( this.getGraphicsState().getStrokingColorSpace().createColor() ); } 用PdfTitle表示候选标题。 public class PdfTitle { public String title = null; //标题文字 - - 获得海量数据 public Color textColor = null; //文字颜色 public float fontSize; //字体大小 public float y; //高度 ArrayList texts; //包含的文本 public PdfTitle(String t,float h) { title = t; y=h; } public PdfTitle(Color c,float f,float h,ArrayList text) { title = delOverlap(text); textColor = c; fontSize = f; y = h; texts = text; } } 候选标题单独组成一个段落,或者位于一个段落内部。通过PDFDegger可以看到PDF文件的树形结构组织。因为通过PDFStreamEngine类导出的文字块没有明显的段落信息,所以需要编写程序寻找段落边界。大的字体差别和颜色差别以及文本的垂直位置都可以用来确定是一个独立段落的开始,但有些相对模糊的边界需要更细致的判断。因此把PdfTitle设计成可以再次拆分的单元。 1. 根据文本的垂直位置寻找该段落的最大垂直区隔; 2. 根据最大垂直区隔拆分PdfTitle; 3. 如果没有找到合适的标题,对于拆分出来的新的PdfTitle重新应用1,2步骤直到不可再拆分或找到合适的标题为止。 可以通过扩展org.pdfbox.util.PDFTextStripper类,覆盖processTextPosition方法来遍历PDF文件中的TextPosition对象。下面的代码简单的提取最大字体的文字作为标题。 float currentFontSize = text.getFontSizeInPt(); if(currentFontSize < bigestFontSize){ //字体不一致,则不是标题文字 consistent = false; } else if (currentFontSize > bigestFontSize){ //字体最大,是标题文字 titleGuess = text.getCharacter(); bigestFontSize = currentFontSize; - - 获得海量数据 consistent = true; } else if(currentFontSize == bigestFontSize && consistent && !"".equals(text.getCharacter().trim())){ //字体和以前文字的最大字体一样大,是标题文字 titleGuess += text.getCharacter(); } PDF文件分成两类:一类首页没有正文,文字比较少,文字比较多的部分可能是标题。还有一类,首页有正文,挨着正文往上的可能是标题,也可能是段落标题。首页没有正文的情况,不计算标题对于首页正文的概括性。 政府公文中往往包含一些主题词,可以利用主题词或者文章内容来判断标题。或者建立一个标题的语料库,例如很多标题都是采用“关于**的通知”这样的形式,可以利用模版匹配的方式来保证标题的完整性。 3.2.3 Word文件 Word是微软公司开发的字处理文件格式,以“doc”或者“docx”作为文件后缀名。Apache的POI(http://poi.apache.org/)可以用来在Windows或Linux平台下提取Word文档。用POI提取文本的基本方法如下: public static String readDoc(InputStream is) throws IOException{ //创建WordExtractor WordExtractor extractor=new WordExtractor(is); // 对DOC文件进行提取 return extractor.getText(); } 为了提取Word文档的标题,需要深入了解POI接口。一个Word文档包含一个或者多个Section,每个Section下面包含一个或者多个Paragraph,每个Paragrah下面包含一个或者多个CharacterRun。 - - 获得海量数据 Document Section Paragraph CharacterRun 图3-16 Word文档结构图 遍历Word文档结构的代码如下: Range r = doc.getRange(); for (int x = 0; x < r.numSections(); x++) { Section s = r.getSection(x); for (int y = 0; y < s.numParagraphs(); y++) { Paragraph p = s.getParagraph(y); for (int z = 0; z < p.numCharacterRuns(); z++) { CharacterRun run = p.getCharacterRun(z); //字符串文本 String text = run.text(); System.out.println(text); } } } 标题往往是居中对齐的。可以通过Paragraph的getJustification方法得到段落的对齐方式。getJustification的返回值0表示左对齐,1表示居中对齐,2表示右对齐,3表示两端对齐。 可以通过CharacterRun对象取得文字内容、字体大小、文字颜色等信息。 run.text(); //文字内容 run.getFontSize(); //字体大小 run.getColor(); //文字颜色 - - 获得海量数据 3.2.4 Rtf文件 在1992年,微软公司为了定义简单的格式化的文本和嵌入的图片引入了富文本格式(RTF)。最开始是为了在不同的操作系统(例如MS-DOS,Windows和OS/2以及苹果的Macintosh)上的不同的应用程序之间转移数据,现在这种格式已经广泛用于Windows系统,因为它可以在RichTextBox控件中编辑。 Rtf的版本从1.0到1.9。Rtf是8位格式的,为了方便传输,标准的Rtf文件只由ASCII字符组成,中文字符用转义符来表示。每个Rtf文件都是一个文本文件。文件开始处是{\rtf,它作为Rtf文件的标志是必不可少的,Rtf阅读器根据它来判断一个文件是否为Rtf格式。然后是文件头和正文,文件头包括字体表、文件表、颜色表等几个数据结构,正文中的字体、表格的风格就是根据文件头的信息来格式化的。每个表用一对大括号括起来,其中包含很多用字符“\”开始的命令。举个最简单的Rtf文件的例子,文件中只包含一行文字:{\rtf1foobar}。用写字板打开这个文件,显示:“foobar”。 许多开源的Rtf文件解析器不能正确处理多字节编码内容,例如:javax.swing.text.rtf。当然有的项目也能够处理包含Unicode编码的Rtf文件,例如RtfConverter(下载地址是http://www.codeproject.com/KB/recipes/RtfConverter.aspx),不过这个项目是c#实现的,下节介绍如何用Java实现一个RTF文件解析器RtfConverter4J。 Rtf文件解析器RtfConverter4J的设计目标是: l 可以在各层次上分析Rtf数据。 l 把对Rtf数据的解析和解释分开。 l 保持解析器和解释器的可扩展性。 l Rtf转换应用程序容易上手。 l 采用开放式架构,能够很容易自定义Rtf转换器。 因此,设计了如图3.5所示的这个开放式架构。 - - 获得海量数据 图3.5 Rtf转换程序总体结构图 RtfParser 类用于完成实际的数据解析。除了识别标签(Tag),也处理字符编码,支持Unicode。RtfParser把RTF数据分成如下几种基本的元素: l RtfGroup:Rtf元素的集合。 l RtfTag:一个Rtf 标签的名字和值对。 l RtfText:任意的文本内容,但是文本内容不一定是可见的。 在RtfParser中对Unicode编码的处理代码如下: if (tagName.equals(RtfSpec.TagUnicodeCode)) { //读入Unicode编码的字符的值 int unicodeValue = tag.getValueAsNumber(); char unicodeChar = (char) unicodeValue; this.curText.append(unicodeChar); for (int i = 0; i < this.unicodeSkipCount; i++) { // 跳过指定数量的文本 char skip1 = (char) reader.read(); if (skip1 == ' ') { char skip2 = (char) PeekNextChar(reader, false); if (skip2 == '\\') { reader.read(); char skip3 = (char) PeekNextChar(reader, false); while (skip3 != '\\' && skip3 != '}' && skip3 != '{') { - - 获得海量数据 reader.read(); skip3 = (char) PeekNextChar(reader, false); } } } else if (skip1 == '\\') { reader.read(); char skip3 = (char) PeekNextChar(reader, false); while (skip3 != '\\' && skip3 != '}' && skip3 != '{') { reader.read(); skip3 = (char) PeekNextChar(reader, false); } } else if (skip1 == '\r') { reader.read(); char skip2 = (char) PeekNextChar(reader, false); if (skip2 == '\\') { reader.read(); char skip3 = (char) PeekNextChar(reader, false); while (skip3 != '\\' && skip3 != '}' && skip3 != '{') { reader.read(); // System.Console.Write((char)reader.Read()); skip3 = (char) PeekNextChar(reader, false); } } } } } else if (tagName.equals(RtfSpec.TagUnicodeSkipCount)) { //读入UnicodeSkipCount的值 int newSkipCount = tag.getValueAsNumber(); if (newSkipCount < 0 || newSkipCount > 10) { throw new Exception("invalid unicode skip count: " + tag); } this.unicodeSkipCount = newSkipCount; } 因为Rtf数据中可能包含另外一种十六进制表示的Unicode,所以增加了对TagUnicodeSkipCount的处理。 - - 获得海量数据 Rtf文件解析器的结构如图5.6所示。实际的解析过程可以通过ParserListener监听。ParserListener通过观察者模式提供了对某个事件反应的机会并执行相应的动作。系统已经集成的解析器监听器RtfParserListenerFileLogger可以用来把Rtf元素的结构写入日志文件 (主要用在开发阶段的调试)。输出可以通过RtfParserLoggerSettings配置。 图5.6 RTF解析器结构图 RtfParserListenerStructureBuilder根据解析过程中碰到的rtf元素生成结构化模型。这个模型把基本元素表示成IrtfGroup、IrtfTag和IrtfText的实例。可以通过RtfParserListener- StructureBuilder.StructureRoot取得层次结构。 当Rtf文档解析成结构化模型后,就可以通过Rtf解释器来解释。Rtf文件解释器的结构如图5.7所示。解释结构化模型的一个方法是构造文档模型来提供对文档内容意义的高层次抽象。一个简单的文档模型由如下块组成:文档信息:标题、主题和作者等;用户属性;颜色信息;字体信息;文本格式;可视化信息。其中,可视化信息包括: l 文本相关的格式化信息。 l 分隔符。线,段落,节,页。 l 特殊字符。制表符、段落开始/结束、下划线、空格、圆点、引号、连字符。 l 图片。 - - 获得海量数据 图5.7 Rtf解释器结构图 下面的例子展示了如何使用文档模型的高层API: //显示Rtf文件内容 public static void RtfWriteDocumentModel(String rtfStream) throws Exception { RtfInterpreterListenerFileLogger logger = null; IRtfDocument document = RtfInterpreterTool.BuildDoc(rtfStream, logger); RtfWriteDocument(document); } // RtfWriteDocumentModel //根据文档模型遍历 public static void RtfWriteDocument(IRtfDocument document) { System.out.println("RTF Version: " + document.getRtfVersion()); // 显示文档信息 System.out.println("Title: " + document.getDocumentInfo().getTitle()); System.out.println("Subject: " + document.getDocumentInfo().getSubject()); System.out.println("Author: " + document.getDocumentInfo().getAuthor()); //显示字体表中的字体 for (String fontName : document.getFontTable().keySet()) { System.out.println("Font: " + fontName); } - - 获得海量数据 //显示颜色表中的颜色 for (IRtfColor color : document.getColorTable()) { System.out.println("Color: " + color.getAsDrawingColor()); } //取得文档的用户属性,例如文档“创建者”或者“时间” for (IRtfDocumentProperty documentProperty : document .getUserProperties()) { System.out.println("User property: " + documentProperty.getName()); } //遍历所有可视化元素 for (IRtfVisual visual : document.getVisualContent()) { RtfVisualKind visualKind = visual.getKind(); if (visualKind == RtfVisualKind.Text) {//文本 System.out.println("Text: " + ((IRtfVisualText) visual).getText()); } else if (visualKind == RtfVisualKind.Break) {//换行符号 System.out.println("Tag: " + ((IRtfVisualBreak)visual). getBreakKind().toString()); } else if (visualKind == RtfVisualKind.Special) {//特别字符 System.out.println("Text: " + ((IRtfVisualSpecialChar) visual).getCharKind() .toString()); } else if (visualKind == RtfVisualKind.Image) {//图像 IRtfVisualImage image = (IRtfVisualImage) visual; System.out.println("Image: " + image.getFormat().toString() + " " + image.getWidth() + "x" + image.getHeight()); } } } 下面调用RtfConverter4J提取文本: URL u = new URL("http://www.silo.net/LaReja-2005-05-07/texto_chino_7M.rtf"); // 创建一个URL连接对象 URLConnection uc = u.openConnection(); - - 获得海量数据 // 提取内容并输出 InputStream is = uc.getInputStream(); RtfExtractor extractor=new RtfExtractor(is); String text = extractor.getText(); is.close(); System.out.println("text:"+text); 提取Rtf文件的标题设计流程如下: (1) 生成候选标题:把字体最大的文字块或者居中对齐的文字块作为候选标题。 (2) 评估候选标题:根据候选标题所在的位置和候选标题与正文的相似度来评分。 (3) 输出标题模块:选择分值最大的标题输出。 首先定义候选标题类,代码如下: public static class TitleInfo { public String fontName; //字体名 public int fontSize; //字体大小 public int position; //位置信息 public int mergeTo; //合并块编号 public boolean isBold; //是否粗体 public String text; //内容 public HashMap words; //内容切分结果 public double weight; //权重 } 提取标题整体流程实现代码如下: public static String getRtfTitle(String fileName) throws Exception { StringBuffer content = new StringBuffer();//用来存储全文内容 //取得候选标题 ArrayList candidates=getCandidates(fileName,content); if (candidates == null || candidates.size() == 0)// 没有候选标题 return ""; else if (candidates.size() == 1) return candidates.get(0).text; else { //候选标题评分 - - 获得海量数据 rankTitle(candidates, content.toString()); //把评分最高的候选标题作为标题提取结果 return getBestTitle(candidates); } } 取得候选标题部分实现代码: public static ArrayList getCandidates(String fileName, StringBuffer content) { IRtfGroup rtfStructure = ParseRtf(fileName);// 获取rtf结构 RtfInterpreterListenerDocumentBuilder docBuilder = new RtfInterpreterListenerDocumentBuilder(); // 初始化 RtfInterpreterListenerLogger interpreterLogger = null; RtfInterpreterTool.Interpret(rtfStructure, interpreterLogger, docBuilder); RtfDocument doc = (RtfDocument) docBuilder.getDocument(); if (doc == null)//读取失败 return null; ArrayList rtfVisuals = doc.getVisualContent(); int maxFontSize = 0; int maxPosition = 0; for (IRtfVisual rtfv : rtfVisuals) {// 找最大字体 if(rtfv.getKind()==RtfVisualKind.Text) { int currentFontSize = ((IRtfVisualText)rtfv).getFormat().getFontSize(); maxFontSize = Math.max(currentFontSize, maxFontSize); } } ArrayList candidates = new ArrayList(); for (int i = 0; i < rtfVisuals.size(); i++) { IRtfVisual rtfv = rtfVisuals.get(i); if (RtfVisualKind.Text == rtfv.getKind())//只有Text才有文字属性 { - - 获得海量数据 // 换行前以第一种字符格式作为整行格式,除了字体大小。 String fontName = ((IRtfVisualText) (rtfv)).getFormat() .getFont().getName();// 首字符字体 Color tc = ((IRtfVisualText) (rtfv)).getFormat() .getForegroundColor().getAsDrawingColor(); boolean isBold = ((IRtfVisualText) (rtfv)).getFormat() .getIsBold(); RtfTextAlignment alignment = ((IRtfVisualText) (rtfv)) .getFormat().getAlignment(); int fontSize = ((IRtfVisualText) (rtfv)).getFormat() .getFontSize(); String oneRow = ""; while (RtfVisualKind.Break != rtfv.getKind()) {// 换行前的部分是一个整体 if(rtfv.getKind()==RtfVisualKind.Text) oneRow += ((IRtfVisualText)rtfv).getText(); i++; rtfv = rtfVisuals.get(i); } //公文属性判断 if (isProSendGovUnit(oneRow, tc) || //发文单位 isProReceiGovUnit(oneRow, tc) || //收文单位 isFileNum(oneRow)) { //文号 maxPosition++; continue; } if (RtfTextAlignment.Center == alignment || fontSize == maxFontSize) {// 居中、最大字体加入候选标题 candidates.add( new TitleInfo(oneRow, fontSize, maxPosition, fontName, isBold)); } else - - 获得海量数据 // 不居中的是正文内容 content.append(oneRow + " "); } maxPosition++; } return candidates; } 对候选标题评分的代码如下: public static void rankTitle(ArrayList titles, String content) { HashSet stopWords = StopSet.getInstance();//停用词表 HashMap contentWords = new HashMap(); int maxLength = 0; int maxFontSize = 0; int width = 440;// 正文宽度 int firstPosition = 9999; for (int i = 0, j = 1; j < titles.size(); i++, j++) {// 第一步:合并可能的标题 TitleInfo ti = titles.get(i); TitleInfo tj = titles.get(j); firstPosition = Math.min(firstPosition, ti.position); if (ti.fontSize == tj.fontSize // 字体大小一致 && ti.position + 1 == tj.position // 上下连贯 && titles.get(i).text.length() > 1 && titles.get(j).text.length() > 1) { if (!(tj.text.startsWith(" "))) { TitleInfo tTmp = ti; tj.mergeTo = i; while (tTmp.mergeTo != -1) { tj.mergeTo = tTmp.mergeTo; tTmp = titles.get(tTmp.mergeTo); } // 向前合并标题同时删除; titles.get(tj.mergeTo).text = titles.get(tj.mergeTo).text .trim() - - 获得海量数据 + tj.text.trim(); tj.text = " "; if (ti.fontSize * ti.text.length() > width) {//因为字符过多而换行的 width *= 2; titles.get(tj.mergeTo).weight += 0.2; } } } } for (TitleInfo m : titles) {// 第二步:分词,确定候选标题的长度与位置范围 if (m.text.trim().length() >= 2) {// 非空标题分词 maxLength = Math.max(maxLength, m.text.length()); maxFontSize = Math.max(maxFontSize, m.fontSize); // 标题分词,建向量 ArrayList taggedTitle = Tagger.getFormatSegResult(m.text); m.words = new HashMap(); for (CnToken ct : taggedTitle) { if ("m".equals(ct.type()) || "t".equals(ct.type()) || stopWords.contains(ct.termText())) continue;// 去除停用词 Double val = m.words.get(ct.termText()); if (val != null) { m.words.put(ct.termText(), new Double(val + 1.0)); } else m.words.put(ct.termText(), new Double(1.0)); } } m.position -= firstPosition; } // 对正文分词,建向量 ArrayList taggedContent = Tagger.getFormatSegResult(content); for (CnToken ct : taggedContent) { if ("w".equals(ct.type()) || "m".equals(ct.type()) - - 获得海量数据 || "t".equals(ct.type()) || stopWords.contains(ct.termText())) continue;// 去除停用词 if (contentWords.containsKey(ct.termText())) { contentWords.put(ct.termText(), new Double(contentWords.get(ct .termText()) + 1)); } else contentWords.put(ct.termText(), new Double(1.0)); } double contentNorm = calculateNorm(contentWords); for (int i = 0; i < titles.size(); i++){// 第三步:综合评价候选标题权重 TitleInfo t = titles.get(i); if (t.text.trim().length() >= 2) { double lengthWeight = getLengthWeight(t.text.length()); double fontSizeWeight = getFontSizeWeight(t.fontSize, maxFontSize); double positionWeight = getPositionWeight(t.position); //计算可选标题与全文的相似度,用夹角余弦来衡量相似度 double semanticWeight = getSimilarity(t.words, contentWords, contentNorm); //计算综合权重 Double compositiveWeight = new Double(t.weight * Math.pow(lengthWeight * fontSizeWeight * positionWeight, 1.0 / 3) * semanticWeight); //得出标题的最终权重 if (t.isBold) t.weight = compositiveWeight * 1.1; else t.weight = compositiveWeight; } } } 最后简单地取最大分值对应的标题: - - 获得海量数据 public static String getBestTitle(ArrayList titles) { double max = 0; //记录最大分值 String bestTitle = null; //记录最好标题 for (TitleInfo t : titles) { if (t.weight > max && t.text.trim().length() >= 2) { max = t.weight; bestTitle = t.text; } } return bestTitle; } 3.2.5 Excel文件 Excel文件由一个工作簿(Workbook)组成。工作簿由一个或多个工作表(Sheet)组成,每个工作表都有自己的名称。每个工作表又包含多个单元格(Cell)。除了POI项目,还有开源项目jxl (http://www.andykhan.com/jexcelapi/index.html)可以用来读写Excel文件。 调用Apache的POI提取文本的代码如下所示: public static String readDoc(InputStream is) throws IOException{ //读入Excel文件数据流 ExcelExtractor extractor = new ExcelExtractor(new POIFSFileSystem(is)); //不返回公式 extractor.setFormulasNotResults(true); //不包括Sheet名称 extractor.setIncludeSheetNames(false); return extractor.getText(); } 为了提取Excel文件的标题,首先从每个工作表找最可能的标题,然后从多个工作表中再次挑选最可能的标题。 首先定义封装单元格属性的类,其中包含了用来计算标题重要度的一些属性: public class CellInfo{ public String text;//文本内容 public short fontSize;//字体大小 public short alignment;//对齐方式 public boolean boldness;//是否黑体 - - 获得海量数据 public int rowPos;//所在行的位置 public double weight;//重要度 public boolean isUnique;//独立成行 public CellInfo(String t, short fs, int rp, short align, boolean bold){ text = t; fontSize = fs; rowPos = rp; alignment = align; boldness = bold; weight = 1.0; isUnique = false; } } 通过遍历工作表中的每个字符型单元格来取得每个工作表的最好标题: private TitleInf getSheetBestTitle(HSSFSheet sheet, HSSFWorkbook wb){ Iterator riter = sheet.rowIterator();//按行遍历工作表 ArrayList titles = new ArrayList(); int maxFontSize = 0; int rowCount = 0; while (riter.hasNext()) { rowCount ++; int columnCount = 0; HSSFRow row = (HSSFRow) riter.next();//按行遍历 Iterator citer = row.cellIterator(); while(citer.hasNext()) { HSSFCell cell = citer.next();//每行再按列遍历 int cellType = cell.getCellType(); HSSFCellStyle cellStyle = cell.getCellStyle(); if (cellType != HSSFCell.CELL_TYPE_BLANK ) {//非空 columnCount ++; } if (cellType == HSSFCell.CELL_TYPE_STRING) { //字符型 String cellString = cell.toString().trim();//取得单元格内的文本 - - 获得海量数据 if (cellString.length() >= 2) { HSSFFont cellFont = cell.getCellStyle().getFont(wb); short fontheight = cellFont.getFontHeight(); short al = cellStyle.getAlignment(); short boldness = cellFont.getBoldweight() ; maxFontSize = Math.max(maxFontSize, (int)fontheight); CellInfo ci = new CellInfo(cellString, fontheight, rowCount, al, boldness == HSSFFont.BOLDWEIGHT_BOLD); titles.add(ci); } } } if (columnCount == 1) //这行只有这个 titles.get(titles.size() - 1).isUnique = true; } if (titles.size() == 0) return new TitleInf("",0); else return selectBestTitle(titles, maxFontSize, rowCount); } 标题类包含了标题的文本内容和重要度: public class TitleInf{ public String text;//文本内容 public double weight;//重要度 public TitleInf(String t, double w){ text = t; weight = w; } } 取得整个Excel文件的最可能的标题: public String getTitle(String fileName) throws Exception{ - - 获得海量数据 InputStream is = new FileInputStream(fileName); HSSFWorkbook wb = new HSSFWorkbook(new POIFSFileSystem(is)); ArrayList candidate = new ArrayList();//侯选标题 int activeSheetIndex = wb.getActiveSheetIndex();//取得活跃工作表的编号 int sheetsNum = wb.getNumberOfSheets(); for (int i = 0 ; i < sheetsNum ; i++){//取得每个工作表的最可能标题 TitleInf bti = getSheetBestTitle(wb.getSheetAt(i), wb); if (i == activeSheetIndex) bti.weight *= 3.0; candidate.add(bti); } //取得最评分最高的候选标题 double maxWeight = 0; String bestTitle = null; for (TitleInf curTitle : candidate) { if (curTitle.weight >= maxWeight){ maxWeight = curTitle.weight; bestTitle = curTitle.text; } } is.close(); return bestTitle; } 3.2.6 PowerPoint文件 在PowerPoint视图编辑区的上半部分显示幻灯片的缩略图,下半部分是备注编辑区。所以可提取的文本包括幻灯片显示的文本和备注中的文本。调用Apache的POI提取PowerPoint文件中的文本代码如下所示: public static String readDoc(InputStream is) throws IOException{ //创建PowerPointExtractor PowerPointExtractor extractor=new PowerPointExtractor(is); //取得所有的幻灯片文本,但是不包括备注中的文本。 //如果要返回备注中的文本,可以调用setNotesByDefault(true) - - 获得海量数据 return extractor.getText(); } PowerPoint文件由一个或多个幻灯片(Slide)组成。第一张幻灯片的标题往往是整个PowerPoint文件的标题,提取标题实现代码如下: SlideShow ss = new SlideShow(new HSLFSlideShow(is));//is是ppt文件的输入流 Slide[] slides = ss.getSlides();//获得每一张幻灯片 return slides[0].getTitle();//返回第一张幻灯片的标题 3.3 图像的OCR识别 抓取过程中,如果碰到包含价格或联系方式等信息的图片,需要转换成文字信息。例如这个以图片表示的价格:。在搜索引擎中实现图片或视频搜索时,也可以把图片或视频中的文字信息提取出来,然后再按文字来查找。 OCR(Optical Character Recognition)识别图像的过程的核心是对图像切割和分类。图像识别过程流程如下: 1. 对要识别的图像区域进行二值化处理;识别出字体和背景的颜色。如果有必要,还需要对图像中的干扰去噪。 2. 对二值化处理后的图像切分;图像切分就好像裁缝裁布一样,一般从空白处切开图片。因为字号变化导致对应的字符图像尺寸相差可能达到数十倍,所以还需要把切割后的图像大小归一化。 3. 判断包含单个字符的图像应该识别成哪个字符,这是个分类的问题,可以采用机器学习的方法实现,是有监督的学习(supervised learning)过程。先要准备好包含对应字符的图像样本及应该识别出的字符答案作为训练库。事先训练出模型后,识别时加载用分类的方法学习出的分类模型;分类方法可以选择SVM或者最大熵分类等。在OCR识别过程中,需要对从图像提取出的数据集进行分类,因此合适的分类器对结果的预测起着非常重要的作用。近年来,支持向量机(SVM)作为一种基于核方法的机器学习技术,不仅有强烈的理论基础而且有极好的成功经验。这里采用开源的SVM 软件LIBSVM来对图像分类。 4. 执行分类并输出结果。如果有必要可以根据上下文猜测最有可能的输出结果来降低识别错误。 调用图像识别模块接口的代码如下: String imgFile = "D:/lg/ocr/sample-images/ESfjydz.png"; - - 获得海量数据 OCR ocr = new OCR(); ocr.preload(); //学习的过程 String text = ocr.recognize(imgFile); //识别的过程 System.out.println("\nresult:"+text); 3.3.1 图像二值化 对要识别的图像区域进行二值化处理是很重要的步骤。比如一个 b 这样的图像区域变成一个0和1组成的图像,如图2-7所示: 110000000000 110000000000 110000000000 110000000000 110011111100 110011111100 111100000011 111100000011 110000000011 110000000011 110000000011 110000000011 110000000011 110000000011 111100000011 111100000011 110011111100 110011111100 图2-7 字符b以及它的0和1组成的二值化结果 在实际应用过程中图像二值化处理很重要,否则识别出来的可能都是1了,也就是说图片需要预处理成0101这样的黑白格式。 首先要支持读入多种常见图片文件格式,例如jpg、gif等。Java中进行图像I/O有一些方法,其中的Image I/O API支持读写常见图片格式。Image I/O API提供了加载和保存图片的方法。支持读取GIF、JPEG和PNG图像,也支持写JPEG和PNG图像,但是不支持写GIF文件。Java Image I/O API 主要在 javax.imageio 包。JDK已经内置了常见图片格式的插件,但它提供了插件体系结构,第三方也可以开发插件支持其他图片格式。 javax.imageio.ImageIO类提供了一组静态方法进行最简单的图像I/O操作。读取一个标准格式(GIF、PNG或JPEG)的图片很简单: File f = new File("c:\images\myimage.gif"); BufferedImage bi = ImageIO.read(f); - - 获得海量数据 Java Image I/O API 会自动探测图片的格式并调用对应的插件进行解码,当安装了一个新插件,新的格式会被自动理解,程序代码不需要改变。 有些待识别的图片存在干扰,可以考虑按颜色分辨出干扰。例如,有的图片只有三种颜色,白色背景,红色字,黑色水平线,黑色水平线是干扰。把黑色去掉,只留下白色背景,红色字。 将彩色图片黑白化的常规做法是先转化为灰度图,在把灰度图转化为黑白图。为了用计算机来表示和处理颜色,必须采用定量的方法来描述颜色,即建立颜色模型。常用的有红、绿、蓝为原色的RGB颜色模型和描述色彩饱和度的HSV颜色模型。RGB彩色包含了亮度信息,即YUV模型中的Y分量,灰度计算公式如下: gray = 0.229 * r + 0.587 * g + 0.114 * b; 计算一个RGB颜色模型表示的像素的灰度的代码如下: /** * 计算一个RGB颜色的灰度 * @param pixel * @return 对应的灰度 */ private static int getGray(int pixel) { int r = (pixel >> 16) & 0xff; int g = (pixel >> 8) & 0xff; int b = (pixel) & 0xff; // 按公式计算出灰度值 int gray = (int)(0.229 * r + 0.587 * g + 0.114 * b); return gray; } 统计图片的灰度直方图的代码: int grayValues[]; //每个像素对应的灰度值 BufferedImage bi = readImageFromFile(imageFile); //得到图片的宽和高 int width = bi.getWidth(null); int height = bi.getHeight(null); //读取像素 - - 获得海量数据 int pixels[] = new int[width * height]; bi.getRGB(0, 0, width, height, pixels, 0, width); //计算每个像素的灰度,保存下来 grayValues = new int[width * height]; for(int i = 0; i < width * height; i++) { grayValues[i] = getGray(pixels[i]); } int hist[] = new int[256]; // 存放每种灰度的出现次数 for(int i = 0; i < grayValues.length; i++) { hist[grayValues[i]]++; // 统计每种灰度出现的次数,即得到直方图 } 可以根据灰度值来确定像素的二值化结果。最简单的方式是固定阈值法。例如,若灰度大于128,则认为是白色,灰度小于128,则认为黑色。 二值化的双峰法的原理是:认为图像由前景和背景组成,在灰度直方图上,前后二景都形成高峰,在双峰之间的最低谷处就是图像的阈值所在,可根据此值,对图像进行二值化。除此外,还可以考虑对颜色聚类,用K平均聚类方法方法把颜色聚成两类。 3.3.2 切分图像 对字符区域定位的CharRange类把带有字符的区域从图像中分离出一个矩形框出来。 public class CharRange { int x; //横坐标位置 int y; //纵坐标位置 int width; //宽度 int height; //高度 } 对图片进行垂直和水平方向的扫描的Entry类实现切割字符并且把字符大小归一化,也就是统一图像宽度和高度。Entry的主要成员变量及方法如下: public class Entry { static final int DOWNSAMPLE_WIDTH = 12;// 样本数据宽度 static final int DOWNSAMPLE_HEIGHT = 18;// 样本数据据高度 protected Image entryImage;// 存储检测的图像 protected Graphics entryGraphics;// 处理图形图像 protected int pixelMap[];// 存储图像像素 - - 获得海量数据 // 水平扫描图像并进行像素检测 protected boolean hLineClear(int x, int w, int y) { int totalWidth = entryImage.getWidth(null); for (int i = x; i <= w; i++) { if (pixelMap[(y * totalWidth) + i] != -1) return false; } return true; } // 垂直扫描图像并进行像素检测 protected boolean vLineClear(int x) { int w = entryImage.getWidth(null); int h = entryImage.getHeight(null); for (int i = 0; i < h; i++) { if (pixelMap[(i * w) + x] != -1) return false; } return true; } // 找到水平扫描时的上边界和下边界 void findVBound(CharRange cr) { for (int i = 0; i < cr.height; i++) { if (!hLineClear(cr.x, cr.width, i)) { cr.y = i; break; } } for (int i = cr.height - 1; i >= 0; i--) { if (!hLineClear(cr.x, cr.width, i)) { cr.height = i; break; } } } - - 获得海量数据 // 找到垂直扫描时的左边界和右边界 protected ArrayList findHBounds(int w, int h) { ArrayList bounds = new ArrayList(); int begin = 0; int end = w; boolean lastState = false; boolean curState = false; for (int i = 0; i < w; i++) { if (vLineClear(i)) { System.out.println("find blank:" + i); curState = false; } else { curState = true; } if (!lastState && curState) { begin = i; } else if (lastState && !curState) { end = (i - 1); CharRange cr = new CharRange(begin, 0, end, h); bounds.add(cr); } lastState = curState; } if (curState) { CharRange cr = new CharRange(begin, 0, w - 1, h); bounds.add(cr); } return bounds; } // 发现图像边界 protected ArrayList findBounds(int w, int h) { ArrayList bounds = findHBounds(w, h); for (CharRange cr : bounds) { findVBound(cr); } - - 获得海量数据 return bounds; } // 对样本数据进行采样、归一化 public ArrayList downSample() { int w = entryImage.getWidth(null); int h = entryImage.getHeight(null); ArrayList samples = new ArrayList(); PixelGrabber grabber = new PixelGrabber(entryImage, 0, 0, w, h, true); grabber.grabPixels(); pixelMap = (int[]) grabber.getPixels(); ArrayList bounds = findBounds(w, h); for (CharRange cr : bounds) { SampleData data = new SampleData("?", DOWNSAMPLE_WIDTH, DOWNSAMPLE_HEIGHT); System.out.println(cr); double ratioX = (double) (cr.width - cr.x + 1) / (double) DOWNSAMPLE_WIDTH; double ratioY = (double) (cr.height - cr.y + 1) / (double) DOWNSAMPLE_HEIGHT; for (int y = 0; y < data.getHeight(); y++) { for (int x = 0; x < data.getWidth(); x++) { if (downSampleQuadrant(x, y, ratioX, ratioY, cr.x, cr.y)) data.setData(x, y, true); else data.setData(x, y, false); } } data.ratio = (double) (cr.width - cr.x + 1) / (double) (cr.height - cr.y + 1); data.ratio = (data.ratio - 1) / 4; samples.add(data); } return samples; } // 在样本数据的指定范围内进行像素扫描 - - 获得海量数据 protected boolean downSampleQuadrant(double x, double y, double ratioX, double ratioY, double downSampleLeft, double downSampleTop) { int w = entryImage.getWidth(null); int startX = (int) (downSampleLeft + (x * ratioX)); int startY = (int) (downSampleTop + (y * ratioY)); int endX = (int) (startX + ratioX); int endY = (int) (startY + ratioY); for (int yy = startY; yy <= endY; yy++) { for (int xx = startX; xx <= endX; xx++) { int loc = xx + (yy * w); if (pixelMap[loc] != -1) return true; } } return false; } } 3.3.3 SVM分类 为了调用通用的模式识别代码来识别采样后的字符图像代表哪个字符,需要把这个图像识别问题抽象化一般的分类问题。对于一个输入对象x,有对应的输出类型y。在这里,考虑对数字图片分类,输入是采样后的“0101”序列,输出则是0-9十个数字。“0101”序列中的每一位作为一个特征。训练集合中的每个xi都有对应的yi这样对应的m个训练实例,记作:(x1; y1); … ; (xm; ym)。每个xi有k个特征。 SVM的分类方法可以分为训练的过程和识别的过程:在训练的过程中,要准备一些识别好的图片,学习出分类模型;在识别过程中,调用学习好的分类模型来分类。LIBSVM(http://www.csie.ntu.edu.tw/~cjlin/libsvm/)是台湾大学的林智仁教授开发的可以解决分类问题的SVM 软件包。LIBSVM支持多种编程语言,这里采用Java版本。LIBSVM使用svm_train 方法训练并返回一个支持向量机模型,然后可以调用svm_pridect 方法根据已训练好的模型进行预测。LIBSVM的输入特征是整数,输出特征是正整数表示的类型。在svm_node类的实例中用稀疏的方式存储单个训练向量中的单个特征: public class svm_node{   public int index; //特征编号   public double value; //特征对应的值 } 因为汉字是大字符集,识别较困难,一般以笔画为依据。对于价格和邮件地址等单纯由数字和字母组成的文本,可以简单的采用像素取样的方法。 在svm_problem类的实例中存储训练集中的所有样本及其所属类别。 - - 获得海量数据 public class svm_problem { //训练数据的实例数 public int l; //存放它们的目标值,类型值用整型数据,回归值用实数 public double[] y; //训练向量用二维矩阵表示 //矩阵的第一个维度的长度是训练的实例数,第二个维度是特征数量。 public libsvm.svm_node[][] x; } 训练数据的输入样本组成了一个SampleData实例的数组: public class SampleData{ protected boolean grid[][];//用二维布尔数组存储的采样数据 public double ratio; //宽高比,即width/height protected String letter; //对应的字符或字符串 } 学习输入样本,得到分类模型: //输入特征数量 int featureNum = Entry.DOWNSAMPLE_HEIGHT * Entry.DOWNSAMPLE_WIDTH + 1; int outputNumber = sampleList.size(); //准备好训练集 TrainingSet set = new TrainingSet(featureNum, outputNumber); set.setTrainingSetCount(sampleList.size()); for (int t = 0; t < sampleList.size(); t++) { int idx = 0; SampleData ds = (SampleData) sampleList.get(t); for (int y = 0; y < ds.getHeight(); y++) { for (int x = 0; x < ds.getWidth(); x++) { set.setInput(t, idx++, ds.getData(x, y) ? .5 : -.5); } } set.setInput(t, idx++, (ds.ratio)); set.setOutput(t, t); } - - 获得海量数据 svm_parameter param = new svm_parameter();//设置模型参数 param.svm_type = svm_parameter.C_SVC;//标准算法 param.kernel_type = svm_parameter.RBF; //训练采用RBF核函数 param.degree = 3; //多项式核函数的阶次 param.gamma = 1.0 / featureNum; param.coef0 = 0; param.nu = 0.5; param.cache_size = 100; //缓存内存大小设置为100M param.C = 1; param.eps = 1e-3; param.p = 0.1; param.shrinking = 1; //使用启发式 param.probability = 0; param.nr_weight = 0; param.weight_label = new int[0]; param.weight = new double[0]; svm_problem prob = new svm_problem();//训练集 prob.l = outputNumber; prob.x = new svm_node[prob.l][]; prob.y = new double[prob.l]; for (int i = 0; i < prob.l; i++){//设置输入及对应的输出 prob.x[i] = set.getInputSet(i); prob.y[i] = set.getOutput(i); } //检查参数的有效性 String error_msg = svm.svm_check_parameter(prob, param); //如果有错误则退出 if (error_msg != null) { System.err.print("Error: " + error_msg + "\n"); System.exit(1); } //执行训练 - - 获得海量数据 model = svm.svm_train(prob, param); 执行对指定图片的分类过程: LoadImage li = new LoadImage();//加载图像 Entry entry = new Entry(); entry.entryImage = li.loadImageFromFile(new File(imgFile));//加载图像 ArrayList samples = entry.downSample();//采样 //特征数组 svm_node[] input = new svm_node[Entry.DOWNSAMPLE_WIDTH* Entry.DOWNSAMPLE_HEIGHT + 1]; StringBuilder text = new StringBuilder();//存放识别结果 for (SampleData ds : samples) {//分别识别每个切分出的图像 int idx = 0; for (int y = 0; y < ds.getHeight(); y++) { for (int x = 0; x < ds.getWidth(); x++) { input[idx] = new svm_node();//设置分类特征,把采样点作为分类特征 input[idx].index = idx + 1; input[idx].value = ds.getData(x, y) ? .5 : -.5;//设置分类特征值 ++idx; } } input[idx] = new svm_node(); input[idx].index = idx + 1; input[idx].value = ds.ratio; int best = (int) svm.svm_predict(model, input);//用SVM方法分类 String result = map[best];//取得分类Id对应的字符串 //对全黑图像字符的特殊处理 if (result.equals("l") || result.equals(".") || result.equals("-")) { - - 获得海量数据 if (ds.ratio == 0) { result = "."; } else if (ds.ratio < 0) { result = "l"; } else if (ds.ratio > 0) { result = "-"; } } text.append(result); } 尽管SVM分类方法返回的结果准确度已经很高,但是仍然可能把字母“o”识别成了数字“0”,这样图像过于相似的情况下要靠上下文来识别,比如后面是h,则前面的符号更可能是字母“o”而不是数字“0”,实现上可以参考HMM(隐马模型)的介绍。 3.4 提取垂直行业信息 本节介绍在旅游或者医疗行业的特别的考虑。 3.4.1 医疗行业 医学编码是国际疾病编码(ICD)和手术操作编码,这些在发达国家是医保部门来对医院进行支付的首要条件,美国非常重视,在近几年用自然语言处理技术大大简化了这个编码过程,预测在2014年在美国就有27亿美元的市场。 比如你感冒了,感冒的编码是0001,然后打了个吊瓶,打吊瓶的编码是1001。再配合一些药物的编码。然后利用这些编码,结合一个价格表,就能知道你这次就医的花销,保险公司给你报销。编码后的用途是医保给医院进行支付,就是说什么代码有什么支付标准,所以医院很重视。 目前疾病编码是医院病案室专门有人查看病历并通过关键字查询等初级的检索方式进行查找,效率低,成本高。因为要确定一个疾病编码不只是医生下的诊断,还要参照病理学,实验室诊断,图像诊断等结果,综合起来才能确定。 有些疾病不是医生凭经验就能下决断的,还需要从相关文献,相关疾病史等很多很多的资料中查找出相应的资料出来,给医生下决断以参考。 - - 获得海量数据 3.4.2 旅游行业 出去玩的时候需要知道出发地和目的地。出发地组织人往往会提供手机号码。从手机号码可以提取地域信息。根据手机号码的前7位判断所属地域。取得地域信息的另外一个方法是根据用户IP地址判断。 private static final String QUERY_SQL = "select city from mobileloc where num = ?"; //根据手机号得到所属城市 public static String getStartCityByPhone(String mobile) { String startCity = null; if(mobile.trim().length() == 11) { mobile = mobile.substring(0,7);//根据前7位判断 ResultSet rs = DBManager.getInstance(). executeQueryByAdd(QUERY_SQL, mobile); if(rs.next()) { startCity = rs.getString("city"); } } return startCity; } //测试方法 public static void main(String[] args) { System.out.println(getStartCityByPhone("18903045009")); } 3.5 流媒体内容提取 相对于文本检索,音频和视频检索技术研究的比较少。常用的方法是提取出相关的文字描述来索引音频和视频。例如,把视频里面的声音通过语音识别(Speech Recognition),然后放入索引库,同时记录时间点。语音识别的方法有很多技术问题,比如电影里面有背景音乐,如何去除噪声和音乐比较麻烦,这种将导致转换率低,而且多语言混杂,需要多种语言处理包,另外,说话的人可能有各种方言,导致转换率非常低。另外把rmvb等视频文件格式语言中的字用屏幕文字识别转换也非常麻烦,因为涉及到多语言的转换,尤其是汉字这样的大字符集文字,除非写得很标准。 视频搜索可以用在电台或电视台制作节目上,他们往往有录制了几十年的数据,现在想从中找出一些内容做合集,语音搜索和人脸搜索可以用上;视频网站每天都会有很多用户上传内容,这些有可能被插入一些违法的内容进去,如果全部人工审核的话,成本很大。先用一边内容检索过滤,可以大大降低人工参与的工作量;用户个人拍录像时可以加一些语音标签,方便日后编辑。 - - 获得海量数据 3.5.1 音频流内容提取 音频是多媒体中的一种重要媒体。我们能够听见的音频频率范围是60Hz~20kHz,其中语音大约分布在300Hz~4kHz之内,而音乐和其他自然声响是全范围分布的。男人说话声音低沉,因为声带振动频率较低。而女人说话声音尖细,因为声带振动频率较高。童声高音频率范围为260Hz-880Hz,低音频率范围196Hz-700Hz。女声高音频率范围为220Hz-1.1KHz,低音频率范围为200Hz-700Hz。男声高音频率范围为160Hz-523Hz,低音频率范围为80Hz-358Hz。声音的响度对应强弱,而音高对应频率。频率是声音的物理特性,而音调则是频率的主观反映。 声音经过模拟设备记录或再生,成为模拟音频,再经数字化成为数字音频。数字化时的采样率必须高于信号带宽的2倍,才能正确恢复信号。样本可用8 位或16位比特表示。一般保存成wav文件格式。尽管wav文件可以包括压缩的声音信号,但是一般情况下是没有压缩的。 以前的许多研究工作涉及到语音信号的处理,如语音识别。当前容易自动识别孤立的字词,如用在专用的听写和电话应用方面,而对连续的语音识别则较困难,错误较多,但目前在这方面已经取得了突破性的进展。同时还有些研究辨别说话人的技术。 语音检索是以语音为中心的检索,采用语音识别等处理技术。可以检索如电台节目、电话交谈、会议录音等。许多语音信号处理的研究成果可以用于语音检索: (1)利用大词汇语音识别技术进行检索 这种方法是利用语音识别技术把语音转换为文本,从而可以采用文本检索方法进行检索。虽然好的连续语音识别系统在小心地操作下可以达到90%以上的词语正确度,但在实际应用中,如电话和新闻广播等,识别率并不高。即使这样,自动语音识别出来的文本仍然对信息检索有用,这是因为检索任务只是匹配包含在音频数据中的查询词句,而不是要求一篇可读性好的文章。例如,采用这种方法把视频的语音对话轨迹转换为文本脚本,然后组织成适合全文检索的形式支持检索。 (2)基于识别关键词进行检索 在无约束的语音中自动检测词或短语通常称为关键词的发现(Spotting)。利用该技术,识别或标记出长段录音或音轨中反映用户感兴趣的事件,这些标记就可以用于检索。如通过捕捉体育比赛解说词中“进球”的词语可以标记进球的内容。 (3)基于说话人的辨认进行分割 这种技术是简单地辨别出说话人话音的差别,而不是识别出说的是什么,叫做声纹识别。它在合适的环境中可以做到非常准确。将来也许可以用声纹识别代替刷卡来识别人。利用这种技术,可以根据说话人的变化分割录音,并建立录音索引。如用这种技术检测视频或多媒体资源的声音轨迹中的说话人的变化,建立索引和确定某种类型的结构(如对话)。例如,分割和分析会议录音,分割的区段对应于不同的说话人,可以方便地直接浏览长篇的会议资料。 - - 获得海量数据 Sphinx-4(http://cmusphinx.sourceforge.net/)是采用Java实现的一个语音识别软件。Sphinx是一个基于隐马尔科夫模型的系统,首先它需要学习一套语音单元的特征,然后根据所学来推断出所需要识别的语音信号最可能的结果。学习语音单元特征的过程叫做训练。应用所学来识别语音的过程有时也被称为解码。在Sphinx系统中,训练部分由Sphinx Trainer来完成,解码部分由Sphinx Decoder来完成。为了识别普通话,可以使用Sphinx Trainer自己建立普通话的声学模型。训练时需要准备好语音信号(Acoustic Signals),与训练用语音信号对应的文本(Transcript File)。当前Sphinx-4只能使用Sphinx-3 Trainer生成的Sphinx-3声学模型。有计划创建Sphinx-4 trainer用来生成Sphinx-4专门的声学模型,但是这个工作还没完成。 讲稿(transcript)文件中记录了单词和非讲话声的序列。序列接着一个标记可以把这个序列和对应的语音信号关联起来。 例如有160个wav文件,每个文件对应一个句子的发音。例如,播放第一个声音文件,会听到“a player threw the ball to me”,而且就这一句话。可以把这些wav或者raw格式的声音文件放到myasm/wav目录。 接下来,需要一个控制文件。控制文件只是一个文本文件。这里把控制文件命名为 myam_train.fileids (必须把它命名成[name]_train.fileids的形式,这里 [name]是任务的名字,例如myam),有每个声音文件的名字(注意,没有文件扩展名)。 0001 0002 0003 0004 接下来,需要一个讲稿文件,文件中的每行有一个独立文件的发声。必须和控制文件相对应。 例如,如果控制文件中第一行是0001,因此讲稿文件中的第一行的讲稿就是“A player threw the ball to me”,因为这是0001.wav 的讲稿。讲稿文件也是一个文本文件,命名成myam.corpus,应该有和控制文件同样多行。讲稿不包括标点符号,所以删除任何标题符号。例如: a player threw the ball to me does he like to swim out to sea how many fish are in the water you are a good kind of person - - 获得海量数据 以这样的顺序,对应0001、0002、0003和0004文件。 现在有了一些声音文件,一个控制文件和一个讲稿文件。 Sphinx-4由3个主要模块组成: 前端处理器(FrontEnd)、解码器(Decoder)、和语言处理器(Linguist)。前端把一个或多个输入信号参数化成特征序列。语言处理器把任何类型的标准语言模型和声学模型以及词典中的发声信息转换成为搜索图。这里,声学模型用来表示字符如何发音,语言模型用来评估一个句子的概率。解码器中的搜索管理器使用前端处理器生成的特征执行实际的解码,生成结果。在识别之前或识别过程中的任何时候,应用程序都可以发出对每个模块的控制,这样就可以有效的参与到识别过程中来。 前端处理 搜索算法 声学模型 词典 语言模型 识别出的文本 语音信号 图5.8 Sphinx-4结构 语音识别的准确率受限于其识别的内容,内容越简单,则识别准确率越高。所以一般根据某个应用场景来识别语音。例如电视台要给录制的新闻节目加字幕。有批处理和实时翻译两种方式,这里采用批处理的方式。以识别新闻节目为例,开发流程如下: 1.准备新闻语料库:语料库就是一个文本文件,每行一个句子。 2.创建语言模型:一般采用基于统计的n元语言模型,例如ARPA格式的语言模型。可以使用语言模型工具Kylm(http://www.phontron.com/kylm/)生成出ARPA格式的语言模型文件。 3.创建发声词典:对于英文可以采用ARPABET格式注音的发音词典。由于汉语是由音节(Syllable)组成的语言,所以可以采用音节作为汉语语音识别基元。每个音节对应一个汉字,比较容易注音。此外,每个音节由声母和韵母组成,声韵母作为识别基元也是一种选择。 4设置配置文件:在配置文件中设置词典文件和语言模型路径。 5.在eclipse中执行语音识别的Java程序。初始情况下,需要执行jsapi.exe或jsapi.sh生成出jsapi.jar文件。 - - 获得海量数据 edu.cmu.sphinx.tools.feature.FeatureFileDumper可以从音频文件导出特征文件,例如MFCC特征。一般提取语音信号的频率特征。找出语音信号中的音节叫做端点检测,也就是找出每个字的开始端点和结束端点。因为语音信号中往往存在噪音,所以不是很容易找准端点。 Transcriber.jar可以实现从声音文件导出讲稿文件: D:\sphinx4-1.0beta5\bin>java -jar -mx300M Transcriber.jar one zero zero zero one nine oh two one oh zero one eight zero three 3.5.2 视频流内容提取 视频信息一般由四部分组成:帧、镜头、情节、节目。视频流的内容可以从多个层次分析: l 底层内容建模,包括颜色、纹理、形状、空间关系、运动信息等。 l 中层内容建模,指视频对象(Video Object),在MPEG一4中包括了视频对象。对象的划分可根据其独特的纹理、运动、形状、模型和高层语义为依据。 l 高层内容建模(语义概念等),例如一段体育视频,可以提取出扣篮或射门的片断。 关键帧提取策略: 1. 设置一个最大关键帧数M; 2. 每个镜头的非边界过渡区的第一帧确定为关键帧; 3. 使用非极大值抑制法确定镜头边界系数极大值,并排序,以实现基于镜头边界系数的关键帧提取。 镜头边界检测方法有:只使用镜头边界系数的镜头边界检测--固定阈值法;结合相邻帧差的镜头边界检测--自适应阈值法。 可以使用Java媒体框架(JMF)API在Java语言中处理声音和视频等时序性的媒体。下面我们首先通过vid2jpg.java类来提取视频中所有的帧。 /** * 用这个方法转换从PushBufferDataSource 发出的数据 */ public void transferData(PushBufferStream stream) { - - 获得海量数据 stream.read(readBuffer); //为了防止数据对象中的内容被其他线程修改 Buffer inBuffer = (Buffer)(readBuffer.clone()); // 检查流是否已经结束 if(readBuffer.isEOM()) { System.out.println("End of stream"); return; } //实际把视频中的帧保存到文件 useFrameData(inBuffer); } 除了关键帧抽取,还可以考虑根据说话人识别和人脸识别来标注或搜索视频。例如,当某人说话的时候,找出视频库里所有该人说的话。对于有声音的视频可以利用视频中的音频信息检索内容。视频流和音频流在时间上是同步的。 手机应用IntoNow可以捕捉视频中的声音,通常它只需“听”20秒左右,就可以判断出你正在看的是什么节目,并列出这个节目的基本信息,以及同样在看这个节目的其他IntoNow用户。 3.6 存储提取内容 3.6.1 存入数据库 把提取出来的信息保存到Access数据库。 Access数据库管理软件是微软Office中的一部分。使用Access2003新建数据库文件goods.mdb ,然后创建表。 如果微软Office中Access安装不上,则可以用MDB Viewer Plus这个不需要安装的软件来管理mdb文件。下载地址:http://www.alexnolan.net/software/mdb_viewer_plus.htm MDB Viewer Plus使用Microsoft Data Access Components (MDAC)访问数据库。MDAC是Windows的一部分。 使用JDBC写入数据。使用JDBC-ODBC桥。 String accessFile = “d:/db/POI.mdb”; Class.forName("sun.jdbc.odbc.JdbcOdbcDriver"); //装载驱动程序 - - 获得海量数据 Connection con = DriverManager.getConnection( "jdbc:odbc:driver={Microsoft Access Driver (*.mdb)};DBQ=" + accessFile); //建立连接 String strSql = "insert into Table_Address(省名,地区,县市,乡镇村,邮政编码,区号) values (?,?,?,?,?,?)"; //创建一个PreparedStatement对象 PreparedStatement psmt = con.prepareStatement(strSql); psmt.setString(1, record.getProvince()); psmt.setString(2, record.getArea()); psmt.setString(3, record.getCity()); psmt.setString(4, record.getVillage()); psmt.setString(5, record.getPostcode()); psmt.setString(6, record.getCode()); psmt.executeUpdate(); con.close(); //关闭连接,这样才能把数据保存到mdb文件 3.6.2 写入维基 可以把提取出的结果写到索引或者维基。例如,Java Wiki Bot Framework(http://jwbf.sourceforge.net/)可以实现把内容写入到MediaWiki。 MediaWikiBot mediaWikiBot = new MediaWikiBot( "http://wiki.1798hw.com/index.php");//维基首页 mediaWikiBot.login("1798hw", "1798hw"); //登陆到维基 String title = "黄山";//标题 Article article = new Article(mediaWikiBot, title);//设置标题 article.setText("黄山内容介绍"); //设置内容 article.save(); //保存文章 为了实现自动写维基,可以把维基百科中在编辑状态的信息抓取过来。 http://zh.wikipedia.org/w/index.php?title=%E7%94%B5%E8%A7%86&action=edit 为了能够上传图片链接。需要修改mediawiki的配置文件Localseting.php。增加: $wgAllowExternalImages = true; $wgAllowCopyUploads = true; 就可以上传外部图片链接地址。 在写入维基时,只需要加入图片地址,就会自动显示图片。以下是上传图片链接示例: - - 获得海量数据 MediaWikiBot mediaWikiBot = new MediaWikiBot("http://wiki.1798hw.com/index.php"); mediaWikiBot.login("1798hw", "1798hw"); Article article = new Article(mediaWikiBot, title); article.setText(body); article.addTextnl(imgUrl); article.save(); 但这样做图片仍然保存在远程的服务器,而不是本地的地址。一般无法访问维基百科的图片,需要使用国外的代理来抓取维基百科中的正常的图片。 3.7 本章小结 怎么识别网页的编码?网页的头信息和meta域,还有二进制流本身。二进制流采用概率估计的方法。如果三个地方编码各不相同,则判别依据是:meta域 > 头信息 > 二进制流。 除了本章已经介绍过的HTMLParser和NekoHTML以外,Jsoup(http://jsoup.org/)提供了类似于JQuery的操作方法来取出和操作数据。 如果用Python,抓一个页面只需一句话: html = urllib2.urlopen('http://www.baidu.com') 至于解析库,Python中有基于HTML语法分析的 lxml.html、beautifulsope和PyQuery。Python也有对正则表达式的支持。 在本章介绍的从各种数据来源提取索引需要的信息,是爬虫开发中重要而且往往容易碰到问题的部分。各种文档格式处理方式总结如表所示。 格式 解析包 Microsoft Office OLE2 Compound Document Format(Excel, Word, PowerPoint, Visio,Outlook) Apache POI Microsoft Office 2007 OOXML Apache POI Adobe Portable Document Format (PDF) PDFBox Rich Text Format (RTF) RtfParser 英文文本 ICU4J - - 获得海量数据 格式 解析包 HTML NekoHTML XML Java的javax.xml 类 ZIP Archives Java内部的ZIP 类 TAR Archives Apache Ant GZIP compression Java内部的GZIPInputStream BZIP2 compression Apache Ant Image formats (metadata only) Java的javax.imageio类 Java class files ASM library (JCR-1522) Java JAR files ZIP + Java Class files MP3 audio org.farng.mp3 Open Document 直接解析XML Microsoft Office 2007 XML Apache POI MIDI文件 Java内部的javax.sound.midi.* DWG文件 DWGDirect - - 提取文档中的文本内容 第4章 中文分词原理与实现 有个经典笑话:护士看到病人在病房喝酒,就走过去小声叮嘱说:小心肝!病人微笑道:小宝贝。在这里,“小心肝!”这句话有歧义,从护士的角度理解是:“小心/肝”,在病人的角度理解是“小心肝”。如果使用中文分词切分成“小心/肝”则可以消除这种歧义。 和英文不同,中文词之间没有空格。很多读者可能会同意这样,因为可以省纸。实现专业的中文搜索引擎,比英文多了一项分词的任务。英语、法语和德语等西方语言通常采用空格或标点符号将词隔开,具有天然的分隔符,所以词的获取简单。但是中文、日文和韩文等东方语言,虽然句子之间有分隔符,但词与词之间没有分隔符,所以需要靠程序切分出词。另外,除了可以用于全文查找,中文分词的方法也被应用到英语手写体识别中。因为在识别手写体时,单词之间的空格就不很清楚了。 要解决中文分词准确度的问题,是否提供一个免费版本的分词程序供人下载使用就够了?但像分词这样的自然语言处理领域的问题,很难彻底的全部解决。例如,通用版本的分词也许需要做很多修改后才能用到手机上。所以需要要让人能看懂其中的代码与实现原理,并参与到改进的过程中才能更好的应用。 本章的中文分词和下章介绍的文档排重和关键词提取等技术都属于自然语言处理技术的范围。因为在中文信息处理领域,中文分词一直是一个值得专门研究的问题,所以单独作为一章。 4.1 Lucene中的中文分词 Lucene中处理中文的常用方法有三种。以“咬死猎人的狗”这句话的输出结果为例: 1. 单字方式:[咬] [死] [猎] [人] [的] [狗]; 2. 二元覆盖的方式:[咬死] [死猎] [猎人] [人的] [的狗]; 3. 分词的方式:[咬] [死] [猎人] [的] [狗]。 例如:《新华字典》或者《现代汉语词典》是按字索引的。 Lucene中的StandardTokenizer采用了单字分词的方式。CJKTokenizer采用了二元覆盖的实现方式。笔者开发的CnTokenizer采用了分词的方式,本章将介绍部分实现方法。 - - 提取文档中的文本内容 4.1.1 Lucene切分原理 Lucene中负责语言处理的部分在org.apache.lucene.analysis包。其中TokenStream类用来进行基本的分词工作。Analyzer类是TokenStream的外围包装类,负责整个解析工作。有人把文本解析比喻成人体的消化过程,输入食物,分解出有用的氨基酸和葡萄糖等。Analyzer类接收的是整段的文本,解析出有意义的词语。 通常不需要直接调用分词的处理类analysis,而是由Lucene内部来调用,其中: l 在做索引阶段,调用addDocument(doc)时,Lucene内部使用Analyzer来处理每个需要索引的列,如图4-1所示。 IndexWriter index = new IndexWriter(indexDirectory, new CnAnalyzer(), //用支持分词的分析器 !incremental, IndexWriter.MaxFieldLength.UNLIMITED); Tokenizer TokenFilter Document Document Writer Inverted Index add 图4-1 Lucene对索引文本的处理 l 在搜索阶段,调用QueryParser.parse(queryText)来解析查询串时,QueryParser会调用Analyzer来拆分查询字符串,但是对于通配符等查询不会调用Analyzer。 Analyzer analyzer = new CnAnalyzer(); //支持中文的分词 QueryParser parser = new QueryParser(Version.LUCENE_CURRENT,"title", analyzer); 因为在索引和搜索阶段都调用了分词过程,索引和搜索的切分处理要尽量一致。所以分词效果改变后需要重建索引。另外,可以用个速度快的版本,用来在搜索阶段切分用户的查询词,另外用一个准确切分的慢速版本用在索引阶段的分词。 为了测试Lucene的切分效果,下面是直接调用Analysis的例子: Analyzer analyzer = new CnAnalyzer(); //创建一个中文分析器 //取得Token流 TokenStream ts = analyzer.tokenStream("myfield",new StringReader("待切分文本")); while (ts.incrementToken()) {//取得下一个词 System.out.println("token: "+ts)); - - 提取文档中的文本内容 } 4.1.2 Lucene中的Analyzer 为了更好的搜索中文,先通过图4-2了解在Lucene中通过WhitespaceTokenizer、WordDelimiterFilter、LowercaseFilter处理英文字符串的流程。 LexCorp BFG-9000 WhitespaceTokenizer LexCorp BFG-9000 WordDelimiterFilter catenateWords=1 Lex Corp BFG 9000 LexCorp LowercaseFilter lex corp bfg 9000 lexcorp 图4-2 Lucene处理英文字符串流程 Lucene中的StandardAnalyzer对于中文采用了单字切分的方式。这样的结果是单字匹配的效果。搜索“上海”,可能会返回和“海上”有关的结果。 CJKAnalyzer采用了二元覆盖的方式实现。小规模搜索网站可以采用二元覆盖的方法,这样可以解决单字搜索 - - 提取文档中的文本内容 “上海”和“海上”混淆的问题。采用中文分词的方法适用于中大规模的搜索引擎。猎兔搜索提供了一个基于Lucene接口的Java版中文分词系统。 可以对不同的索引列使用不同的Analyzer来切分。例如可以对公司名采用CompanyAnalyzer来分析,对地址列采用AddressAnalyzer来分析。这样可以通过更细分化的切分方式来实现更准确合适的切分效果。 例如把“唐山聚源食品有限公司”拆分成表4-1所示的结果: 词 开始位置 结束位置 标注类型 唐山 0 2 City 聚源 2 4 KeyWord 食品 4 6 Feature 有限公司 6 10 Function 表4-1 公司名拆分结果表 这里的开始位置和结束位置是指词在文本中的位置信息,也叫做偏移量。例如“唐山”这个词在“唐山聚源食品有限公司”中的位置是0-2。OffsetAttribute属性保存了词的位置信息。TypeAttribute属性保存了词的类型信息。 切分公司名的流程如图4-4所示: - - 提取文档中的文本内容 北京盈智星科技发展有限公司 ComTokenizer 北京 盈智星 ComFilter 北京 盈智星 科技发展 有限公司 公司 科技发展 有限公司 图4-4 Lucene处理公司名流程 专门用来处理公司名的CompanyAnalyzer实现如下: public class CompanyAnalyzer extends Analyzer { public TokenStream tokenStream(String fieldName, Reader reader) { //调用ComTokenizer切分公司名 TokenStream stream = new ComTokenizer(reader); //调用ComFilter后续加工 stream = new ComFilter(stream); return stream; } } 对不同的数据写了不同用途的分析器,需要在同一个索引中针对不同的索引列使用。一般情况下只使用一个分析器。为了对不同的索引列使用不同的分析器,可以使用PerFieldAnalyzerWrapper。在PerFieldAnalyzerWrapper中,可以指定一个缺省的分析器,也可以通过addAnalyzer方法对不同的列使用不同的分析器。例如: PerFieldAnalyzerWrapper aWrapper = new PerFieldAnalyzerWrapper(new CnAnalyzer()); aWrapper.addAnalyzer("address", new AddAnalyzer()); - - 提取文档中的文本内容 aWrapper.addAnalyzer("companyName", new CompanyAnalyzer()); 在这个例子中,将对所有的列使用CnAnalyzer,除了地址列使用AddAnalyzer,公司名称列使用CompanyAnalyzer。像其他分析器一样,PerFieldAnalyzerWrapper可以在索引或者查询解析阶段使用。 4.1.3 自己写Analyzer Analyzer内部包括一个对字符串的处理流程,先由Tokenizer做基本的切分,然后再由Filter做后续处理。一个简单的分析器(Analyzer)的例子: public class MyAnalyzer extends Analyzer { public TokenStream tokenStream(String fieldName, Reader reader) { //以空格方式切分Token TokenStream stream = new WhitespaceTokenizer(reader); //删除过短或过长的词,例如 in、of、it stream = new LengthFilter(stream, 3, Integer.MAX_VALUE); //给每个词标注词性 stream = new PartOfSpeechAttributeImpl.PartOfSpeechTaggingFilter(stream); return stream; } } 一般在Tokenizer的子类实际执行词语的切分。需要设置的值有:和词相关的属性termAtt、和位置相关的属性offsetAtt。在搜索结果中高亮显示查询词时,需要用到和位置相关的属性。但是在切分用户查询词时,一般不需要和位置相关的属性。此外还有声明词类型的属性TypeAttribute。Tokenizer的子类需要重写incrementToken方法。通过incrementToken方法遍历Tokenizer分析出的词,当还有词可以获取时,返回true,已经遍历到结尾时,返回false。 基于属性的方法把无用的词特征和想要的词特征分隔开。每个TokenStream在构造时增加它想要的属性。在TokenStream的整个生命周期中都保留一个属性的引用。这样在获取所有和TokenStream实例相关的属性时,可以保证属性的类型安全。 protected CnTokenStream(TokenStream input) { super(input); termAtt = (TermAttribute) addAttribute(TermAttribute.class); } 在TokenStream.incrementToken()方法中,一个token流仅仅操作在构造方法中声明过的属性。例如,如果只要分词,则只需要TermAttribute。其他的属性,例如PositionIncrementAttribute或者PayloadAttribute都被这个TokenStream忽略掉了,因为这时不需要其他的属性。 - - 提取文档中的文本内容 public boolean incrementToken() throws IOException { if (input.incrementToken()) { final char[] termBuffer = termAtt.termBuffer(); final int termLength = termAtt.termLength(); if (replaceChar(termBuffer, termLength)) { termAtt.setTermBuffer(output, 0, outputPos); } return true; } return false; } 虽然也可以通过termAtt对象中的term方法返回词,但这个方法返回的是字符串,直接返回字符数组的termBuffer方法性能更好。下面是采用正向最大长度匹配实现的一个简单的Tokenizer。 public class CnTokenizer extends Tokenizer { private static TernarySearchTrie dic = new TernarySearchTrie("SDIC.txt");//词典 private TermAttribute termAtt;// 词属性 private static final int IO_BUFFER_SIZE = 4096; private char[] ioBuffer = new char[IO_BUFFER_SIZE]; private boolean done; private int i = 0;// i是用来控制匹配的起始位置的变量 private int upto = 0; public CnTokenizer(Reader reader) { super(reader); this.termAtt = ((TermAttribute) addAttribute(TermAttribute.class)); this.done = false; } public void resizeIOBuffer(int newSize) { if (ioBuffer.length < newSize) { // Not big enough; create a new array with slight // over allocation and preserve content final char[] newCharBuffer = new char[newSize]; System.arraycopy(ioBuffer, 0, newCharBuffer, 0, ioBuffer.length); ioBuffer = newCharBuffer; - - 提取文档中的文本内容 } } @Override public boolean incrementToken() throws IOException { if (!done) { clearAttributes(); done = true; upto = 0; i = 0; while (true) { final int length = input.read(ioBuffer, upto, ioBuffer.length - upto); if (length == -1) break; upto += length; if (upto == ioBuffer.length) resizeIOBuffer(upto * 2); } } if (i < upto) { char[] word = dic.matchLong(ioBuffer, i, upto);// 正向最大长度匹配 if (word != null) {// 已经匹配上 termAtt.setTermBuffer(word, 0, word.length); i += word.length; } else { termAtt.setTermBuffer(ioBuffer, i, 1); ++i;// 下次匹配点在这个字符之后 } return true; } return false; } } 不太可能搜索某些词,这样不被索引的词叫做停用词。Analyzer可能会去掉一些停用词。 - - 提取文档中的文本内容 4.1.4 Lietu中文分词 Lietu 中文分词程序由seg.jar的程序包和一系列词典文件组成。通过系统参数dic.dir指定词典数据文件路径。我们可以写一个简单的分词测试代码: String sentence = "有关刘晓庆偷税案"; //输入句子,返回单词组成的数组 String[] result = com.lietu.seg.result.Tagger.split(sentence); for (int i=0; i { private Character splitChar; //分隔字符 private T nodeValue; //值信息 private Map> children = new HashMap>();//孩子节点 } - - 提取文档中的文本内容 4.2.2 三叉Trie树 在一个三叉Trie树(TernarySearchTrie)中,每一个节点也是包括一个字符。但和标准Trie树不同,三叉Trie树的节点中只有三个位置相关的属性,一个指向左边的树,一个指向右边的树,还有一个向下,指向单词的下一个数据单元。三叉Trie树是二叉搜索树和标准Trie树的混合体。它有和标准Trie树差不多的速度但是和二叉搜索树一样只需要相对较少的内存空间。 通过选择一个排序后的词表的中间值,并把它作为开始节点,可以创建一个平衡的三叉树。再次以有序的单词序列(as at be by he in is it of on or to)为例。 首先把关键字“is”作为中间值并且构建一个包含字母“i”的根节点。它的直接后继结点包含字母“s”并且可以存储任何与“is”有关联的数据。对于“i”的左树,选择“be”作为中间值并且创建一个包含字母“b”的结点,字母“b”的直接后继结点包含“e”。该数据存储在“e”结点。对于“i”的右树,按照逻辑,选择“on”作为中间值,并且创建“o”结点以及它的直接后继结点“n”。最终的三叉树如图4-3所示: 图4-3 三叉树 垂直的虚线代表一个父节点的下面的直接的后继结点。只有父节点和它的直接后继节点才能形成一个数据单元的关键字;“i”和“s”形成关键字“is”,但是“i”和“b”不能形成关键字,因为它们之间仅用一条斜线相连,不具有直接后继关系。图4-3中带圈的节点为终止节点,如果查找一个词以终止节点结束,则说明三叉树包含这个词。从根节点开始查找单词 - - 提取文档中的文本内容 。以搜索单词“is”为例,向下到相等的孩子节点“s”,在两次比较后找到“is”。查找“ax”时,执行三次比较达到首字符“a”,然后经过两次比较到达第二个字符“x”,返回结果是“ax”不在树中。 TernarySearchTrie本身存储关键字到值的对应关系,可以当作HashMap对象来使用。关键字按照字符拆分成了许多节点,以TSTNode的实例存在。值存储在TSTNode的data属性中。TSTNode的实现如下: public final class TSTNode { /** 节点的值 */ public Data data=null;// data属性可以存储词原文和词性、词频等相关的信息 protected TSTNode loNode; //左边节点 protected TSTNode eqNode; //中间节点 protected TSTNode hiNode; //右边节点 protected char splitchar; // 本节点表示的字符 /** * 构造方法 * *@param splitchar 该节点表示的字符 */ protected TSTNode(char splitchar) { this.splitchar = splitchar; } public String toString() { return "splitchar:"+ splitchar; } } 基本的查找词典过程是:输入一个词,返回这个词对应的TSTNode对象,如果该词不在词典中则返回空。查找词典的过程中,从树的根节点匹配输入查询词。按字符从前往后匹配Key。匹配过程如下: protected TSTNode getNode(String key, TSTNode startNode) { if (key == null ) { return null; } int len = key.length(); if (len ==0) - - 提取文档中的文本内容 return null; TSTNode currentNode = startNode; //匹配过程中的当前节点的位置 int charIndex = 0; //表示当前要比较的字符在Key中的位置 char cmpChar = key.charAt(charIndex); int charComp; while (true) { if (currentNode == null) {//没找到 return null; } charComp = cmpChar - currentNode.splitchar; if (charComp == 0) {//相等 charIndex++; if (charIndex == len) {//找到了 return currentNode; } else { cmpChar = key.charAt(charIndex); } currentNode = currentNode.eqNode; } else if (charComp < 0) {//小于 currentNode = currentNode.loNode; } else {//大于 currentNode = currentNode.hiNode; } } } 三叉树的创建过程也就是在Trie树上创建和单词对应的节点。实现代码如下: //向词典树中加入一个单词的过程 private TSTNode addWord(String key) { TSTNode currentNode = root; //从树的根节点开始查找 int charIndex = 0; //从词的开头匹配 while (true) { //比较词的当前字符与节点的当前字符 int charComp =key.charAt(charIndex) - currentNode.splitchar; if (charComp == 0) {//相等 charIndex++; - - 提取文档中的文本内容 if (charIndex == key.length()) { return currentNode; } if (currentNode.eqNode == null) { currentNode.eqNode = new TSTNode(key.charAt(charIndex)); } currentNode = currentNode.eqNode; } else if (charComp < 0) {//小于 if (currentNode.loNode == null) { currentNode.loNode = new TSTNode(key.charAt(charIndex)); } currentNode = currentNode.loNode; } else {//大于 if (currentNode.hiNode == null) { currentNode.hiNode = new TSTNode(key.charAt(charIndex)); } currentNode = currentNode.hiNode; } } } 相对于查找过程,创建过程在搜索的过程中判断出链接的空值后创建相关的节点,而不是碰到空值后结束搜索过程并返回空值。 同一个词可以有不同的词性,例如“朝阳”既可能是一个“区”,也可能是一个“市”。可以把这些和某个词的词性相关的信息放在同一个链表中。这个链表可以存储在TSTNode 的Data属性中。 树是否平衡取决于单词的读入顺序。如果按排序后的顺序插入,则生成方式最不平衡。单词的读入顺序对于创建平衡的三叉搜索树很重要,但对于二叉搜索树就不是太重要。通过选择一个排序后的数据单元集合的中间值,并把它作为开始节点,我们可以创建一个平衡的三叉树。可以写一个专门的过程来生成平衡的三叉树词典。 /** * 在调用此方法前,先把词典数组k排好序 * @param fp 写入的平衡序的词典 * @param k 排好序的词典数组 * @param offset 偏移量 * @param n 长度 * @throws Exception - - 提取文档中的文本内容 */ void outputBalanced(BufferedWriter fp,ArrayList k,int offset, int n){ int m; if (n < 1) { return; } m = n >> 1; //m=n/2 String item= k.get(m + offset); fp.write(item);//把词条写入到文件 fp.write('\n'); outputBalanced(fp,k,offset, m); //输出左半部分 outputBalanced(fp,k, offset + m + 1, n - m - 1); //输出右半部分 } 取得平衡的单词排序类似于对扑克洗牌。假想有若干张扑克牌,每张牌对应一个单词,先把牌排好序。然后取最中间的一张牌,单独放着。剩下的牌分成了两摞。左边一摞牌中也取最中间的一张放在取出来的那张牌后面。右边一摞牌中也取最中间的一张放在取出来的牌后面,依次类推。 4.3 中文分词的原理 中文分词就是对中文断句,这样能消除文字的部分歧义。除了基本的分词功能,为了消除歧义还可以进行更多的加工。中文分词可以分成如下几个子任务: l 分词:把输入的标题或者文本内容等分成词。 l 词性标注(POS):给分出来的词标注上名词或动词等词性。词性标注可以部分消除词的歧义,例如“行”作为量词和作为形容词表示的意思不一样。 l 语义标注:把每个词标注上语义编码。   很多分词方法都借助词库。词库的来源是语料库或者词典,例如“人民日报语料库”或者《现代汉语大词典》。 为了探索适合中国国情的中文分词,不妨借鉴一下汽车产业曾经发生过的事情。日本的汽车充电电池公司使用全自动化生产线,大部分工作都交给机器人来完成,这样的生产线建一条至少需要数千万元的投资,可位于深圳的比亚迪公司把生产线分解成一个个可以人工完成的工序,在最后容易产生误差的环节则设计了很多简单实用的夹具来保证质量。 - - 提取文档中的文本内容 因为加工后的语料库往往很稀缺,为了使得人工可调整分词结果,这里所有的词典都采用方便人工编辑的文本格式。 中文分词的两类方法: l 机械匹配的方法:例如正向最大长度匹配(Forward Maximum Match)的方法和逆向最大长度匹配(Reverse Maximum Matching)的方法。 l 统计的方法:例如最大概率分词方法和最大熵的分词方法等。 正向最大长度匹配的分词方法实现起来很简单。每次从词典找和待匹配串前缀最长匹配的词,如果找到匹配词,则把这个词作为切分词,待匹配串减去该词,如果词典中没有词匹配上,则按单字切分。例如,Trie树结构的词典中包括如下的8个词语: 大 大学 大学生 活动 生活 中 中心 心 为了形成平衡的Trie树,把词先排序,排序后为: 中 中心 大 大学 大学生 心 活动 生活 按平衡方式生成的词典Trie树如图4-4所示,其中粗黑显示的节点可以做为匹配终止节点: 大 中 学 活 心 生 心 动 生 活 图4-4 三叉树 输入:“大学生活动中心”,首先匹配出“大学生”,然后匹配出“活动”,最后匹配出“中心”。切分过程如表4-2: - - 提取文档中的文本内容 已匹配上的结果 待匹配串 NULL 大学生活动中心 大学生 活动中心 大学生/活动 中心 大学生/活动/中心 NULL 表4-2正向最大长度匹配切分过程表 最后分词结果为:“大学生/活动/中心”。 在最大长度匹配的分词方法中,需要用到从指定字符串返回指定位置的最长匹配词的方法。例如,当输入串是“大学生活动中心”,则返回“大学生”这个词,而不是返回“大”或者“大学”。从Trie树搜索最长匹配单词的方法如下: public String matchLong(String key,int offset) { //输入字符串和匹配的开始位置 String ret = null; if (key == null || rootNode == null || "".equals(key)) { return ret; } TSTNode currentNode = rootNode; int charIndex = offset; while (true) { if (currentNode == null) { return ret; } int charComp = key.charAt(charIndex) - currentNode.spliter; if (charComp == 0) { charIndex++; if(currentNode.data != null){ ret = currentNode.data; //候选最长匹配词 } if (charIndex == key.length()) { return ret; //已经匹配完 } - - 提取文档中的文本内容 currentNode = currentNode.eqNode; } else if (charComp < 0) { currentNode = currentNode.loNode; } else { currentNode = currentNode.hiNode; } } } 测试matchLong方法: String sentence = "大学生活动中心";//输入字符串 int offset = 0;//匹配的开始位置 String ret = dic.matchLong(sentence,offset); System.out.println(sentence+" match:"+ret); 返回结果: 大学生活动中心 match:大学生 正向最大长度分词的实现代码如下: public void wordSegment(String sentence) {//传入一个字符串作为要处理的对象 int senLen = sentence.length();//首先计算出传入的字符串的字符长度 int i=0;//控制匹配的起始位置 while(i < senLen) {//如果i小于此字符串的长度就继续匹配 String word = dic.matchLong(sentence, i);//正向最大长度匹配 if(word!=null) {//已经匹配上 //下次匹配点在这个词之后 i += word.length(); //如果这个词是词库中的那么就打印出来 System.out.print(word + " "); } else{//如果在词典中没有找到匹配上的词,就按单字切分 word = sentence.substring(i, i+1); //打印一个字 System.out.print(word + " "); ++i;//下次匹配点在这个字符之后 } - - 提取文档中的文本内容 } } 因为采用了Trie树结构查找单词,所以和用HashMap查找单词的方式比较起来,这种实现方法代码更简单,而且切分速度更快。例如:“有意见分歧”这句话,正向最大长度切分的结果是:“有意/见/分歧”,逆向最大长度切分的结果是:“有/意见/分歧”。因为汉语的主干成分后置,所以逆向最大长度切分的精确度稍高。另外一种最少切分的方法是使每一句中切出的词数最小。 4.4 中文分词流程与结构 中文分词总体流程与结构如图4-5: 词库维护工具 文本格式的词库 词库构建工具 二进制格式的词库 切分工具 词查找模块 分词评测工具 测试语料库 训练语料库 训练工具 切分算法 图4-5中文分词结构图 简化版本的中文分词切分过程如下: 1. 生成全切分词图:根据基本词库对句子进行全切分,并且生成一个邻接链表表示的词图; - - 提取文档中的文本内容 2. 计算最佳切分路径:在这个词图的基础上,运用动态规划算法生成切分最佳路径; 3. 词性标注:可以采用HMM方法; 4. 未登录词识别:应用规则识别未登录词; 5. 按需要的格式输出结果。 复杂版本的中文分词切分过程如下: 1. 对输入字符串切分成句子:对一段文本进行切分,首先是依次从这段文本里面切分出一个句子出来,然后再对这个句子进行切分; 2. 原子切分:对于一个句子的切分,首先是通过原子切分,将整个句子切分成一个个的原子单元(即不可再切分的形式,例如ATM这样的英文单词可以看成不可再切分的); 3. 生成全切分词图:根据基本词库对句子进行全切分,并且生成一个邻接链表表示的词图; 4. 计算最佳切分路径:在这个词图的基础上,运用动态规划算法生成切分最佳路径; 5. 未登录词识别:进行中国人名、外国人名、地名、机构名等未登录名词的识别; 6. 重新计算最佳切分路径; 7. 词性标注:可以采用HMM方法或最大熵方法等; 8. 根据规则调整切分结果:根据每个分词的词形以及词性进行简单的规则处理,如:日期分词的合并; 9. 按需要的格式输出结果:例如输出成Lucene需要的格式。 4.5 全切分词图 为了消除分词中的歧异,提高切分准确度,需要找出一句话所有可能的词,生成全切分词图。 4.5.1 保存切分词图 如果待切分的字符串有m个字符,考虑每个字符左边和右边的位置,则有 m+1个点对应,点的编号从0到m。把候选词看成边,可以根据词典生成一个切分词图。切分词图是一个有向正权重的图。“有意见分歧”这句话的切分词图如图4-5所示: - - 提取文档中的文本内容 意见 分歧 有意 分 见 意 有 0 1 2 3 4 5 图4-5 中文分词切分路径 在“有意见分歧”的切分词图中:“有”这条边的起点是0,终点是1;“有意”这条边的起点是0,终点是2;依次类推。切分方案就是从源点0到终点5之间的路径。存在两条切分路径: 路径1: 0-1-3-5 对应切分方案: 有/ 意见/ 分歧/ 路径2: 0-2-3-5 对应切分方案: 有意/ 见/ 分歧/ 切分词图中的边都是词典中的词,边的起点和终点分别是词的开始和结束位置。 public class CnToken{ public String termText;//词 public int start;//词的开始位置 public int end;//词的结束位置 public int freq;//词在语料库中出现的频率 public CnToken(int vertexFrom, int vertexTo, String word) { start = vertexFrom; end = vertexTo; termText = word; } } 邻接表表示的切分词图由一个链表表示的数组组成。首先实现一个单向链表: public class TokenLinkedList implements Iterable { public static class Node { public TokenInf item; public Node next; - - 提取文档中的文本内容 Node(TokenInf item) { this.item = item; next = null; } } private Node head; public TokenLinkedList() { head = null; } public void put(TokenInf item) { Node n = new Node(item); n.next = head; head = n; } public Node getHead() { return head; } public Iterator iterator() {//迭代器 return new LinkIterator(head); } private class LinkIterator implements Iterator { Node itr; public LinkIterator(Node begin) { itr = begin; } public boolean hasNext() { return itr != null; } - - 提取文档中的文本内容 public TokenInf next() { if (itr == null) { throw new NoSuchElementException(); } Node cur = itr; itr = itr.next; return cur.item; } public void remove() { throw new UnsupportedOperationException(); } } public String toString() { StringBuilder buf = new StringBuilder(); Node cur = head; while (pCur != null) { buf.append(cur.item.toString()); buf.append('\t'); cur = cur.next; } return buf.toString(); } } 为了方便用动态规划的方法计算最佳前驱词,在此单向链表的基础上形成逆向邻接表: public class AdjList { private TokenLinkedList list[];// AdjList的图结构 /** * 构造方法:分配空间 */ public AdjList(int verticesNum) { - - 提取文档中的文本内容 list = new TokenLinkedList[verticesNum]; // 初始化数组中所有的链表 for (int index = 0; index < verticesNum; index++) { list[index] = new TokenLinkedList(); } } public int getVerticesNum() { return list.length; } /** * 增加一个边到图中 */ public void addEdge(TokenInf newEdge) { list[newEdge.end].put(newEdge); } /** * 返回一个迭代器,包含以指定点结尾的所有的边 */ public Iterator getAdjacencies(int vertex) { TokenLinkedList ll = list[vertex]; if(ll == null) return null; return ll.iterator(); } } 4.5.2 形成切分词图 首先从词典中形成以某个字符串的前缀开始的词集合。例如,以“中华人民共和国成立了”这个字符串前缀开始的词集合是“中”、“中华”、“中华人民共和国”,一共三个词。这三个词都存在于当前的词典中。如果要找出指定位置开始的所有词,下面是匹配的方法: public static class PrefixRet { public ArrayList values; - - 提取文档中的文本内容 public int end; //记录下次匹配的开始位置 } //如果匹配上则返回true,否则返回false public boolean getMatch(String sentence, int offset, PrefixRet prefix) { if (sentence == null || rootNode == null || "".equals(sentence)) { return false; } boolean match = matchEnglish(offset, sentence, prefix); if (match) { return true; } match = matchNum(offset, sentence, prefix); if (match) { return true; } prefix.end = offset+1; ArrayList ret = new ArrayList(); TSTNode currentNode = rootNode; int charIndex = offset; while (true) { if (currentNode == null) { prefix.values = ret; if (ret.size() > 0) { return true; } return false; } int charComp = sentence.charAt(charIndex) - currentNode.splitChar; if (charComp == 0) { charIndex++; if (currentNode.data != null) { //System.out.println(currentNode.data) ; ret.add(currentNode.data); } if (charIndex == sentence.length()) { - - 提取文档中的文本内容 prefix.values = ret; if (ret.size() > 0) { return true; } return false; } currentNode = currentNode.eqNode; } else if (charComp < 0) { currentNode = currentNode.loNode; } else { currentNode = currentNode.hiNode; } } } 对于英文和数字等需要特殊处理。“ATM柜员机” 这个字符串前缀开始的词集合是“ATM”。这里的“ATM”并不一定存在于当前的词典中。为了区分这两种情况,可以把每类需要特殊处理的词放在单独的过程执行。 //匹配英文的过程。返回第一个不是英文的位置。 int matchEnglish (int start, String sentence) //匹配数字的过程。返回第一个不是数字的位置。 int matchNum (int start, String sentence) 然后在统一的过程调用matchEnglish和matchNum。 getMatch(String sentence, int offset){ int ret= matchEnglish(offset,key); if(ret>offset) return EnglishWord; ret = matchNum(offset,key); if(ret>offset) return NumWord; //匹配普通词; } 这里还要用到对字符类型的判断。字符类型有字母、数字、中文。 public enum CharType { /** char type: SINGLE byte */ SINGLE, - - 提取文档中的文本内容 /** char type: delimiter */ DELIMITER, /** char type: Chinese Char */ CHINESE, /** char type: letter */ LETTER, /** char type: chinese number */ NUM, /** char type: index */ INDEX, /** char type: other */ OTHER } 可以用查表法来快速判断字符类型。 通过查词典形成切分词图的主体过程: for(int i=0;i P(),所以选择S1对应的切分。 如何尽快找到概率最大的词串?因为假设每个词之间的概率是上下文无关的。因此满足用动态规划求解所要求的最优子结构性质和无后效性。在动态规划求解的过程中并没有先生成所有可能的切分路径Si,而是求出值最大的P(Si)后,利用回溯的方法直接输出Si。 到节点Nodei为止的最大概率称为节点Nodei的概率: 因此 如果Wj的结束节点是Nodei,就称Wj为Nodei的前驱词。这里的prev(Nodei)就是节点i的前驱词集合。比如上面的例子中,候选词“有”就是节点1的前驱词,“意见”和“见”都是节点3的前驱词。 这里的StartNode(wj)是wj 的开始节点,也是节点i的前驱节点。 - - 提取文档中的文本内容 因此切分的最大概率max(P(S))就是P(Nodem)=P(节点m的最佳前驱节点)*P(节点m的最佳前驱词)。 按节点编号,从前往后计算如下: P(Node0)=1 P(Node1)= P(有) P(Node3)= P(Node1)*P(意见) 修改词的描述类成为: public class CnToken { public String termText; //词 public int start; //开始位置 public int end; //结束位置 public double logProb; //概率取对数,这样做是为了避免计算结果向下溢出 } 为了计算词的概率,需要在词典中保存所有词的总次数。 首先把切分方案抽象成一个邻接矩阵表示的图,然后按节点从左到右计算最佳前驱节点。计算节点i的最佳前驱节点实现代码如下: //计算节点i的最佳前驱节点,以及它的概率 void getPrev(AdjList g,int i){ //得到前驱词的集合 Iterator it = g.getPrev(i); double maxProb = Double.NEGATIVE_INFINITY; int maxNode = -1; //根据前驱词集合挑选最佳前趋节点 while(it.hasNext()) { CnToken itr =it.next(); double nodeProb = prob[itr.start]+itr.logProb;//候选节点概率 if (nodeProb > maxProb) { //候选节点概率最大的开始节点就是最佳前趋节点 maxNode = itr.start; maxProb = nodeProb; } } - - 提取文档中的文本内容 prob[i] = maxProb;//节点概率 prevNode[i] = maxNode;//最佳前驱节点 } 找出最大概率的切分路径整体计算流程如下: public static ArrayList maxProb(AdjList g) { int[] prevNode = new int[g.verticesNum]; //最佳前驱节点 double[] prob = new double[g.verticesNum]; //节点概率 prob[0] = 0;//节点0的初始概率是1,取log后是0 //按节点求最佳前驱 for (int index = 1; index < g.verticesNum; index++) { //求出最大前驱 getBestPrev(g,index,prevNode,prob); } ArrayList ret = new ArrayList(); //从右向左取最佳前驱节点,通过回溯发现最佳切分路径 for(int i=(g.verticesNum-1);i>0;i=prevNode[i]){ ret.add(i); } return ret; } 上面的计算中假设相邻两个词之间是上下文无关的。但实际情况并不如此,例如:如果前面一个词是数词,后面一个词更有可能是量词。如果前后两个词都只有一种词性,则可以利用词之间的搭配信息对分词决策提供帮助。 while (it.hasNext()) { CnToken itr = it.next(); long currentCost = itr.cost; int transProb = getTransProb(itr.type, i.type);//前一个词到下一个词的转移概率 currentCost += transProb;//注意不要让currentCost的值溢出 if (currentCost > maxFee) { maxID = itr; maxFee = currentCost; } } - - 提取文档中的文本内容 4.7 N元分词方法 在介绍N元模型之前,让我们先来做个香农游戏(Shannon Game)。我们给定一个词,然后猜测下一个词是什么。当我说“NBA”这个词时,你想到下一个词是什么呢?我想大家有可能会想到“篮球”,基本上不会有人会想到“足球”吧。 切分出来的词序列越通顺,越有可能是正确的切分方案。N元模型来衡量词序列搭配的合理性。N元模型指在句子中在n个单词序列后出现的单词 w的概率。 P(S)=P(w1,w2,...,wn)= P(w1)P(w2|w1)P(w3|w1,w2)…P(wn|w1w2…wn-1) 但是这种方法存在两个致命的缺陷:一个缺陷是参数空间过大,不可能实用化;另外一个缺陷是数据稀疏严重。为了解决这个问题,我们引入了马尔科夫假设:一个词的出现仅仅依赖于它前面出现的有限的一个或者几个词。 如果简化成一个词的出现仅依赖于它前面出现的一个词,那么就称为二元模型(Bigram)。即: P(S) = P(w1,w2,...,wn)= P(w1) P(w2|w1) P(w3|w1,w2)…P(wn|w1w2…wn-1) ≈P(w1) P(w2|w1)P(w3|w2)…P(wn|wn-1) 如果简化成一个词的出现仅依赖于它前面出现的两个词,就称之为三元模型(Trigram)。如果一个词的出现不依赖于它前面出现的词,叫做一元模型(Unigram),也就是已经介绍过的概率语言模型的分词方法。 在实践中用的最多的就是二元和三元了,而且效果很不错。高于四元的用的很少,因为训练它需要更庞大的语料,而且数据稀疏严重,时间复杂度高,精度却提高的不多。 二元模型考虑一个单词后出现另外一个单词的概率,是n元模型中的一种。例如:一般来说,“中国”之后出现“北京”的概率大于“中国”之后出现“北海”的概率,也就是: P(北京|中国)> P(北海|中国) P(wi|wi-1) ≈ freq(wi-1,wi) /freq(wi-1) 二元词表的格式是“左词@右词:组合频率”,例如: 中国@北京:100 中国@北海:1 可以把二元词表看成是基本词表的常用搭配。分词初始化时,先加载基本词表,对每个词编号,然后加载二元词表,只存储词的编号。 - - 提取文档中的文本内容 对于拼音转换等歧义较多的情况下也可以采用三元模型(Trigram),例如: P (海淀|中国,北京) > P (海龙|中国,北京) P(wi|wi-2,wi-1) ≈ freq(wi-2,wi-1,wi) /freq(wi-2,wi-1) 因为有些词作为开始词的可能性比较大,例如“在那遥远的地方”,“在很久以前”,这两个短语都以“在”这个词作为开始词。因此,在实际的n元分词过程中,增加虚拟的开始节点(Start)和结束节点(End),分词过程中考虑P(在|Start)。因此,如果把“有意见分歧”当成一个完整的输入,分词结果实际是:“Start/ 有/ 意见/ 分歧/ End”。 想象在下跳棋,跳2次涉及到3个位置。二元连接中的前后2个词涉及到3个节点。二元连接的实现代码如下: //计算节点i的最佳前驱节点 void getBestPrev(AdjList g,int i) { Iterator it1 = g.getPrev(i);//得到一级前驱词集合 double maxProb = Double.NEGATIVE_INFINITY; int maxPrev1 = -1; int maxPrev2 = -1; while(it1.hasNext()) { CnToken t1 = it1.next(); //得到一级前驱词对应的二级前驱词集合 Iterator it2 = g.getPrev(t1.start); while(it2.hasNext()) { CnToken t2 = it2.next(); int bigramFreq=getBigramFreq(t2,t1);//从二元词典找二元频率 //平滑后的二元概率 double biProb = lambda1*t1.freq + lambda2*(bigramFreq/t2.freq); double nodeProb = prob[t2.start]+(Math.log(biProb)); if (nodeProb > maxProb) {//概率最大的算作最佳前趋 maxPrev1 = t1.start; maxPrev2 = t2.start; maxProb = nodeProb; } } } - - 提取文档中的文本内容 prob[i] = maxProb; prev1Node[i] = maxPrev1; prev2Node[i] = maxPrev2; } 查找N元词典的方法有:可以采用Trie树的形式来存放N元模型的参数。与词典Trie树的区别在于:词典Trie树上每个结点对应一个汉字,而N元模型Trie树的一个结点对应一个词。或者可以把搭配信息存放在词典Trie树的叶子节点上。存储从词编号到频率的映射,采用折半查找。 public class BigramMap { public int[] keys;//词编号 public int[] vals;//频率 } 在自然语言处理中,N元模型可以应用于字符,衡量字符之间的搭配,或者词,衡量词之间的搭配。应用于字符的例子:可以应用于编码识别,将要识别的文本按照GB码和BIG5码分别识别成不同的汉字串,然后计算其中所有汉字频率的乘积。取乘积大的一种编码。 4.8 语料库 计算值用log形式存储。 所有的n元概率都在log空间计算。这样做是为了避免向下溢出。另外一个考虑是:加法比乘法快。 P1 * P2 * P3 * P4 = EXP(logP1+ logP2 + logP3 + logP4) ARPA文件格式是: n元 log(词序列的估计概率) 词序列 回退值 语言建模工具有: l C++实现的SRILM(http://www-speech.sri.com/projects/srilm/),它,可运行在Windows或Linux。 l Java实现的Kyoto Language Modeling Toolkit(http://www.phontron.com/kylm/)。 通过困惑度(Perplexity)来衡量语言模型。 困惑度是和一个语言事件的不确定性相关的度量。考虑词级别的困惑度。 “行”后面可以跟的词有“不行”、“代码”、“善” 、“走”。所以“行”的困惑度较高。 但有些词不太可能跟在 - - 提取文档中的文本内容 “行”后面,例如“您”、“分”。 而有些词的困惑度比较低,例如“康佳”等专有名词,后面往往跟着“彩电”等词。 语言模型的困惑度越低越好,相当于有比较强的消除歧义能力。如果从更专业的语料库学习出语言模型,则有可能获得更低的困惑度,因为专业领域中的词搭配更加可预测。 4.9 新词发现 词典中没有的,但是结合紧密的字或词有可能组成一个新词。 比如:“水立方”如果不在词典中,可能会切分成两个词“水”和“立方”。如果在一篇文档中“水”和“立方”结合紧密,则有“水立方”可能是一个新词。可以用信息熵来度量两个词的结合紧密程度。信息熵的一般公式是: 如果x和y的出现相互独立,则P(x,y)的值和p(x)p(y)的值相等,I(x,y)为0。如果x和y密切相关,P (x,y)将比P (x) P (y)大很多,I(x,y)值也就远大于0。如果x和y的几乎不会相邻出现,而它们各自出现的概率又比较大,那么I(x,y)将取负值,这时候x和y负相关。设f(C)是词C出现的次数,N是文档的总词数,则: 因此,两个词的信息熵I(C1,C2) = + = + - - 两个相邻出现的词叫做二元连接串。 public class Bigram { String one;//上一个词 String two;//下一个词 private int hashvalue = 0; Bigram(String first, String second) { this.one = first; this.two = second; this.hashvalue = (one.hashCode() ^ two.hashCode()); - - 提取文档中的文本内容 } } 从二元连接串中计算二元连接串的信息熵的代码如下: int index = 0; fullResults = new BigramsCounts[table.size()]; Bigrams key; int freq;//频率 double logn = Math.log((double)n); //文档的总词数取对数 double temp; double entropy; int bigramCount; //f(c1,c2) for( Entry e : table.entrySet()){//计算每个二元连接串的信息熵 key = e.getKey(); freq1 = oneFreq.get(key.one).freq; freq2 = oneFreq.get(key.two).freq; temp = Math.log((double)freq1) + Math.log((double)freq2); bigramCount = (e.getValue())[0]; entropy = logn+Math.log((double)bigramCount) - temp;//信息熵 fullResults[index++] = new BigramsCounts( bigramCount, entropy, key.one, key.two); } 新词语有些具有普遍意义的构词规则,例如“模仿秀”由“动词+名词”组成。统计的方法和规则的方法结合对每个文档中重复子串组成的候选新词打分候选新词打分,超过阈值的候选新词选定为新词。此外,可以用Web信息挖掘的方法辅助发现新词:网页锚点上的文字可能是新词,例如“美甲”。另外,可以考虑先对文档集合聚类,然后从聚合出来的相关文档中挖掘新词。 4.10 未登录词识别 有人问道:南京市长叫江大桥?你怎么知道的?因为看到一个标语——南京市长江大桥欢迎您。 - - 提取文档中的文本内容 未登录词在英文中叫做Out Of Vocabulary (简称OOV)词。常见的未登录词包括:人名、地名、机构名。 对识别未登录词有用的信息: l 未登录词所在的上下文。例如:“**教授”,这里“教授”是人名的下文;“邀请**”,这里“邀请”是人名的上文。 l 未登录词本身的概率。例如:不依赖上下文,直观的来看,“刘宇”可能是个人名,“史光”不太可能是个人名。采用未登录词的概率作为这种可能性的衡量依据。“刘宇”作为人名的概率是:“刘宇”作为人名出现的次数/人名出现的总次数。 例如:我爸叫李刚。 这里“动词 + 姓 + 名 + 标点符号”组成了一个识别规则。可以根据这个识别规则识别出“李刚”这个人名。这个规则的完整形式是: 动词 + 中国人名 + 标点符号 => 动词 + 姓 + 名 + 标点符号 所以可以通过匹配规则来识别未登录词。为了实现同时查找多个规则,可以把右边的模式组织成Trie树,左边的模式作为节点属性。全切分词图匹配上右边的模式后用左边的模式替换。 可以用二元或三元语言模型来整合未登录词本身的概率和未登录词所在的上下文这两种信息。 未登录地名识别过程是: 1.选取未登录地名候选串。 2.未登录地名特征识别。 3.对每个候选未登录地名根据特征判断是否真的地名。判断方法可以用SVM二值分类。 4.整合地名词图。 4.11 词性标注 词性用来描述一个词在上下文中的作用。例如描述一个概念的词叫做名词,在下文引用这个名词的词叫做代词。有的词性经常会出现一些新的词,例如名词,这样的词性叫做开放式的词性。另外一些词性中的词比较固定,例如代词,这样的词性叫做封闭式的词性。因为存在一个词对应多个词性的现象,所以给词准确的标注词性并不是很容易。比如:“改革”在“中国开始对计划经济体制进行改革”这句话中作为一个动词,在“医药卫生改革中的经济问题”中作为一个名词。把这个问题抽象出来就是已知单词序列 ,给每个单词标注上词性 - - 提取文档中的文本内容 。 不同的语言有不同的词性标注集。比如英文有反身代词,例如myself,而中文中则没有反身代词。为了方便指明词的词性,可以给每个词性编码。例如《PFR人民日报标注语料库》中把“形容词”编码成a,名词编码成n,动词编码成v,...。 词性标注有小标注集和大标注集。例如小标注集代词都归为一类,大标注集可以把代词进一步分成三类: l 人称代词:你 我 他 它 你们 我们 他们… l 疑问代词:哪里 什么 怎么 l 指示代词:这里 那里 这些 那些 采用小标注集比较容易实现,但是太小的标注集可能会导致类型区分度不够。例如在黑白二色世界中,可以通过颜色的深浅来分辨出物体,但是通过七彩颜色可以分辨出更多的物体。 参考《PFR人民日报标注语料库》的词性编码表,如表4-2所示: 表4-2 词性编码表 代码 名称 举例 a 形容词 最/d 大/a 的/u ad 副形词 一定/d 能够/v 顺利/ad 实现/v 。/w ag 形语素 喜/v 煞/ag 人/n an 名形词 人民/n 的/u 根本/a 利益/n 和/c 国家/n 的/u 安稳/an 。/w b 区别词 副/b 书记/n 王/nr 思齐/nr c 连词 全军/n 和/c 武警/n 先进/a 典型/n 代表/n d 副词 两侧/f 台柱/n 上/f 分别/d 雄踞/v 着/u dg 副语素 用/v 不/d 甚/dg 流利/a 的/u 中文/nz 主持/v 节目/n 。/w e 叹词 嗬/e !/w - - 提取文档中的文本内容 代码 名称 举例 f 方位词 从/p 一/m 大/a 堆/q 档案/n 中/f 发现/v 了/u g 语素 例如dg或ag h 前接成分 目前/t 各种/r 非/h 合作制/n 的/u 农产品/n i 成语 提高/v 农民/n 讨价还价/i 的/u 能力/n 。/w j 简称略语 民主/ad 选举/v 村委会/j 的/u 工作/vn k 后接成分 权责/n 明确/a 的/u 逐级/d 授权/v 制/k l 习用语 是/v 建立/v 社会主义/n 市场经济/n 体制/n 的/u 重要/a 组成部分/l 。/w m 数词 科学技术/n 是/v 第一/m 生产力/n n 名词 希望/v 双方/n 在/p 市政/n 规划/vn ng 名语素 就此/d 分析/v 时/Ng 认为/v nr 人名 建设部/nt 部长/n 侯/nr 捷/nr ns 地名 北京/ns 经济/n 运行/vn 态势/n 喜人/a nt 机构团体 [冶金/n 工业部/n 洛阳/ns 耐火材料/l 研究院/n]nt nx 字母专名 ATM/nx 交换机/n nz 其他专名 德士古/nz 公司/n o 拟声词 汩汩/o 地/u 流/v 出来/v p 介词 往/p 基层/n 跑/v 。/w q 量词 不止/v 一/m 次/q 地/u 听到/v ,/w r 代词 有些/r 部门/n s 处所词 移居/v 海外/s 。/w t 时间词 当前/t 经济/n 社会/n 情况/n - - 提取文档中的文本内容 代码 名称 举例 tg 时语素 秋/Tg 冬/tg 连/d 旱/a u 助词 工作/vn 的/u 政策/n ud 结构助词 有/v 心/n 栽/v 得/ud 梧桐树/n ug 时态助词 你/r 想/v 过/ug 没有/v uj 结构助词的 迈向/v 充满/v 希望/n 的/uj 新/a 世纪/n ul 时态助词了 完成/v 了/ ul uv 结构助词地 满怀信心/l 地/uv 开创/v 新/a 的/u 业绩/n uz 时态助词着 眼看/v 着/uz v 动词 举行/v 老/a 干部/n 迎春/vn 团拜会/n vd 副动词 强调/vd 指出/v vg 动语素 做好/v 尊/vg 干/j 爱/v 兵/n 工作/vn vn 名动词 股份制/n 这种/r 企业/n 组织/vn 形式/n ,/w w 标点符号 生产/v 的/u 5G/nx 、/w 8G/nx 型/k 燃气/n 热水器/n x 非语素字 生产/v 的/u 5G/nx 、/w 8G/nx 型/k 燃气/n 热水器/n y 语气词 已经/d 30/m 多/m 年/q 了/y 。/w z 状态词 势头/n 依然/z 强劲/a ;/w 以[把][这][篇][报道][编辑][一][下]为例,有5×1×1×2×2×2×3=120种词性标注可能性,哪种可能性最大? - - 提取文档中的文本内容 p q n v r q n v n v m c q f v 把 这 篇 报道 编辑 一 下 m 图4-6 词性标注 解决标注歧义问题最简单的一个方法是从单词所有可能的词性中选出这个词最常用的词性作为这个词的词性,也就是一个概率最大的词性,比如“改革”大部分时候作为一个名词出现,那么可以机械的把这个词总是标注成名词,但是这样标注的准确率会比较低,因为只考虑了频率特征。 考虑词所在的上下文可以提高标注准确率。例如在动词后接名词的概率很大。“推进/改革”中的“推进”是动词,所以后面的“改革”很有可能是名词。这样的特征叫做上下文特征。 隐马尔可夫模型(Hidden Markov Model简称HMM)和基于转换的学习方法是两种常用的词型标注方法。这两种方法都整合了频率和上下文两方面的特征来取得好的标注结果。具体来说,隐马尔可夫模型同时考虑到了词的生成概率和词性之间的转移概率。 很多生物也懂得同时利用两种特征信息。例如,箭鼻水蛇是一种生活在水中以吃鱼或虾为生的蛇。它是唯一一种长着触须的蛇类。箭鼻水蛇最前端触须能够感触非常轻微的变动,这表明它可以感触到鱼类移动时产生的细微水流变化。当在光线明亮的环境中,箭鼻水蛇能够通过视觉捕食小鱼。因此它能够同时利用触觉和视觉,也就是说光线的变化和水流的变化信息来捕鱼。 4.11.1 隐马尔可夫模型 词性标注的任务是:给定词序列W=w1,w2,…,wn,寻找词性标注序列T=t1,t2,…,tn,最大化概率:P(t1,t2,…,tn|w1,w2,…,wn)。 使用贝叶斯公式重新描述这个条件概率: - - 提取文档中的文本内容 P(t1,t2,…,tn)*P(w1,w2,…,wn|t1,t2,…,tn)/P(w1,w2,…,wn) 忽略掉分母P(w1,w2,…,wn),同时做独立性假设,使用n元模型近似计算P(t1,t2,…,tn)。例如使用二元连接,则有: 近似计算P(w1,w2,…,wn|t1,t2,…,tn):假设一个类别中的词独立于它的邻居,则有: 寻找最有可能的词性标注序列实际的计算公式: 这里把词w叫做显状态,词性t叫做隐状态。条件概率P(ti|ti-1)叫做转移概率。条件概率P(wi|ti)叫做发射概率。 抽象来说,基本的马尔可夫模型中的状态之间有转移概率。隐马尔可夫模型中有隐状态和显状态。隐状态之间有转移概率。一个隐状态对应多个显状态。隐状态生成显状态的概率叫做生成概率或者发射概率。在初始概率、转移概率以及发射概率已知的情况下,可以从观测到的显状态序列计算出可能性最大的隐状态序列,这个算法叫做维特比(Viterbi)算法。对于词性标注的问题来说,显状态是分词出来的结果——单词W,隐状态是需要标注的词性C。词性之间存在转移概率。词性按照某个发射概率产生具体的词。可以把初始概率、转移概率和发射概率一起叫做语言模型。因为它们可以用来评估一个标注序列的概率。采用隐马尔可夫模型标注词性的总体结构如图4-7所示。 T1 T2 Tn W1 T3 Wn … W2 W3 转移概率 发射概率 图4-7 词性标注中的隐马尔可夫模型 - - 提取文档中的文本内容 举例说明隐马尔可夫模型。假设只有词性:代词(r)、动词(v)、名词(n)和方位词(f)。 有如下一个简化版本的语言模型描述: start: go(r,1.0) emit(start,1.0) f: emit(来,0.1) go(n,0.9) go(end,0.1) v: emit(来,0.4) emit(会,0.3) go(f,0.1) go(v,0.3) go(n,0.5) go(end,0.1) n: emit(会,0.1) go(f,0.5) go(v,0.3) go(end,0.2) r: emit(他,0.3) go(v,0.9) go(n,0.1) 这里的start和end都是虚拟的状态,start表示开始,end表示结束。emit表示发射概率,go表示转移概率。语言模型中的值可以事前统计出来。中文分词中的语言模型可以从语料库统计出来。 这个语言模型的初始概率向量是表4-3。 表4-3 初始概率表 r n f end start 1.0 0 0 0 这个初始概率的意思是,代词是每个句子的开始。 转移概率矩阵是表4-4。 表4-4 转移概率表 下个词性 上个词性 start f v n r end start 1 f 0.9 0.1 v 0.1 0.3 0.5 0.1 n 0.5 0.3 0.2 r 0.9 0.1 - - 提取文档中的文本内容 例如第3行表示动词后是名词的可能性比较大,仍然是动词的可能性比较小,所以上个词性是动词,下一个词性是名词的概率是0.5,而上个词性是动词,下一个词性还是动词的概率是0.3。 r v v f n 0.3 0.3 0.5 0.9 0.1 start 1 end 0.1 0.1 0.1 图4-6 转移概率图 代表的发射概率(混淆矩阵)是: 表4-5 发射概率表 他 会 来 f 0.1 v 0.3 0.4 n 0.6 r 0.3 以发射概率表的第二行为例:如果一个词是动词,那么这个词是“来”的概率比“会”的概率大。 - - 提取文档中的文本内容 C |end 1 B A 他|r 0.3 来|v 0.4 来|f 0.1 会|n 0.6 0.3 0.3 0.5 0.9 0.1 |start 1 1 0.1 0.1 0.1 会|v 0.3 D E F G 图4-7 发射概率图 分词后的输入是:[start] [他] [会] [来] [end]。 考虑到某些词性更有可能作为一句话的开始,有些词性更有可能作为一句话的结束。这里增加了开始和结束的虚节点start和end。 每个隐状态和显状态的每个阶段组合成一个如图4-8所示的由节点组成的二维矩阵: 图4-8 维特比求解格栅 采用动态规划的方法求解最佳标注序列。每个词对应一个求解的阶段,当前节点概率的计算依据是: l 上一个阶段的节点概率; - - 提取文档中的文本内容 l 上一个阶段的节点到当前节点的转移概率; l 当前节点的发射概率。 动态规划的思想产生了维特比算法。维特比求解方法由两个过程组成:前向累积概率计算过程和反向回溯过程。前向过程按阶段计算。从图上看就是从前向后按列计算。分别叫做阶段“start”、“他”、“会”、“来”、“end”。 在阶段“start”计算: l Best(A) = 1 在阶段“他”计算: l Best(B) = Best(A) * P(r|start) * P(他|r) = 1*1*.3=.3 在阶段“会”计算: l Best(C)=Best(B) * P(v|r) * P(会|v) = .3*.9*.3= .081 l Best(D)=Best(B) * P(n|r) * P(会|n) = .3*.1*.6= .018 在阶段“来”计算: l Best(E) = Max [Best(C)*P(v|v), Best(D)*P(v|n)] * P(来|v) = .081*.3*.4= .00972 l Best(F) = Max [Best(C)*P(f|v), Best(D)*P(f|n)] * P(来|f)= .081*.1*.1= .00081 在阶段“end”计算: l Best(G) = Max [Best(E)*P(end|v), Best(F)*P(end|f)] * P(|end)= .00972*.1*1= .000972 执行回溯过程发现最佳隐状态(粗黑线节点),如图4-9所示: - - 提取文档中的文本内容 B A 他|r 0.3 来|v 0.4 来|f 0.1 会|n 0.6 0.3 0.3 0.5 0.9 0.1 |start 1 1 0.1 0.1 0.1 会|v 0.3 D E F G |end 1 C 图4-9 维特比求解过程 G的最佳前驱节点是E,E的最佳前驱节点是C,C的最佳前驱节点是B,B的最佳前驱节点是A。所以猜测词性输出:他/r 会/v 来/v。这样消除了歧义,判断出[会]的词性是动词而不是名词,[来]的词性是动词而不是方位词。 然后开始实现维特比算法。维特比算法又叫做在格栅上运行的算法,暗示可以用二维数组存储计算的中间结果,也就是累积概率。但是一个词可能的词性往往只是很少几种,所以一般采用稀疏的方式存储词性。首先定义节点类: public class Node { public Node bestPrev; //最佳前驱 public State tag; //隐状态 public double prob; //累积概率 } 初始化存储累积概率的二维节点: al = new ArrayList[symbols.size()]; for (int i = 0; i < symbols.size(); i++) { al[i] = new ArrayList(states.size()); } // 添加初始节点,即把发射到start概率不为零的节点添加到al[0]中 for (int j = 0; j < states.size(); j++) { double emitProb = states.get(j).emitprob(symbols.get(0)); if (emitProb > 0) { Node tmNode = new Node(states.get(j),emitProb); al[0].add(tmNode); - - 提取文档中的文本内容 } } // 初始化第一阶段以后的节点 for (int i = 1; i < symbols.size(); i++) { for (int j = 0; j < states.size(); j++) { double emitProb = states.get(j).emitprob(symbols.get(i)); if (emitProb > 0) { Node tmNode = new Node(states.get(j)); al[i].add(tmNode); } } } 维特比求解的前向累积过程主要代码如下: // stage表示显状态序号,跳过start状态 for (stage = 1; stage < al.length; stage++) { // i1表示显状态序号stage对应的隐状态节点维的序号 for (i1 = 0; i1 < al[stage].size(); i1++) { // i0表示i1上一显状态维的隐状态节点编号 for (i0 = 0; i0 < al[stage - 1].size(); i0++) { sym1 = symbols.get(stage); // 上一显状态维的隐状态节点 Node prevNode = al[stage - 1].get(i0); // 当前显状态维的隐状态节点 Node nextNode = al[stage].get(i1); s0 = prevNode.tag;// 上一显状态维的隐状态 s1 = nextNode.tag;// 当前显状态维的隐状态 // 上一显状态维的隐状态节点累积概率 prob = prevNode.prob; // 上一显状态维的隐状态到当前显状态维的隐状态的转移概率 double transprob = s0.transprob(s1); prob = prob * transprob; emitprob = s1.emitprob(sym1); // 这一步的累积概率是 //上一步的累积概率 * //上一个状态到这一个状态的转移概率 * - - 提取文档中的文本内容 //当前节点的发射概率 prob = prob * emitprob; // 找到当前节点的最大累积概率 if (nextNode.prob <= prob) { // 记录当前节点的最大累积概率 nextNode.prob = prob; // 记录当前节点的最佳前驱 nextNode.bestPrev = prevNode; } } } } 维特比求解的反向回溯过程用来寻找最佳路径,主要代码如下: public ArrayList backward() { ArrayList maxNode = new ArrayList(); Node endNode=al[symbols.size() - 1].get(0);//结束阶段对应的最有可能的隐状态 //从后往前找最有可能的隐状态 for (Node i = endNode; i != null; i = i.bestPrev) { maxNode.add(i);// 被选中的隐状态加入到结果路径 } return maxNode; } 统计人民日报语料库中词性间的转移概率矩阵: for (int i = 0; i < wordAndPOS.length - 1; i++) { int j = i + 1; String[] a = wordAndPOS[i].split("/"); String[] b = wordAndPOS[j].split("/"); int pre = getPOSId(a[1]);//前一个词性ID int next = getPOSId(b[1]);//后一个词性ID addWordPOSToMatrix(pre,next);//转移矩阵数组增加一个计数 } 统计某个词的发射概率: public String getFireProbability(CountPOS countPOS){ StringBuilder ret = new StringBuilder(); - - 提取文档中的文本内容 for(Entry m: posFreqMap.entrySet()){ //某词的这个词性的发射概率是 某词出现这个词性的频率 / 这个词性的总频率 double prob = (double)m.getValue()/ (double)(countPOS.getFreq(CorpusToDic.getPOSId(m.getKey()))); ret.append(m.getKey() + ":"+ prob +" "); } return ret.toString(); } 测试一个词的发射概率和相关的转移概率: String testWord = "成果"; System.out.println(testWord+" 的词频率:\n"+this.getWord(testWord)); System.out.println("词性的总频率:\n"+posSumCount); System.out.println (testWord+" 发射概率:\n"+this.getWord(testWord).getFireProbability(posSumCount)); System.out.println("转移频率计数取值测试:\n "+this.getTransMatrix("n","w")); printTransMatrix(); 例如,“成果” 这个的词的频率: nr:5 b:1 n:287 词性的总频率: a:34578 ad:5893 ag:315 an:2827 b:8734 c:25438 d:47426 dg:125 e:25 f:17279 g:0 h:48 i:4767 j:9309 k:904 l:6111 m:60807 n:229296 ng:4483 nr:35258 ns:27590 nt:3384 nx:415 nz:3715 o:72 p:39907 q:24229 r:32336 s:3850 t:20675 tg:480 u:74751 ud:0 ug:0 uj:0 ul:0 uv:0 uz:0 v:184775 vd:494 vg:1843 vn:42566 w:173056 x:0 y:1900 z:1338 “成果”的发射概率: nr: 0.00014181178739576834 b: 0.0001144950767117014 n:0.0012516572465285046 即“成果”这个词的作为名词的发射概率 =“成果”作为名词出现的次数/名词的总次数 = 287/229296 = 0.0012516572465285046 为了支持词性标注,需要在词典中存储词性和对应的次数。格式如下: - - 提取文档中的文本内容 滤波器 n 0 堵击 v 0 稿费 n 7 神机妙算 i 0 开设 vn 0 v 32 每行一个词,然后是这个词可能的词性和语料库中按这个词性出现的次数。存储基本词性相关信息的类如下: public class POSInf { public short pos=0; //词性,或者词的类别 public int freq=0; //词频,就是一个词在语料库中出现的次数。词频高就表示这个词是常用词。 public String seCode = null; //词的语义编码 } 同一个词可以有不同的词性,可以把这些和某个词的词性相关的信息放在同一个链表中。 4.11.2 基于转换的错误学习方法 想象在画一幅油画。一幅由蓝天、草地、白云组成的油画。先把整块画布涂成蓝色,然后把下面的三分之一用绿色覆盖,表示草地,然后把上面的蓝色用白色覆盖,表示白云。这样通过从大到小的修正,越来越接近最终的精细化结果。 基于转换的学习方法(Transformation Based Learning简称TBL)先把每个词标注上最可能的词性,然后通过转换规则修正错误的标注,提高标注精度。 一个转换规则的例子:如果一个词左边第一个词的词性是量词(q),左边第二个词的词性是数词(m),则将这个词的词性从动词(v)改为名词(n)。 他/r 做/v 了/u 一/m 个/q 报告/v 转换成: 他/r 做/v 了/u 一/m 个/q 报告/n 另一个转换规则的例子:如果一个词左边第一个词的词性是介词(p),则将这个词的词性从动词(v)改为名词(n)。 从形式上来看,转换规则由激活环境和改写规则两部分组成。例如,对于刚才的例子: l 改写规则是:将一个词的词性从动词(v)改为名词(n); - - 提取文档中的文本内容 l 激活环境是:该词左边第一个紧邻词的词性是量词(q),第二个词的词性是数词(m)。 可以从训练语料库中学习出转换规则。学习转换规则序列的过程如下: 1. 初始状态标注:用从训练语料库中统计的最有可能的词性标注语料库中的每个词。 2. 考察每个可能的转换规则:选择能最多的消除语料库标注错误数的规则,把这个规则加到规则序列最后。 3. 用选择出来的这个规则重新标注语料库。 4. 返回到2,直到标注错误没有明显的减少为止。 这样得到一个转换规则集序列,以及每个词最有可能的词性标注。 标注新数据分两步:首先,用最有可能的词性标注每个词。然后依次应用每个可能的转换规则到新数据。 使用基于转换的学习方法标注词性的实现代码如下: public List tag(List words) { List ret = new ArrayList(words.size()); for (int i = 0, size = words.size(); i < size; i++) { String[] ss = (String[]) lexicon.get(words.get(i)); if (ss == null) ss = lexicon.get(words.get(i).toLowerCase()); if (ss == null && words.get(i).length() == 1) ret.add(words.get(i) + "^"); if (ss == null) ret.add("NN"); else ret.add(ss[0]);//先给每个词标注上最有可能的词性 } //然后依次应用每个可能的转换规则 for (int i = 0; i < words.size(); i++) { String word = ret.get(i); // rule 1: DT, {VBD | VBP} --> DT, NN if (i > 0 && ret.get(i - 1).equals("DT")) { if (word.equals("VBD") || word.equals("VBP") || word.equals("VB")) { - - 提取文档中的文本内容 ret.set(i, "NN"); } } } return ret; } 4.12 平滑算法 语料是有限的,不可能覆盖所有的词汇。比如说N元模型,当N较大的时候,由于样本数量有限,导致很多的先验概率值都是0,这就是零概率问题。当n值是1的时候,也存在零概率问题,例如一些词在词表中,但是却没有出现在语料库中。这说明语料库太小了,没能包括一些本来可能出现的词的句子。 做过物理实验的都知道,我们一般测量了几个点后,就可以画出一条大致的曲线,这叫做回归分析。利用这条曲线,可以修正测量的一些误差,并且还可以估计一些没有测量过的值。平滑算法用观测到的事件来估计的未观察到的事件的概率。例如从那些比较高的概率值中匀一些给那些低的或者是0。为了更合理的分配概率,可以根据整个直方图分布曲线去猜那些为0的实际值应该是多少。 由于训练模型的语料库规模有限且类型不同,许多合理的搭配关系在语料库中不一定出现,因此会造成模型出现数据稀疏现象。数据稀疏在统计自然语言处理中的一个表现就是零概率问题。有各种平滑算法来解决零概率的问题。例如,我们对自己能做到的事情比较了解,而不太了解别人是否能做到一些事情,这样导致高估自己而低估别人。所以需要开发一个模型减少已经看到的事件的概率,而允许没有看到的事件发生。 平滑有黑盒方法和百盒方法两种。黑盒平滑方法把一个项目作为不可分割的整体。而白盒平滑方法把一个项目作为可分拆的,可用于n元模型。 加法平滑算法是最简单的一种平滑。加法平滑的原理是给每个项目增加lambda(1>=lambda>=0),然后再除以总数作为项目新的概率。因为数学家拉普拉斯(laplace)首先提出用加1的方法估计没有出现过的现象的概率,所以加法平滑也叫做拉普拉斯平滑。 下面是加法平滑算法的一个实现,注释中以词为例来理解代码: //根据原始的计数器生成平滑后的分布 public static Distribution laplaceSmoothedDistribution( GenericCounter counter, int numberOfKeys, double lambda) { Distribution norm = new Distribution();//生成一个新的分布 norm.counter = new Counter(); - - 提取文档中的文本内容 double total = counter.totalDoubleCount();//原始的出现次数 double newTotal = total + (lambda * (double) numberOfKeys);//新的出现次数 //有多大可能性出现零概率事件 double reservedMass = ((double) numberOfKeys - counter.size()) * lambda / newTotal; norm.numberOfKeys = numberOfKeys; norm.reservedMass = reservedMass; for (E key : counter.keySet()) { double count = counter.getCount(key); //对任何一个词来说,新的出现次数是原始出现次数加lambda norm.counter.setCount(key, (count + lambda) / newTotal); } if (verbose) { System.err.println("unseenKeys=" + (norm.numberOfKeys - norm.counter.size()) + " seenKeys=" + norm.counter.size() + " reservedMass=" + norm.reservedMass); System.err.println("0 count prob: " + lambda / newTotal); System.err.println("1 count prob: " + (1.0 + lambda) / newTotal); System.err.println("2 count prob: " + (2.0 + lambda) / newTotal); System.err.println("3 count prob: " + (3.0 + lambda) / newTotal); } return norm; } 需要注意的是reservedMass是所有零概率词的出现概率的总和,而不是其中某个词出现概率的总和。取得指定key的概率实现代码可以看到: public double probabilityOf(E key) { if (counter.containsKey(key)) { return counter.getCount(key); } else { int remainingKeys = numberOfKeys - counter.size(); if (remainingKeys <= 0) { return 0.0; } else { //如果有零概率的词, //则这个词的概率是reservedMass分摊到每个零概率词的概率 return (reservedMass / remainingKeys); } } - - 提取文档中的文本内容 } 这种方法中的lambda值不好选取,在接下来介绍的另外一种平滑算法Good-Turing方法中则不需要lambda值。为了说明Good-Turing方法,首先定义一些标记: 假设词典中共有x个词。在语料库中出现r次的词有Nr个。例如,出现1次的词有N1个。 则语料库中的总词数N=0*N0+ 1*N1+r*Nr+… 而x=N0+ N1+Nr+… 使用观察到的类别 r+1的全部概率去估计类别r的全部概率。计算中的第一步是估计语料库中没有见过的词的总概率p0 = N1 / N,分摊到每个词的概率是N1 / (N*N0)。第二步估计语料库中出现过一次的词的总概率p1 = N2*2 / N,分摊到每个词的概率是N2*2/ (N*N1)。依次类推,当r值比较大时,Nr可能是0,这时候不再平滑。 图4-10 词的概率图 Good-Turing平滑实现代码如下: public static Distribution goodTuringSmoothedCounter( GenericCounter counter, int numberOfKeys) { //收集计数数组,也就是直方图 int[] countCounts = getCountCounts(counter); //如果计数数组不可靠,就不要用Good-Turing方法 - - 提取文档中的文本内容 //而采用拉普拉斯平滑方法 for (int i = 1; i <= 10; i++) { if (countCounts[i] < 3) { return laplaceSmoothedDistribution(counter, numberOfKeys, 0.5); } } double observedMass = counter.totalDoubleCount(); double reservedMass = countCounts[1] / observedMass; //计算和缓存调整后的频率,同时也调整观察到的项目的总数 double[] adjustedFreq = new double[10]; for (int freq = 1; freq < 10; freq++) { adjustedFreq[freq] = (double) (freq + 1) * (double) countCounts[freq + 1] / (double) countCounts[freq]; observedMass -= ((double) freq - adjustedFreq[freq]) * countCounts[freq]; } double normFactor = (1.0 - reservedMass) / observedMass; Distribution norm = new Distribution(); norm.counter = new Counter(); //填充新的分布,同时重新归一化 for (E key : counter.keySet()) { int origFreq = (int) Math.round(counter.getCount(key)); if (origFreq < 10) { norm.counter.setCount(key, adjustedFreq[origFreq] * normFactor); } else { norm.counter.setCount(key, (double) origFreq * normFactor); } } norm.numberOfKeys = numberOfKeys; norm.reservedMass = reservedMass; return norm; } - - 提取文档中的文本内容 对条件概率的N元估计平滑: 这里的c*来源于GT估计。 例如:估计三元条件概率: 对于一个没见过的三元联合概率是: 对于一元和二元模型也是如此。 4.13 机器学习的方法 可以把机器学习的方法分成产生式和判别式两种。假定输入是x,类别标签是y。产生式模型估计联合概率 P(x, y),因为可以根据联合概率来生成样本,所以叫做产生式模型。判别式模型估计条件概率 P(y|x),因为没有x的知识,无法生成样本,只能判断分类,所以叫做判别式模型。 产生式模型可以根据贝叶斯公式得到判别式模型,但反过来不行。例如下面的情况: (1,0), (1,1), (2,0), (2, 1) 联合概率p(x, y)如下: P(1, 0) = 1/2, P(1, 1) = 0, P(2, 0) = 1/4, P(2, 1) = 1/4 计算出条件概率P(y|x): P(0|1) = 1, P(1|1) = 0, P(0|2) = 1/2, P(1|2) = 1/2 - - 提取文档中的文本内容 4.13.1 最大熵 熵(information entropy)是信息论的核心概念,它描述一个随机系统的不确定度。设离散随机变量X取值AK的概率为PK,这里k=1, 2,… ,n,,。则熵定义为: 最大熵原理(Maximum Entropy)描述在一定条件下,随机变量满足何种分布时熵取得最大值。例如,在离散随机变量情况下,当概率分布为平均分布,即时,熵取最大熵。 举个例子,一个快餐店提供3种食品:汉堡(B)、鸡肉(C)、鱼(F)。价格分别是1元、2元、3元。已知人们在这家店购买一种食品的平均消费是1.75元,求顾客购买这3种食品的概率。符合条件的概率有很多,但是一个稳定的系统往往都趋向于使得熵最大。如果假设买这三种食品的概率相同,那么根据熵公式,这不确定性就是1(熵等于1)。但是这个假设很不合适,因为这样算出来的平均消费是2元。我们已知的信息是: P(B)+P(C)+P(F) = 1 1*P(B)+2*P(C)+3*P(F) = 1.75 以及关于对概率分布的不确定性度量,熵: 对前两个约束,两个未知概率可以由第三个量来表示,可以得到: P(C) = 0.75 - 2*P(F) P(B) = 0.25 + P(F) 把上式代入熵的表达式中,熵就可以用单个概率P(F)来表示: 对这个单变量优化问题,很容易求出P(F)=0.216 时熵最大,S=1.517,P(B)=0.466,P(C)=0.318。 宾夕法尼亚州立大学的Ratnaparkhi将上下文信息、词性(名词、动词和形容词等)、句子成分通过最大熵模型结合起来,做出了当时世界上最 - - 提取文档中的文本内容 准确的词性标注系统和句法分析器。 opennlp.maxent(http://maxent.sourceforge.net/)是一个最大熵分类器的实现。 而http://opennlp.sourceforge.net则利用maxent实现了Ratnaparkhi提出的句法解析器算法。 maxent训练多类分类问题。Maxent的输出和SVM分类方法不同,SVM训练的是2类分类问题。如果训练的是多类问题,可以考虑用最大熵。 例如要做一个从英文句子查找人名的分类器。假设已经有了一个训练好的模型。从下面这个句子判断Terrence是否一个人名。 对于下面这句话: He succeeds Terrence D. Daniels, formerly a W.R. Grace vice chairman, who resigned. 首先要从句子中提取针对当前词“Terrence”的特征,并且转换成context: previous=succeeds current=Terrence next=D. currentWord IsCapitalized 送一个带有所有特征的字符串数组给模型,然后调用eval方法。 public double[] eval(String[] context); 返回的double[]包含了对每个类别的隶属概率。 这些隶属概率是根据context 输入的各种特征计算出来的。double[]的索引位置和每个类别对应。例如,索引位置0是代表"TRUE"的概率,而索引位置1是代表"FALSE"的概率。可以调用下面的方法查询某个索引类别的字符串名字: public String getOutcome(int i); 可以通过下面的方法取得eval返回的隶属概率数组中的最大概率所对应的类名: public String getBestOutcome(double[] outcomes); 简单的根据特征分类的例子: RealValueFileEventStream rvfes1 = new RealValueFileEventStream("D:/ /opennlp/maxent/real-valued-weights-training-data.txt"); GISModel realModel = GIS.trainModel(100,new OnePassRealValueDataIndexer(rvfes1,1)); - - 提取文档中的文本内容 String[] features2Classify = new String[] {"feature2","feature5"}; //输入特征数组返回分类结果,还可以对每个特征附加特征权重 double[] realResults = realModel.eval(features2Classify); for(int i=0; i transitions = new LinkedList();//转换到其他状态的数组 private boolean finalState = false; } 转换类定义了在什么条件下可以从哪些状态可以转换到下个状态,实现如下: public class Transition { private State nextState; //下个状态 private State state; //当前状态 private Guard guard; //从当前状态转换到下个状态的条件 } 在有限状态机中定义有效的状态转换: State startState = createState(); //创建开始状态 startState.setLabel("start"); setStartState(startState); reset(); //重置当前状态 //定义有限状态机 Guard numGuard = createGuard();//创建条件 numGuard.setEvent(InputEvent.yearNum); State yearNumState = createState(); yearNumState.setLabel("yearNum"); createTransition(startState, yearNumState, numGuard); //创建状态之间的转移 State yearTimeState = createState(); yearTimeState.setLabel("yearTime"); Guard yearGuard = createGuard(); yearGuard.setEvent(InputEvent.yearUnitEvent); createTransition(yearNumState, yearTimeState, yearGuard); - - 提取文档中的文本内容 Guard num2Guard = createGuard(); num2Guard.setEvent(InputEvent.digital2); State monthNumState = createState(); monthNumState.setLabel("monthNum"); createTransition(yearTimeState, monthNumState, num2Guard); State monthTimeState = createState(); monthTimeState.setLabel("monthTime"); Guard monthGuard = createGuard(); monthGuard.setEvent(InputEvent.monthUnitEvent); createTransition(monthNumState, monthTimeState, monthGuard); Guard dayNumGuard = createGuard(); dayNumGuard.setEvent(InputEvent.digital2); State dayNumState = createState(); dayNumState.setLabel("dayNum"); createTransition(monthTimeState, dayNumState, num2Guard); State dayTimeState = createState(); dayTimeState.setLabel("dayTime"); dayTimeState.setFinal(true); Guard dayGuard = createGuard(); dayGuard.setEvent(InputEvent.dayUnitEvent); createTransition(dayNumState, dayTimeState, dayGuard); 执行有限状态机: //从指定位置开始匹配数字 public static int matchDigital(int start, String sentence) { int i = start; int count = sentence.length(); - - 提取文档中的文本内容 while (i < count) { char c = sentence.charAt(i); if (c >= '0' && c <= '9' || c >= '0' && c <= '9') { ++i; } else { break; } } return i; } public static boolean isDate(String s) { //通过有限状态机判断输入的字符串是否有效日期 FSM fsm = FSM.getInstance(); //把输入串转换成有限状态机可以接收的事件 for(int i=0; i{ private static final long serialVersionUID = -5739693689170303932L; private static StopSet stopSet = new StopSet(); /** * * @return the singleton of dictionary */ public static StopSet getInstance(){ - - 自然语言处理 return stopSet; } //取得词典文件所在的路径 public static String getDir() { String dir = System.getProperty("dic.dir"); if (dir == null) dir = "/dic/"; else if( !dir.endsWith("/")) dir += "/"; return dir; } //加载停用词词典 private StopSet() { super(1000); String line; try{ String dic = "/stopword.txt"; InputStream file = null; if (System.getProperty("dic.dir") == null) file = getClass().getResourceAsStream(getDir()+dic); else file = new FileInputStream(new File(getDir()+dic)); BufferedReader in= new BufferedReader(new InputStreamReader(file,"GBK")); while( true ) { line = in.readLine(); if (line == null ) break; if (!"".equals(line)) { this.add(line); } } - - 自然语言处理 in.close(); }catch (Exception e) { e.printStackTrace(System.err); } } } 然后使用这个停用词表类: Set stopSet=StopSet.getInstance(); if( stopSet.contains(word) ){ //如果word在停用词表中 } 5.2 句法分析树 句法分析树一般用在机器翻译中,但是搜索引擎也可以借助句法分析树更准确的理解文本,从而更准确的返回搜索结果。比如有用户输入:“肩宽的人适合穿什么衣服”。如果返回结果中包括“肩宽的人穿什么衣服好?”,或者“肩膀宽宽的女孩子穿什么衣服好看?”可能是用户想要的结果。 OpenNLP(http://incubator.apache.org/opennlp/)包含一个句法分析树的实现。输入句子“Boeing is located in Seattle.”通过该程序分析后,返回的句法树如图5-1所示: - - 自然语言处理 图5-1 非词汇化的句法树 “咬/死/了/猎人/的/狗”这个经过中文分词切分后的句子有两个不同的理解。句法分析能够确定该句子的意义。也就是说句法分析树能消除歧义。 分析树的节点定义如下: /** 保存解析构件的数据结构 */ public class Parse { /**这个解析基于的文本字符串,同一个句子的所有解析共享这个对象*/ private String text; /** 这个构件在文本中代表的字符的偏移量 */ private Span span; /**这个解析的句法类型*/ private POSType type; /** 这个解析的孩子 */ private Parse[] children; - - 自然语言处理 } 在句法树的每个节点中还可以增加中心词(head)。中心词就是被修饰部分的词,比如“女教师”,中心词就是“教师”,而“女”就是定语了。 对中文来说,一般先分词然后形成树形结构。 句法分析树可以用自顶向下的方法或者自底向上的方法。chart parser是自顶向下的分析器。Earley parser是chart parser的一种。 OpenNLP采用了移进-归约(shift-reduce parser)的方法实现分析器。移进-归约分析器是一种自底向上的分析器。移进-归约算法的基本数据结构是堆栈。检查输入词并且决定是把它移进堆栈或者规约堆栈顶部的元素,把产生式右边的符号用产生式左边的符号替换掉。 移进-归约算法的四种操作是: l 移进(Shift):从句子左端将一个终结符移到栈顶。 l 归约(Reduce):根据规则,将栈顶的若干个符号替换成一个符号。 l 接受(Accept):句子中所有词语都已移进到栈中,且栈中只剩下一个符号S,分析成功,结束。 l 拒绝(Error):句子中所有词语都已移进栈中,栈中并非只有一个符号S,也无法进行任何归约操作,分析失败,结束。 例如,表5-1中的产生式列表: 编号 产生式 1 r ->我 2 v ->是 3 n ->县长 4 np -> r 5 np -> n 6 vp-> v np 7 s-> np vp 表5-1 产生式列表 - - 自然语言处理 其中第1,2,3条可以叫做词法规则(Lexical rule),5,6,7条叫做内部规则(Internal rule)。 移进-归约的方法分析词序列“我 是 县长”的过程如下: 栈 输入 操作 规则 我 是 县长 移进 我 是 县长 规约 (1) r ->我 r(1) 是 县长 规约 (4) np -> r np(4) 是 县长 移进 np(4) 是 县长 规约 (2)v ->是 np(4) v(2) 县长 移进 np(4) v(2) 县长 规约 (3) n ->县长 np(4) v(2) n(3) 规约 (5) np -> n np(4) v(2) np(5) 规约 (6)vp-> v np np(4) vp(6) 规约 (7) s-> np vp s(7) 接受 表5-2 分析词序列“我 是 县长”的过程 如果在每一步规约的过程中记录父亲指向孩子的引用,则可以生成一个完整的句法树,例如图5-2所示的句法树。 - - 自然语言处理 s np vp v np n r 是 县长 我 图5-2 非词汇化的句法树 词汇化规则如表5-3所示。 表5-3 词汇化规则列表 编号 产生式 4 np(我,r) -> r(我,r) 5 np(县长, n) -> n(县长, n) 6 vp(是, v)-> v(是, v) np(县长, n) 7 s(是, v)-> np(我,r) vp(是, v) 根据词汇化的规则可以生成如图5-3所示的词汇化句法树。 - - 自然语言处理 s(是,v) np(我,r) vp(是,v) v(是,v) np(县长,n) n(县长,n) r(我,r) 是 县长 我 图5-3 词汇化的句法树 产生式定义如下: public class Production { protected TokenType lhs; //产生式左边的非终结符 protected ArrayList rhs; //产生式右边的符号 } 递归方式实现的移进规约算法如下: //输入待分析的字符串,判断是否可以接受这个字符串 private boolean recognise(NonTerminal cat, ArrayList string, Stack stack) { //可以接受吗? if (string.size() == 0 && stack.size() == 1 && stack.peek().equals(cat)) { return true; } else if (string.size() == 0 && stack.size() == 1 ) { return false; } - - 自然语言处理 // 可以移进吗? if (string.size() > 0) { Terminal sym = (Terminal) string.get(0); ArrayList prods = grammar.getTerminalProductions(); for (int i = 0; i < prods.size(); i++) { // 是任何产生式的右侧的第一个符号吗? if (((Production) prods.get(i)).getRHS().get(0).equals(sym)) { //是的,就移进并检查剩下的 Symbol lhs = ((Production) prods.get(i)).getLHS(); ArrayList restString = new ArrayList(string); restString.remove(0); Stack shiftedStack = (Stack) stack.clone(); shiftedStack.push(lhs); if (recognise(cat, restString, shiftedStack)) { return true; } } } } // 可以规约吗? if (! stack.empty()) { ArrayList prods = (ArrayList) grammar.getRhsIndex().get(stack.peek()); if (prods == null) { return false; } // 堆栈中顶层的符号对应产生式的右边部分吗? for (int i = 0; i < prods.size(); i++) { Symbol lhs = ((Production) prods.get(i)).getLHS(); ArrayList rhs = ((Production) prods.get(i)).getRHS(); Stack reducedStack = (Stack) stack.clone(); if (rhs.size() > stack.size()) { continue; } ArrayList topOfStack = new ArrayList(); for (int j = 0; j < rhs.size(); j++) { - - 自然语言处理 topOfStack.add(reducedStack.pop()); } topOfStack = reverse(topOfStack); if (rhs.equals(topOfStack)) { //是的,则通过弹出右边的符号并推入左边的符号来规约 reducedStack.push(lhs); // 检查其余的 if (recognise(cat, string, reducedStack)) { return true; } } } } return false; } 基于统计的句法分析训练集是标注了结构的语料库。经过结构标注的语料库叫做树库,例如宾夕法尼亚大学树库(http://www.cis.upenn.edu/~chinese/)。 Stanford Parser(http://nlp.stanford.edu/software/lex-parser.shtml)实现了一个基于要素模型的句法分析器,其主要思想就是把一个词汇化的分析器分解成多个要素(factor)句法分析器。Stanford Parser将一个词汇化的模型分解成一个概率上下文无关文法(PCFG)和一个依存模型。 5.3 相似度计算 相似度计算的任务是根据两段输入文本的相似度返回从0到1之间的相似度值:完全不相似,则返回0;完全相同,则返回1。衡量两段文字距离的常用方法有:海明距离(Hamming distance)、编辑距离、欧氏距离、文档向量的夹角余弦距离、最长公共子串。 文档向量的夹角余弦相似度量方法将两篇文档看作是词的向量,如果x、y为两篇文档的向量,则: 如果余弦相似度为1,则x和y之间夹角为0°,并且除大小(长度)之外,x和y是相同的;如果余弦相似度为0,则x和y之间夹角为90°,并且它们不包含任何相同的词。 衡量文档相似度的另外一种常用方法是最长公共子串法。举例说明两个字符串s1 和s2的最长公共子串(Longest Common Subsequence简称LCS)。 - - 自然语言处理 假设s1 = { a , b , c , b , d , a , b },s2 = { b , d , c , a , b , a },则从前往后找,s1和s2的最长公共子串是LCS(s1, s2) = { b , c , b , a },如图5-4所示。 a , b , c , b , d , a , b b , d , c , a , b , a 图5-4 最长公共子串 s1 = “高新技术开发区北环海路128号” s2 = “高技区北环海路128号” 则s1和s2的最长公共子串为 LCS(s1, s2) = “高技区北环海路128号”。 使用动态规划的思想计算LCS的方法:引进一个二维数组num[][],用num[i][j]记录s1的前i个长度的子串与s2的前j个长度的子串的LCS的长度。 自底向上进行递推计算,那么在计算num[i][j]之前,num [i-1][j-1],num [i-1][j]与num [i][j-1]均已计算出来。此时再根据s1[i-1]和s2[j-1]是否相等,就可以计算出num[i][j]。 计算两个字符串的最长公共子串的实现如下: public static String longestCommonSubsequence(String s1, String s2){ int[][] num = new int[s1.length()+1][s2.length()+1]; //初始化为0的二维数组 //实际算法 for (int i = 1; i <= s1.length(); i++) for (int j = 1; j <= s2.length(); j++) if (s1.charAt(i - 1)==s2.charAt(j - 1)) num[i][j] = 1 + num[i-1][j-1]; else num[i][j] = Math.max(num[i-1][j], num[i][j-1]); System.out.println("最长公共子串的长度是:" + num[s1.length()][s2.length()]); int s1position = s1.length(), s2position = s2.length(); StringBuilder result = new StringBuilder(); - - 自然语言处理 while (s1position != 0 && s2position != 0) { if (s1.charAt(s1position - 1)==s2.charAt(s2position - 1)) { result. append (s1.charAt(s1position - 1)); s1position--; s2position--; } else if (num[s1position][s2position - 1] >= num[s1position - 1][s2position]) { s2position--; } else { s1position--; } } result.reverse(); return result.toString(); } 为了返回0到1之间的一个相似度值,根据LCS计算的打分公式如下: Sim(s1,s2)=LCS-Length(s1,s2)/min(Length(s1),Length(s2)) 最长公共子串(LCS)与夹角余弦相比,最长公共子串体现了词的顺序,而夹角余弦没有。显然,词的顺序在网页文档的相似性比较中本身就是一种重要的信息,一个由若干词语按顺序组成的句子和若干没有顺序的词语组成的集合有着完全不同的意义。完全有可能,两篇文档根本不同,但是夹角余弦值却很接近1,特别是当文档数规模很大的时候。 最长公共子串中比较的是字符,可以把字符抽象成Token序列。也就是说把最长公共子串的方法抽象成最长公共子序列。对英文文档计算相似度,可以先按空格分词,然后再计算最长公共子序列。 比如说“书香门第4号门”和“书香门第4号”相似度高,但是“书香门第4号门”和“书香门第5号门”相似度低。所以单从字面比较无法更准确的反映其相似性。如果只差一个字,还要看有差别的这个字是什么类型的。所以应用带权重的最长公共子串方法,首先对输入字符串进行切分成词,然后对切分出来的词数组应用带权重的最长公共子序列的相似度打分算法。让比较的元素E增加权重属性Weight。 public static double addSim(String n1, String n2) throws Exception { //首先执行同义词替换,例如把“地方税务局”替换成“地税局” String s1 = SynonymReplace.replace(n1); String s2 = SynonymReplace.replace(n2); - - 自然语言处理 double d = getSim(s1, s2); if (d > 0.7) { ArrayList ret1 = PoiTagger.basicTag(s1); ArrayList poiW1 = new ArrayList(); for (int i = 0; i < ret1.size(); i++) { PoiToken poi = ret1.get(i); PoiTokenWeight poiTokenWeight = new PoiTokenWeight(poi); poiW1.add(poiTokenWeight); } ArrayList ret2 = PoiTagger.basicTag(s2); ArrayList poiW2 = new ArrayList(); for (int i = 0; i < ret2.size(); i++) { PoiToken poi = ret2.get(i); PoiTokenWeight poiTokenWeight = new PoiTokenWeight(poi); poiW2.add(poiTokenWeight); } return getSim(poiW1, poiW2); } return d; } //应用带权重的LCS double getSim(ArrayLists1, ArrayLists2){ … if (s1.get(i-1).equals(s2.get(j-1))) { num[i][j] = s1.get(i-1).Weight + num[i-1][j-1];//增加权重 } else{ num[i][j] = Math.max(num[i-1][j], num[i][j-1]); } … } 上述的相似度计算方法中没有考虑词语之间的语义相关度。例如,“国道”和“高速公路”在字面上不相似,但是两个词在意义上有相关性。可以使用分类体系的语义词典提取词语语义相关度。基于语义词典的度量方法的计算公式,以下因素是最经常使用的: 1) 最短路径长度,即两个概念节点A和B之间所隔最少的边数量。 - - 自然语言处理 2) 局部网络密度,即从同一个父节点引出的子节点数量。显然,层次网络中的各个部分的密度是不相同的。例如,WordNet中的plant/flora部分时非常密集的,一个父节点包含了数百个子节点。对于一个特定节点(和它的子节点)而言,全部的语义块是一个确定的数量,所以局部密度越大,节点(即父子节点或兄弟节点)之间的距离越近。 3) 节点在层次中的深度。在层次树中,自顶向下,概念的分类是由大到小,大类间的相似度肯定要小于小类间的。所以当概念由抽象逐渐变得具体,连接它们的边对语义距离计算的影响应该逐渐减小。 4) 连接的类型,即概念节点之间的关系的类型。在许多语义网络中,上下位关系是一种最常见的关系,所以许多基于边的方法也仅仅考虑IS-A连接。事实上,如果其他类型的信息可以得到,如部分关系和整体关系,那么其他的关系类型对于边权重计算的影响也同样应该考虑。 5) 概念节点的信息含量。它的基本思想[3]是用概念间的共享信息作为度量相似性的依据,方法是从语义网中获得概念间的共享信息,从语料库的统计数据中获得共享信息的信息量,综合两者计算概念间的相似性。这种方法基于一个假设:概念在语料库中出现的频率越高,则越抽象,信息量越小。 6) 概念的释义。在基于词典的模型中--不论是基于传统词典,还是基于语义词典--词典被视为一个闭合的自然语言解释系统,每一个单词都被词典中其他的单词所解释。如果两个单词的释义词汇集重叠程度越高,则表明这两个单词越相似。 将上述六个因素进一步合并,则可归为三大因素:结构特点,信息量和概念释义。 5.4 文档排重 不同的网站间转载内容的情况很常见。即使在同一个网站,有时候不同的URL地址可能对应同一个页面,或者存在同样的内容以多种方式显示出来。所以,网页需要按内容做文档排重。 判断文档的内容重复有很多种方法,语义指纹的方法比较高效。语义指纹直接提取一个文档的二进制数组表示的语义,通过比较相等来判断网页是否重复。语义指纹是一个很大的数组,全部存放在内存会导致内存溢出,普通的数据库效率太低,所以这里采用内存数据库BerkeleyDB。可以通过BerkeleyDB判断该语义指纹是否已经存在。另外一种方法是通过第二章介绍过的布隆过滤器来判断语义指纹是否重复。 按词作维度的文档向量维度很高,可以把SimHash看成是一种维度削减技术。SimHash除了可以用在文档排重上,还可以用在任何需要计算文档之间的距离应用上,例如文本分类或聚类。 - - 自然语言处理 5.4.1 语义指纹 提取网页的语义指纹的方法是:对于每张网页,从净化后的网页中,选取最有代表性的一组关键词,并使用该关键词组生成一个语义指纹。通过比较两个网页的指纹是否相同来判断两个网页是否相似。 网络一度出现过很多篇关于“罗玉凤征婚”的新闻报道。其中的两篇新闻对比如下: 文档ID 文档1 文档2 标题 北大清华硕士不嫁的“最牛征婚女” 1米4专科女征婚 求1米8硕士男 应征者如云 内容 …24岁的罗玉凤,在上海街头发放了1300份征婚传单。传单上写了近乎苛刻的条件,要求男方北大或清华硕士,身高1米76至1米83之间,东部沿海户籍。而罗玉凤本人,只有1米46,中文大专学历,重庆綦江人。…此事经网络曝光后,引起了很多人的兴趣。“每天都有打电话、发短信求证,或者是应征。”罗玉凤说,她觉得满意的却寥寥无几,“到目前为止只有2个,都还不是特别满意”。… …24岁的罗玉凤,在上海街头发放了1300份征婚传单。传单上写了近乎苛刻的条件,要求男方北大或清华硕士,身高1米76至1米83之间,东部沿海户籍。而罗玉凤本人,只有1米46,中文大专学历,重庆綦江人。…此事经网络曝光后,引起了很多人的兴趣。“每天都有打电话、发短信求证,或者是应征。”罗玉凤说,她觉得满意的却寥寥无几,“到目前为止只有2个,都还不是特别满意”。… 对于这两篇内容相同的新闻,有可能提取出同样的关键词: “罗玉凤”、“征婚”、“北大”、“清华”、“硕士”。 这就表示这两篇文档的语义指纹也相同。 为了提高语义指纹的准确性,需要考虑到同义词,例如:“北京华联”和“华联商厦”可以看成相同意义的词。最简单的,可以做同义词替换。把“开业之初,比这还要多的质疑的声音环绕在北京华联决策者的周围”替换为“开业之初,比这还要多的质疑的声音环绕在华联商厦决策者的周围”。 设计同义词词典的格式是:每行一个义项,前面是基本词,后面是一个或多个被替换的同义词。例如: 华联商厦 北京华联 华联超市 这样会把“北京华联”和“华联超市”替换成“华联商厦”。对指定文本,要从前往后查找同义词词库中每个要替换的词,然后实施替换。同义词替换的实现代码分为两步。首先是查找Trie树结构的词典过程: public void checkPrefix(String sentence,int offset,PrefixRet ret) { - - 自然语言处理 if (sentence == null || root == null || "".equals(sentence)) { ret.value = Prefix.MisMatch; ret.data = null; ret.next = offset; return ; } ret.value = Prefix.MisMatch;//初始返回值设为没匹配上任何要替换的词 TSTNode currentNode = root; int charIndex = offset; while (true) { if (currentNode == null) { return; } int charComp = sentence.charAt(charIndex) - currentNode.splitchar; if (charComp == 0) { charIndex++; if(currentNode.data != null){ ret.data = currentNode.data;//候选最长匹配词 ret.value = Prefix.Match; ret.next = charIndex; } if (charIndex == sentence.length()) { return; //已经匹配完 } currentNode = currentNode.eqKID; } else if (charComp < 0) { currentNode = currentNode.loKID; } else { currentNode = currentNode.hiKID; } } } 然后是同义词替换过程: //输入待替换的文本,返回替换后的文本 - - 自然语言处理 public static String replace(String content) throws Exception{ int len = content.length(); StringBuilder ret = new StringBuilder(len); SynonymDic.PrefixRet matchRet = new SynonymDic.PrefixRet(null,null); for(int i=0;i=0)?1:0); t <<= c; simHash |= t ; } return simHash; } 要生成好的SimHash编码,就要让完全不同的特征差别尽量大,而相似的特征差别比较小。如果特征是枚举类型,只有两个可能的取值,例如是Open和Close。Open返回二进制位全是1的散列编码,而Close则返回二进制位全是0的散列编码。下面的代码将为指定的枚举值生成尽量不一样的散列编码。 public static long getSimHash(MatterType matter){ int b=1; //记录用多少位编码可以表示一个枚举类型的集合 int x=2; while(x ret = fingerprintSet.getSimSet(simHashKey, k); 把文档转换成SimHash后,文档排重就变成了海明距离计算问题。海明距离计算问题是:给出一个f位的语义指纹集合F和一个语义指纹fg,找出F中是否存在与fg只有k位差异的语义指纹。 最基本的一种方法是逐次探查法,先把所有和fg差k位的指纹找出来,然后用折半查找法查找排好序的指纹集合F。需要多少次折半查找呢?首先借助组合数生成器 - - 自然语言处理 CombinationGenerator来生成出和给定的语义指纹差别在2位以内的语义指纹: long fingerPrint = 1L; //语义指纹 int[] indices; //组合数生成的一种组合结果 //生成差2位的语义指纹 CombinationGenerator x = new CombinationGenerator(64, 2); int count =0; //计数器 while (x.hasMore()) { indices = x.getNext();//取得组合数生成结果 long simFP = fingerPrint; for (int i = 0; i < indices.length; i++) { simFP = simFP ^ 1L << indices[i];//翻转对应的位 } System.out.println(Long.toBinaryString(simFP));//打印相似语义指纹 ++count; } 这里运行的结果是count=2016。因为是从64位中选有差别的2位,所以计算公式是=64*63/2=2016。也就是说,要找出和给定语义指纹差别在2位以内的语义指纹需要探查2016次。 逐次探查法完整的查找过程如下: //输入要查找的语义指纹和k值,如果找到相似的语义指纹则返回真,否则返回假 public boolean containSim(long fingerPrint,int k) { //首先用二分法直接查找语义指纹 if(contains(fingerPrint)) { return true; } //然后用逐次探查法查找 int[] indices; for(int ki=1;ki<=k;++ki) { //找差1位直到差k位的 CombinationGenerator x = new CombinationGenerator(64, ki); while (x.hasMore()) { indices = x.getNext(); long simFP = fingerPrint; - - 自然语言处理 for (int i = 0; i < indices.length; i++) { simFP = simFP ^ 1L << indices[i]; } //查找相似语义指纹 if(contains(simFP)) { return true; } } } return false; } 在k值很小,而要找的语义指纹集合S中的元素不太多的情况下,可以用比逐次探查法更快的方法查找。 如果k值很小,例如k=1,可以给指纹集合S中每个元素生成出和这个元素差别在1位以内的元素。对于长整型的元素,差别在1位以内的元素只有65种可能。然后再把所有这些新生成的元素排序,最后用折半查找算法查询这个排好序的语义指纹集合simSet。生成法查找近似语义指纹的整体流程如图5-6所示。 语义指纹集合S 64位的指纹F 和S最多差k位的语义指纹集合simSet (排序) 精确匹配 图5-6 生成法查找近似语义指纹 对给定SimHash值n生成差1位的Hash值的代码如下: for(int j=0;j<64;j++){ long newSimHash=n^1L< comp = new Comparator(){ public int compare(SimHashData o1, SimHashData o2){ if(o1.q==o2.q) return 0; return (isLessThanUnsigned(o1.q,o2.q)) ? 1: -1; } }; // 比较无符号64位 static Comparator compHigh = new Comparator(){ public int compare(Long o1, Long o2){ o1 |= 0xFFFFFFFFFFFFL; o2 |= 0xFFFFFFFFFFFFL; //System.out.println(Long.toBinaryString(o1)); //System.out.println(Long.toBinaryString(o2)); //System.out.println((o1 == o2)); if(o1.equals(o2)) return 0; return (isLessThanUnsigned(o1,o2)) ? 1: -1; } }; // 比较无符号64位中的高16位 public void sort(){//对四个表排序 t2.clear(); t3.clear(); t4.clear(); for(SimHashData simHash:t1) { long t = Long.rotateLeft(simHash.q, 16); t2.add(new SimHashData(t,simHash.no)); t = Long.rotateLeft(t, 16); t3.add(new SimHashData(t,simHash.no)); - - 自然语言处理 t = Long.rotateLeft(t, 16); t4.add(new SimHashData(t,simHash.no)); } Collections.sort(t1, comp); Collections.sort(t2, comp); Collections.sort(t3, comp); Collections.sort(t4, comp); } 5.4.3 分布式文档排重 在批量版本的海明距离问题中,有一批查询语义指纹,而不是一个查询语义指纹。 假设已有的语义指纹库存储在文件F中,批量查询语义指纹存储在文件Q中。80亿个64位的语义指纹文件F大小是64GB。压缩后小于32GB。 一批有1M 大小的语义指纹需要批量查询,因此假设文件Q的大小是8MB。Google把文件F 和Q存放在GFS分布式文件系统。文件分成多个64MB 的组块。每个组块复制到一个集群中的3个随机选择的机器上。每个组块在本地系统存储成文件。 使用MapReduce框架,整个计算可以分成两个阶段。在第一阶段,有和F的组块数量一样多的计算任务(在MapReduce术语中,这样的任务叫做mapper)。 每个任务以整个文件Q作为输入在某个64MB的组块上求解海明距离问题。 public class SimHashMapper extends Mapper, SimHashSet4, SimHashData, HashSet>{ static int k=3; //在64MB的组块fingerprintSet上求解海明距离问题 public void map(ArrayList q, SimHashSet4 fingerprintSet, Context context) throws IOException, InterruptedException { for (SimHashData query : q) { HashSet ret = fingerprintSet.getSimSet(query.q, k); if(ret!=null) //收集相似的语义指纹集合 context.write(query, ret); } } - - 自然语言处理 } 一个任务以发现的一个近似重复的语义指纹列表作为输出。在第二阶段,MapReduce收集所有任务的输出,删除重复发现的语义指纹,产生一个唯一的、排好序的文件。 public class SimHashReducer extends Reducer, SimHashData, HashSet> { public void reduce(SimHashData key, Iterable> values, Context context) throws IOException, InterruptedException { HashSet dup = new HashSet(); for (ArrayList val : values) { dup.addAll(val); } context.write(key, dup); } } Google用200个任务(mapper),扫描组块的合并速度在1GB每秒以上。压缩版本的文件Q大小大约是32GB(压缩前是64GB)。因此总的计算时间少于100秒。压缩对于速度的提升起了重要作用,因为对于固定数量的任务(mapper),时间大致正比于文件Q的大小。 通过Job类构建一个任务。 Job job = new Job(conf, "Find Duplicate"); job.setJarByClass(FindDup.class); 5.5 中文关键词提取 关键词提取是文本信息处理的一项重要任务,例如可以利用关键词提取来发现新闻中的热点问题。和关键词类似,很多政府公文也有主题词描述。上下文相关广告系统也可能会用到关键词提取技术。可以给网页自动生成关键词来辅助搜索引擎优化(SEO)。 有很多种方法可以应用于关键词提取。例如,基于训练的方法和基于图结构挖掘的方法、基于语义的方法等。KEA(http://www.nzdl.org/Kea)是一个开源的关键词提取项目。 5.5.1 关键词提取的基本方法 提取关键词整体流程如图5-4: - - 自然语言处理 文本、系统参数输入 分词、过滤停用词 单个词的权重计算、排序 文本关键词 图5-4 关键词提取流程图 为了调节计算过程中用到的参数,可以建立关键词提取训练库。训练库包括训练文件(x.txt)和对应的关键词文件(x.key)。 依据如下几点来判断词的权重: l 利用TF*IDF公式,计算每个可能的关键词的TF*IDF:统计词频和词在所有文档中出现的总次数。TF(Term Frequence)代表词频,IDF(Invert Document Frequence)代表文档频率的倒数。比如说“的”在100文档中的40篇文档中出现过,则文档频率DF(Document Frequence)是40,IDF是1/40。“的”在第一篇文档中出现了15次,则TF*IDF(的) = 15 * 1/40=0.375。另外一个词 “反腐败”在这100篇文档中的5篇文档中出现过,则DF是5,IDF是1/5。“反腐败”在第一篇文档中出现了5次,则TF*IDF(反腐败) = 5 * 1/5=1。结果是:TF*IDF(反腐败)> TF*IDF(的)。 l 利用位置信息:开始和结束位置的词往往更可能是关键词。比如,利用下面的经验公式: double position = t.startOffset() / content.length(); position = position * position - position +2; 或者利用一个分段函数,首段或者末段的词的权重更大。 l 标题中出现的词比内容中的词往往更重要。 l 利用词性信息:关键词往往是名词或者名词结尾的词,而介词,副词,动词结尾的词一般不能组成词组。 l 利用词或者字的互信息:互信息大的单字有可能是关键词。 比如说。 l 利用标点符号:《 》 和 “ ” 之间的文字更有可能是关键词。例如: “汉芯一号”造假案。 l 构建文本词网络:将单个词语当作词网络的节点,若两个词语共同出现在文本的一句话中,网络中对应节点建立一条权值为1.0的边。在文本词网络上运行图结构挖掘算法(例如Page Rank或HITS算法)对节点进行权值计算。 - - 自然语言处理 l 把出现的名词词按语义聚类,然后提取出有概括性的词作为关键词。 首先定义词及其权重的描述类: public class WordWeight implements Comparable { public String word; //单词 public double weight; //权重 protected WordWeight(String word, double weight) { this.word = word; this.weight = weight; } public String toString() { return word + ":" + weight; } public int compareTo(WordWeight obj) { WordWeight that = obj; return (int) (that.weight - weight); } } 返回关键词的主要代码如下: //全部待选的关键词放入PairingHeap实现的优先队列 PairingHeap h = new PairingHeap(); //把单个单词放入优先队列 for (Entry it : wordTable.entrySet()) { word = it.getKey(); java.lang.Double tempDouble; if( word.length() ==1) tempDouble = new Double(0.0); else tempDouble = it.getValue(); h.insert( new WordWeight(word,tempDouble.doubleValue()) ); } //把词组放入优先队列 for(WordWeight we : ngram) { h.insert(we); } retNum = Math.min(retNum,h.size()); //返回的关键词数量 - - 自然语言处理 WordWeight[] fullResults = new WordWeight[retNum]; //关键词返回结果 for(int i=0;i0 ) {//如果没有超过指定叠代次数 for (int i = 1; i <= graph.numNodes(); i++) {//更新Authority值 Map inlinks = graph.inLinks(new Integer(i)); double authorityScore = 0; for (Integer id:inlinks.keySet()) { authorityScore += (hubScores.get(id)).doubleValue(); } authorityScores.put(new Integer(i), new Double(authorityScore)); } for (int i = 1;i <= graph.numNodes(); i++)//更新hub值 { Map outlinks = graph.outLinks(new Integer(i)); double hubScore = 0; for (Integer id:outlinks.keySet()) { hubScore += (authorityScores.get(id)).doubleValue(); } hubScores.put(new Integer(i),new Double(hubScore)); } normalize(authorityScores); //归一化authority值 normalize(hubScores); //归一化hub值 } } 如果认为节点之间的连接的重要程度不一样,也就是指向关系有强弱之分。考虑节点之间的连接重要度的实现。 public void computeWeightedHITS(int numIterations) { while(numIterations-->0 ) { for (int i = 1; i <= graph.numNodes(); i++) { Map inlinks = graph.inLinks(new Integer(i)); Map outlinks = graph.outLinks(new Integer(i)); double authorityScore = 0; double hubScore = 0; for (Entry in:inlinks.entrySet()) { authorityScore+=(hubScores.get(in.getKey())).doubleValue() * in.getValue(); } - - 自然语言处理 for (Entry out:outlinks.entrySet()) { hubScore += (authorityScores.get(out.getKey())).doubleValue() * out.getValue(); } authorityScores.put(new Integer(i),new Double(authorityScore)); hubScores.put(new Integer(i),new Double(hubScore)); } normalize(authorityScores); normalize(hubScores); } } 为了确保引号里的词一定最重要,例如:“金正日”加了引号,所以这个词很重要。还有些词在文档的标题中出现,也很重要。所以要修改HITS算法,把节点的初始authority提高,并且保证它会不变的很低。例如,把用TF*IDF方法算的词权重作为节点的初始hub或authority。 5.5.3 从网页中提取关键词 从网页中提取关键词的处理流程是: 1. 从网页中提取正文。 2. 从正文中提取关键词。 在H1标签中的词,或者黑体加粗的词可能更重要,更有可能是网页的关键词。另外,Meta中的KeyWords描述也有可能真实的反映了该网页的关键词,例如: 5.6 相关搜索词 搜索引擎中往往有个可选搜索词的列表,当搜索结果太少的时候,可以帮助用户扩展搜索内容,或者当搜索结果过多的时候,可以帮助用户深入定向搜索。一种方法是从搜索日志中挖掘字面相似的词作为相关搜索词列表。从一个给定的词语挖掘多个相关搜索词,可以用编辑距离为主的方法查找一个词的字面相似词,如果候选的相关搜索词很多,就要筛选出最相关的10个词。 - - 自然语言处理 5.6.1 挖掘相关搜索词 下面是利用Lucene筛选给定词的最相关词的方法。 private static final String TEXT_FIELD = "text"; /** * @param words 候选相关词列表 * @param word 要找相关搜索词的种子词 * @return * @throws IOException * @throws ParseException */ static String[] filterRelated(HashSet words, String word) { StringBuilder sb = new StringBuilder(); for(int i=0;i接口,线程池执行任务并通过Future的实例获取返回值。 实现Callable方法的任务类的主要实现: public class FindSimCall implements Callable { private HashSet words; // 总的搜索词集合 private String s; // 待发现相关词的词 public FindSimCall(HashSet w ,String source) { words = w; s = source; } @Override public String[] call() throws Exception { System.out.println(s); // 形成related words列表 // … - - 自然语言处理 return relatedWords; } } 主线程类的实现如下: int threads = 4; ExecutorService es = Executors.newFixedThreadPool(threads); Set> set = new HashSet>(); for (final String s : words) { FindSimCall task = new FindSimCall(words,s); Future future = es.submit(task); set.add(future); } FileOutputStream fos = new FileOutputStream(relatedWordsFile); OutputStreamWriter osw = new OutputStreamWriter(fos,"GBK"); BufferedWriter writer = new BufferedWriter(osw); for (Future future : set) { String[] ret = future.get(); for(String word:ret) { writer.write("%"+word); } writer.write( "\r\n" ); } writer.close(); 采用线程池可以充分利用多核CPU的计算能力,并且简化了多线程的实现。 5.7 信息提取 据说,看同一段视频,聪明人和一般人的差别在于:他会从视频中提取出自己感兴趣的信息。本节只介绍文本信息提取。 从文本中抽取用户感兴趣的事件、实体和关系,被抽取出来的信息以结构化的形式描述。然后存储在数据库中,为各种应用提供服务。例如:从新闻报道中抽取出什么地方发生车祸,什么地方堵车。 - - 自然语言处理 例如:从新闻报道中抽取出恐怖事件的详细情况:时间、地点、作案者、受害者、袭击目标、使用的武器等。从经济新闻中抽取出公司发布新产品的情况:公司名、产品名、发布时间、产品性能等。 华盛顿大学开发的开放式互联网信息提取系统“TEXTRUNNER”提取实体和它们之间的关系。例如“海德公园”和“英国”的关系是“位于”。当用户提问“海德公园位于哪里?”时,系统可以根据提取出的信息回答“英国”。依赖手工编写的提取规则,或者手工标注的训练例子来实现信息提取(Information Extraction)。 GATE(http://gate.ac.uk)是一个应用广泛的信息抽取的开放型基础架构,该系统对语言处理的各个环节――从语料收集、标注、重用到系统评价均能提供很好的支持。我们这里实现一个简化版本的信息抽取系统。 输入“北京盈智星公司”切分后标注成:“北京/行政区划 盈智星/关键词 公司/功能词”。根据标注的结果可以提取出“盈智星”这样的关键词。有个基本的词典用来存放行政区划,功能词表等特征,例如“北京”这个词是在一个行政区划词表中,“公司”在另外一个功能词词表中。提取地址会碰到很多词典中没有的需要识别的未登录词,例如“高东镇高东二路”,需要把“高东二路”这样不在词典中的路名识别出来。可以先把输入串抽象成待识别的序列“镇后缀 UNKNOW 号码 街后缀”,然后利用规则(也叫模板)来识别并提取信息。未登录地址识别规则可以表示成如下的形式: 镇后缀 未登录街道 =>镇后缀 UNKNOW 号码 街后缀 转换成代码实现: lhs = new ArrayList(); //左边的符号 rhs = new ArrayList(); //右边的符号 //镇后缀 UNKNOW 号码 街后缀 rhs.add(AddressType.SuffixTown); rhs.add(AddressType.Unknow); rhs.add(AddressType.No); rhs.add(AddressType.SuffixStreet); //镇后缀 未登录街道 lhs.add(new AddressSpan(1,AddressType.SuffixTown));//归约长度是1 //把“UNKNOW 号码 街后缀”3个符号替换成“未登录街道”,因此归约长度是3 lhs.add(new AddressSpan(3,AddressType.Street)); //加到规则库 addProduct(rhs, lhs); “UNKNOW 号码 街后缀”合并成“街道”,可以记录下内部结构,这样方便后续处理。 - - 自然语言处理 为了提高提取准确性,规则往往设计成比较长的形式。长的规则往往更多的参考上下文,覆盖面小,但是更准确。短的规则会影响更多的提取结果,可能这一条信息靠这条规则提取正确了,却有更多的其它记录受影响。 设计规则存储格式:“右边的符号列表@左边的符号列表”。左边的符号列表用“整数值,类型”来表示,例如: Town,SuffixProvince @ 2, Province Unknow,SuffixTown,Unknow,No,SuffixStreet@2,Town,3,Street Town,Unknow,No,SuffixStreet@1,Town,3,Street City,Unknow,Street@1,City,2,Street Unknow,SuffixStreet@2,Street Unknow,SuffixLandMark@2,LandMark Unknow,No,SuffixStreet@3,Street 读入规则的程序: StringTokenizer st = new StringTokenizer(line, "@");//分成左右符号串 StringTokenizer rhst = new StringTokenizer(st.nextToken(), ","); //逗号分隔 StringTokenizer lhst = new StringTokenizer(st.nextToken(), ","); //逗号分隔 ArrayList rhs = new ArrayList(); //右边的符号 ArrayList lhs = new ArrayList(); //左边的符号 while (rhst.hasMoreTokens()) { lhs.add(PoiType.valueOf(rhst.nextToken()));//左边类型 } while (lhst.hasMoreTokens()) { rhs.add(new PoiSpan(Integer.parseInt(lhst.nextToken()), //右边符号长度 PoiType.valueOf(lhst.nextToken()))); //右边符号类型 } addProduct(lhs, rhs); //加入到规则库 可以从Java源代码中抽取出规则到配置文件中,实现代码如下: String[] sa=str.split("addProduct");// 分割每一条规则 for(String s : sa){ //遍历每一条规则并处理 //通过正则表达式匹配找出该条规则中所有的右边的符号 Pattern p=Pattern.compile("rhs\\.add.POIType1\\.([a-zA-Z]+)"); Matcher m=p.matcher(s); String result=""; while(m.find()) { result=result+m.group(1)+","; - - 自然语言处理 } if("".equals(result)) { break; } //通过正则表达式匹配找出该条规则中所有的左边的符号 result=result.substring(0, result.length()-1)+"@"; Pattern p2=Pattern.compile("lhs\\.add.new\\sPoiSpan1.([0-9]+),\\sPOIType1\\.([a-zA-Z]+)"); Matcher m2=p2.matcher(s); while(m2.find()) { String num=m2.group(1); String type=m2.group(2); result+=num+","+type+","; } //输出提取出的规则 System.out.println(result.substring(0, result.length()-1)); } 规则替换可能会进入死循环,因为可能出现重复应用规则的情况。如果规则的左边部分小于右边部分,也就是说替换后的长度越来越短,应用这样的规则不会导致死循环。当规则的左边部分和右边部分相等时,可以用Token的类型的权重和来衡量,规则左边部分的权重和必须小于右边部分的权重和。这样的规则让应用于匹配序列的Token的类型的权重和越来越小,所以也不会产生死循环。使用ordinal方法取得的枚举类型的内部值大小作为权重。下面是检查规则的实现方法: /** * 规则校验 * * @return true 表示规则不符合规范 false 表示符合符合规范 */ public boolean check(ArrayList key, ArrayList lhs) { boolean isEquals = false; for (DocSpan span : lhs) { if (span.length > 1) { return isEquals; } } - - 自然语言处理 int leftCount = 0; int rightCount = 0; for (DocType dy : key) { leftCount += dy.ordinal(); } for (DocSpan sd : lhs) { rightCount += sd.type.ordinal(); } if (leftCount <= rightCount) { isEquals = true; } return isEquals; } 规则很多的时候,需要看一段文本匹配上了哪一条规则。或者考察某一条具体的规则可能产生的影响,先总体执行一遍数据,然后看那些数据用了这条规则。 信息提取的流程如下: 1. 定义词的类别; 2. 根据词库做全切分; 3. 最大概率动态规划求解; 4. HMM词性标注; 5. 基于规则的未登录词识别; 6. 根据切分和标注的结果提取信息。 例如,为农业相关的文档提取出作物名称,对应季节,适用地区等信息。 public enum DocType { Product,//作物名称 Pronoun,//代词 Address,//地名 Season,//季节 Start,//虚拟类型,开始状态 End //虚拟类型,结束状态 - - 自然语言处理 } 可以自己建几个简单的词表。例如季节词表 season.txt。存放内容如: 春 夏 秋 冬 作物名称有个product.txt 词表。存放内容如: 大豆 高粱 然后通过DicDoc类加载这些词,代码如下: private DicDoc() { //加载字典 //"product.txt" 是一类词, DocType.Product 定义好这类词性 load("product.txt", DocType.Product); //农作物 load("address.txt", DocType.Address); //地址 load("season.txt", DocType.Season); //季节 } 信息提取的关键在于定义相关规则,用户定义好规则后程序会按照指定的规则提取相关信息,规则越多,提取的信息越精确。另外,可以把需要优先匹配的规则放到前面。因为规则库中放在前面的规则会先匹配上。 还可以用信息提取的方法提取网页中的信息。例如下面这段描述图书的网页片段:“出版社:中国工人出版社
”。要从中提取出版社信息。 把标签放到不同的词典文件中,例如“”和“
”,“出版社:”。这样可以根据规则提取出“中国工人出版社”。 5.8 拼写检查与建议 输错电话号码,往往只是得到简单的提示“没有这个电话号码”。但是在搜索框中输入错误的搜索词,搜索引擎往往会提示“您是不是要找”这个正确的词。这个功能也叫做“Did you mean”。 拼写检查是查询处理极为重要的一个组成部分。在网络搜索引擎用户提交的查询中有大约10%到15%的拼写错误,拼写检查就是对错误的词给出正确的提示。如果有个正确的词和用户输入的词很近似,则用户的输入可能是错误的。 - - 自然语言处理 查询日志中包含大量的简单错误的例子,如下面: poiner sisters -> pointer sisters brimingham news -> birmingham news ctamarn sailing -> catamaran sailing 类似这些错误可以通过建立正误词表来检查。然而,除此之外,将有许多查询日志包含与网站,产品,公司相关的词,对于这样的开放类的词不可能在标准的拼写词典中发现。以下是来自同一个查询日志一些例子: akia 1080i manunal -> akia 1080i manual ultimatwarcade -> ultimatearcade mainscourcebank -> mainsource bank 因此不存在万能的词表,垂直(网站)搜索引擎往往需要整理和自己行业(网站)相关的词库才能达到好的匹配效果。可以从搜索日志中挖掘出“错误词->正确词”这样的词对,例如“飞利蒲->飞利浦”。 根据正误词表替换用户输入。 public static String replace(String content) { int len = content.length(); StringBuilder ret = new StringBuilder(len); ErrorDic.PrefixRet matchRet = new ErrorDic.PrefixRet(null,null); for(int i=0;i intersect(DFA dfa1, DFA dfa2) { ArrayList match = new ArrayList();//找到的正确单词集合 Stack stack = new Stack(); stack.add(new StackValue("", dfa1.startState, dfa2.startState)); while (!stack.isEmpty()) { StackValue stackValue = stack.pop(); Set ret = intersection(dfa1.edges(stackValue.s1),dfa2.edges(stackValue.s2)); for(char edge:ret) { State state1 = dfa1.next(stackValue.s1, edge); State state2 = dfa2.next(stackValue.s2, edge); if(state1!=null&& state2!=null) { String prefix = stackValue.s+edge; stack.add(new StackValue(prefix,state1,state2)); if(dfa1.isFinal(state1) && dfa2.isFinal(state2)){ match.add(prefix); } } } } return match; } 可以使用标准Trie树代替DFA,标准Trie树中存储了正确词库。使用方法如下: //错误词 NFA lev = NFA.levenshteinAutomata("foo",1); DFA dfa = lev.toDFA(); //正确词表 Trie stringTrie = new Trie(); stringTrie.add("food", "food"); stringTrie.add("hammer", "hammer"); stringTrie.add("hammock", "hammock"); stringTrie.add("ipod", "ipod"); - - 自然语言处理 stringTrie.add("iphone", "iphone"); //返回相似的正确的词 ArrayList match = DFA.intersect(dfa, stringTrie); 现在已经实现了从一个大的正确词表中找和输入词编辑距离小于k的词集合。为了计算P(c|w),需要用到P(w|c)和P(c)。根据正确词表中的词频来估计P(c)。根据w和c之间的编辑距离估计P(w|c)。所以实际返回的是如下对象的列表: public class RightWord implements Comparable { public String word; //正确词 public int errors; //错误数,也就是正确词和错误词之间的编辑距离 public int freq; //在词表中的词频 public RightWord(String w, int e, int f) { word = w; errors = e; freq = f; } public int compareTo(RightWord o) { //先比较错误数,再比较词频 int diff = this.errors - o.errors; if(diff!=0) { return diff; } return o.freq - this.freq; } } 为了简化计算选择正确词,先取编辑距离小的正确词,对于编辑距离相同的正确词,则取词频高的正确词。 5.8.2 英文拼写检查 对英文报关公司名101919条统计,有拼写出错的为16663条,出错概率为16.35%。大部分是正确的,如果所有的词都在正确词表中,则不必再查找错误。否则先检查错误词表。最后仍然不确定的,提交查询给搜索引擎,看看是否有错误。 正确词的词典格式每行一个词,分别是词本身和词频。样例如下: biogeochemistry : 1 - - 自然语言处理 repairer : 3 wastefulness : 3 battier : 2 awl : 3 preadapts : 1 surprisingly : 3 stuffiest : 3 因为互联网中的新词不断出现,正确的词并不是来源于固定的词典,而是来源于搜索的文本本身。下面直接从文本内容提取英文单词。不从索引库中提取的原因是Term可能经过词干化处理过了,所以我们用StandardAnalysis再次处理。 java.io.StringReader input = new java.io.StringReader(content); TokenStream tokenizer = new StandardTokenizer(input); for (Token t = tokenizer.next(); t != null; t = tokenizer.next()){ if( isAllLetter(t.termText()) && (t.termText().length()>=3) && (t.termText().length()<=30) ){ System.out.println(t.termText()); fpSource.write(t.termText().toLowerCase()); fpSource.write(" : 1\n"); } } 可以根据发音计算用户输入词和正确词表的相似度,还可以根据字面的相似度来判断是否输入错误,并给出正确的单词提示。也可以参考一下开源的拼写检查器(spell checker)的实现,例如Aspell(http://aspell.net/)。 特别的,考虑公司名中的拼写错误。一般首字母拼写错误的可能性很小。可以简单的先对名称排序,然后再比较前后两个公司名就可以检测出一些非常相似的公司名称了。 另外可以考虑抓取Google的拼写检查结果。 public static String getGoogleSuggest(String name) throws Exception { String searchWord = URLEncoder.encode(name,"utf-8"); String searchURL = "http://www.google.com/search?q=" + searchWord; String strPages=DownloadPage.downloadPage(searchURL);//下载页面 String suggestWord=""; Parser parser=new Parser(strPages); //使用HTMLParser解析返回的网页 NodeFilter filter= - - 自然语言处理 new AndFilter(new TagNameFilter("a"),new HasAttributeFilter("class","spell")); //取得符合条件的第一个节点 NodeList nodelist=parser.extractAllNodesThatMatch(filter); int listCount=nodelist.size(); if(listCount>0){ TagNode node=(TagNode)nodelist.elementAt(0); if (node instanceof LinkTag) {//
标签 LinkTag link = (LinkTag) node; suggestWord = link.getLinkText();//链接文字 } } return suggestWord; } 5.8.3 中文拼写检查 和英文拼写检查不一样,中文的用户输入的搜索词串的长度更短,从错误的词猜测可能的正确输入更加困难。这时候需要更多的借助正误词表,词典文本格式如下: 代款:贷款 阿地达是:阿迪达斯 诺基压:诺基亚 飞利蒲:飞利浦 寂么沙洲冷:寂寞沙洲冷 欧米加:欧米茄 欧米枷:欧米茄 爱力信:爱立信 西铁成:西铁城 瑞新:瑞星 - - 自然语言处理 登心绒:灯心绒 这里,前面一个词是错误的词条,后面是对应的错误词条。为了方便维护,我们还可以把这个词库存放在数据库中: CREATE TABLE CommonMisspellings ( [misword] [varchar] (50) COLLATE Chinese_PRC_CI_AS NULL , --错误词 [rightword] [varchar] (50) COLLATE Chinese_PRC_CI_AS NULL --正确词 ) 除了人工整理,还可以从搜索日志中挖掘相似字串来找出一些可能的正误词对。比较常用的方法是采用编辑距离(Levenshtein Distance)来衡量两个字符串是否相似。编辑距离就是用来计算从原串(s)转换到目标串(t)所需要的最少的插入,删除和替换的数目。例如源串是“诺基压”,目标串是“诺基亚”,则编辑距离是1。 当一个用户输入错误的查询词没有结果返回时,他可能会知道输入错误,然后用正确的词再次搜索。从日志中能找出来这样的行为,进而找出正确/错误词对。 例如,日志中有这样的记录: 2007-05-24 00:41:41.0781|DEBUG |221.221.167.147||喀尔喀蒙古|2 … 2007-05-24 00:43:45.7031|DEBUG|221.221.167.147||喀爾喀蒙古|0 … 处理每行日志信息,用StringTokenizer返回“|”分割的字符串,代码如下。 StringTokenizer st = new StringTokenizer(line,"|"); while(st.hasMoreTokens()) { //有更多的内容 System.out.println(st.nextToken()); //取得子串 } 假设日志中有搜索结果返回的是正确词,无搜索结果返回的是错误词。挖掘日志的程序如下: //存放挖掘的词及搜索出的结果数 HashMap searchWords = new HashMap(); while((readline=br.readLine())!=null) { StringTokenizer st = new StringTokenizer(readline,"|"); if(!st.hasMoreTokens()) continue; st.nextToken(); - - 自然语言处理 if(!st.hasMoreTokens())continue; st.nextToken(); if(!st.hasMoreTokens())continue; st.nextToken(); if(!st.hasMoreTokens())continue; st.nextToken(); if(!st.hasMoreTokens())continue; st.nextToken(); if(!st.hasMoreTokens())continue; //存放搜索词 String key = st.nextToken(); if(key.indexOf(":")>=0) { continue; } //如果已经处理过这个词就不再处理 if(searchWords.containsKey(key)) { continue; } if(!st.hasMoreTokens()) { continue; } String results = st.nextToken(); int resultCount = Integer.parseInt(results);//得到搜索出的结果数 for(Entry e : searchWords.entrySet()) { int diff= Distance.LD(key, e.getKey()) ; if(diff ==1 && key.length()>2) { if( resultCount == 0 && e.getValue()>0 ) { // e.getKey()是正确词,key是错误词 System.out.println(key +":"+ e.getKey()); bw.write(key +":"+ e.getKey()+"\r\n"); } else if(e.getValue()==0 && resultCount>0) { // key是正确词,e.getKey()是错误词 System.out.println(e.getKey() +":"+ key); bw.write(e.getKey() +":"+ key+"\r\n"); - - 自然语言处理 } } } searchWords.put(key, resultCount);//存放当前词及搜索出的结果数 } 可以挖掘出如下一些错误、正确词对: 谕伽服:瑜伽服 落丽塔:洛丽塔 巴甫洛:巴甫洛夫 hello kiitty:hello kitty … 除了根据搜索日志挖掘正误词表,还可以根据拼音或字形来挖掘。例如根据拼音挖掘出“周杰论:周杰伦”,根据字形挖掘出“淅江移动:浙江移动”。搜索“HTMLParser 源代码”时,提示:您是不是要用SVN客户端下载? 5.9 自动摘要 减少原文的长度而保留文章的主要意思叫做摘要。摘要有各种形式。和搜索关键词相关的摘要,叫做动态摘要;只和文本内容相关的摘要叫做静态摘要。搜索引擎中显示的搜索结果就是关键词相关摘要的例子。如果在文档的内容列中没有搜索关键词,则搜索结果中内容列的动态摘要退化成静态摘要。 按照信息来源来分有来源于单个文档的摘要和合并多个相关文档意思的摘要。单文档摘要精简一篇文章的主要意思,多文档摘要同时可以过滤掉出现在多篇文档中的重复内容。 除了用于搜索结果中显示的摘要,文本自动摘要还可以用于手机WAP网站显示摘要信息,还可以用于发送短信息。 5.9.1 自动摘要技术 摘要的实现方法有摘取性的和概括性的。摘取性的方法相对容易实现,通常的实现方法是摘取文章中的主要句子。 MEAD(http://www.summarization.com/mead/)是一个功能完善的多文档摘要软件,不过是Perl实现的。Classifier4J(http://classifier4j.sourceforge.net/)包含一个简单的文本摘要实现,方法是 - - 自然语言处理 抽取指定文本中的重要句子形成摘要。使用它的例子如下: String input = "Classifier4J is a java package for working with text. Classifier4J includes a summariser."; //输入文章内容及摘要中需要返回的句子个数。 String result = summariser.summarise(input, 1); 返回结果是:"Classifier4J is a java package for working with text."。 5.9.2 自动摘要的设计 自动摘要主要方法有基于句子重要度的方法和基于篇章结构的方法。基于句子重要度的方法相对成熟,基于篇章结构的方法还处在研究阶段。 Classifier4J也是采用了句子重要度计算的简化方法。Classifier4J通过统计高频词和句子分析来实现自动摘要。主要流程如下: 1. 取得高频词; 2. 把内容拆分成句子; 3. 取得包含高频词的前k个句子。 4. 将句子按照在文中出现的顺序重新排列,添加适当的分隔符后输出。 统计文本中最常出现的k个高频词的基本方法如下: 1) 在遍历整个单词序列时,使用一个散列表记录所有的单词频率。散列表的关键字是词,而值是词频。花费O(n)时间。 2) 对散列表按值从大到小排序。使用通常的排序算法花费O(n*lg(n))时间。 3) 排序后,取前k个词。 为了优化第2步和第3步,可以不对散列表全排序,直接取前k个值最大的词。用到的一个方法是从数组中快速的选取最大的k个数。 快速排序基于分而治之(divide and conquier)策略。数组A[p..r]被划分为两个(可能空)子数组A[p..q-1]和A[q+1..r],使得A[p..q-1]中的每个元素都小于等于A(q),而且小于等于A[q+1..r]中的元素。这里A(q)称为中值。可以根据快速排序的原理设计接口如下: //根据随机选择的中值来选取最大的k个数,输入参数说明如下: // a 待选取的数组 // size数组的长度 - - 自然语言处理 // k 前k个值最大的词 // offset 偏移量 selectRandom(ArrayList a, int size, int k, int offset) 根据快速排序的原理,实现选取最大的k个数的方法如下: //把数组中的两个元素交换位置 public static > void swap(ArrayList a, int i, int j) { E tmp = a.get(i); a.set(i, a.get(j)); a.set(j, tmp); } static void selectRandom(ArrayList a, int size, int k, int offset) { if (size < 5) {//采用简单的冒泡排序方法对长度小于5的数组排序 for (int i = offset; i < (size + offset); i++) for (int j = i + 1; j < (size + offset); j++) if (a.get(j).compareTo(a.get(i)) < 0) swap(a, i, j); return; } Random rand = new Random();//随机选取一个元素作为中值 int pivotIdx = partition(a, size, rand.nextInt(size) + offset, offset); if (k != pivotIdx) { if (k < pivotIdx) { selectRandom(a, pivotIdx - offset, k, offset); } else { selectRandom(a, size - pivotIdx - 1 + offset, k, pivotIdx + 1); } } } static int partition(ArrayList a, int size, int pivot, int offset) { WordFreq pivotValue = a.get(pivot); //取得中值 swap(a, pivot, size - 1 + offset); int storePos = offset; for (int loadPos = offset; loadPos < (size - 1 + offset); loadPos++) { if (a.get(loadPos).compareTo(pivotValue) < 0) { swap(a, loadPos, storePos); storePos++; - - 自然语言处理 } } swap(a, storePos, size - 1 + offset); return (storePos); } 参考Classifier4J的实现方法,中文自动摘要的基本实现方法如下5个步骤: 1. 通过中文分词,统计词频和词性等信息,抽取出关键词。 2. 把文章划分成一个个的句子。 3. 通过各句中关键词出现的情况定义出句子的重要度。 4. 确定前K个最重要的句子为文摘句。 5. 把文摘句按照在原文中出现的顺序输出成摘要。 文本摘要的主体程序如下: ArrayList pItem = Tagger.getFormatSegResult(content);//分词 //关键词及对应的权重 HashMap keyWords = new HashMap(10); WordCounter wordCounter = new WordCounter();//词频统计 for (int i = 0; i < pItem.size(); ++i) { CnToken t = pItem.get(i); if (t.type().startsWith("n")) { wordCounter.addNWord(t.termText());//增加名词词频 } else if (t.type().startsWith("v")) { wordCounter.addVWord(t.termText());//增加动词词频 } } //取得出现的频率最高的五个名词 WordFreq[] topNWords = wordCounter.getWords(wordCounter.wordNCount); for (int i = 0; i < topNWords.length; i++) { keyWords.put(topNWords[i].word, topNWords[i].freq); } //取得出现的频率最高的五个动词 WordFreq[] topVWords = wordCounter.getWords(wordCounter.wordVCount); for (int i = 0; i < topVWords.length; i++) { - - 自然语言处理 keyWords.put(topVWords[i].word,topVWords[i].freq); } //把内容分割成句子 ArrayList sentenceArray = getSentences(content,pItem); //计算每个句子的权重 for(SentenceScore sc:sentenceArray) { sc.score = 1; for (Entry e:keyWords.entrySet()) { String word = e.getKey(); if (sc.containWord(word)) { sc.score = sc.score * e.getValue(); } } } //取得权重最大的三个句子 int minSize = Math.min(sentenceArray.size(), 3); Select.selectRandom(sentenceArray, sentenceArray.size(), minSize,0); SentenceScore[] orderSen = new SentenceScore[minSize]; for(int i=0;i来描述,其中中心词为该三元组的主要部分。 l 其中第一步,在提取关键词阶段,可以去掉停用词表,然后再统计关键词。也可以考虑利用同义词信息更准确的统计词频。 - - 自然语言处理 l 划分句子阶段,可以记录句子在段落中出现的位置,在段落开始或结束出现的句子更有可能是关键句。同时可以考虑句型,陈述句比疑问句或感叹句更有可能是关键句。 首先定义句子类型: public static enum SentenceType { declare, //陈述句 question,//疑问句 exclamation //感叹句 } 句子权重统计阶段考虑句型来打分。 //判断句子类型 if(sc.type == SentenceScore.SentenceType.question) { sc.score *= 0.1; } else if(sc.type == SentenceScore.SentenceType.exclamation) { sc.score *= 0.5; } 为了使输出的摘要意义连续性更好,有必要划分段落。识别自然段和更大的意义段。自然段一般段首缩进两个或四个空格。 在对句子打分时,除了关键词,还可以查看事先编制好的线索词表。表示线索词的权值,有正面的和负面的两种。文摘正线索词就是类似“总而言之”、“总之”、“本文”、“综上所述”等词汇,含有这些词的句子权重有加分。文摘负线索词可以是“比如”,“例如”等。如果句中包括这些词权重就会降低。 为了减少文摘句之间的冗余度,可以通过句子相似度计算减少冗余句子。具体过程如下: 1. 首先将句子按其重要度从高到底排序; 2. 抽取重要度最高的句子Si; 3. 选取候选句Si后,调整剩下的每个待选句的重要度。待选句Sj的重要度按如下公式进行调整:Score(Sj)= Score(Sj) − sim(Si,Sj) * Score(Si) 其中sim(Si,Sj)是句子Si和Sj的相似度。 4. 剩下的句子按重要度从高到底排序,选取重要度高的句子; 5. 重复3、4步,直至摘要足够长为止。 - - 自然语言处理 最后为了输出的摘要通顺,还需要处理句子间的关联关系。例如下面的关联句子: “这个节目,需要的是接班人,而不是变革者。”换言之,一个节目的“心”的意义是大于“脸”的意义的;“换脸”未必就是“换心”;但《新闻联播》目前还只能接班性地“换脸”而不能变革性地的“换心”。 处理关联句子的方法有三种: 1. 调整关联句的权重,使更重要的句子优先成为摘要句。 2. 调整关联句的权重,使关联的两个句子都成为或都不成为摘要句。 3. 输出摘要时,如果不能完整的保持相关联的句子,则删除句前的关联词。 句子间的关联通过关联性的词语来表示。处理关联句可以根据关联性词语的类型分别处理。表5-3列出了各种关联类型的处理方法。 表5-3 关联词表 关联类型 关联词 处理方式 转折 虽然…但是… 对于这类偏正关系的,调整后面部分的关键句的权重,保证其大于前面部分的权重。当只有一句是摘要句时,删除该句前的关联词。 因果 因为…所以…/因此 递进 不但…而且… 尤其 并列 一方面…另一方面… 对于这类并列关系,使关键句的权重都一样。找不到对应的关联句的删除该句子前面的关连词。 承接 接着 然后 选择 或者…或者 分述 首先…其次… 总述 总而言之 综上所述 总之 这类可承前省略的,如果与前面的句子都是摘要句,则保持不变。否则,如果前面的句子不是摘要句则删除该句子前面的关连词。 等价 也就是说 即 换言之 话题转移 另外 对比 相对而言 - - 自然语言处理 关联类型 关联词 处理方式 举例说明 比如 例如 这类可承前省略的,如果与前面的句子都是摘要句,则保持不变。否则删除后面的句子。 定义句子的关系类型: public enum RelationType { conjunctive, //偏正 juxtapose,//并列 conlusion //承前省略 } 表示句子及它们之间的关系: public class SentenceRelation { SentenceScore pre;//前一个句子 SentenceScore sub;//下一个句子 RelationType type;//关系类型 public SentenceRelation(SentenceScore preSentence, SentenceScore subSentence, RelationType t) { pre = preSentence; sub = subSentence; type = t; } } 5.9.3 基于篇章结构的自动摘要 对段落之间的内容语义关系进行分析,进而划分出文档的主题层次,得到文档的篇章结构。或者,可以建立句子级别的带权重的图,然后应用PageRank或者HITS算法。 5.9.4 Lucene中的动态摘要 Lucene扩展包中有一个实现自动摘要的包——HighLighter。HighLighter返回一个或多个和搜索关键词最相关的段落。实现原理是:首先由分段器(Fragmenter)把文本分成多个段落,然后QueryScorer计算每个段落的分值。QueryScorer只应该包含需要做高亮显示的Term。 - - 自然语言处理 为了实现高亮显示,以lucene-3.0.2为例,除了依赖lucene-core-3.0.2.jar和lucene-highlighter-3.0.2.jar以外,还依赖lucene-memory-3.0.2.jar。通过调用getBestFragments 方法返回一个或多个和搜索关键词最相关的段落。实现高亮显示最简单的做法: TokenStream tokenStream = analyzer.tokenStream("title",new StringReader(title)); String highLightText = highlighter.getBestFragment(tokenStream, title); 搜索时使用analyzer分析出搜索词会导致搜索速度变慢。形成索引的时候已经分过词了,因此可以在索引时存储位置信息。可以通过IndexReader取出索引中保存的词的位置信息,通过词的位置信息来构造TokenStream,这样就避免了搜索时再次分词导致的搜索速度降低。一般情况下,用户经常在使用关键词搜索,而索引只需要做一次就可以了,所以提升搜索速度很重要。如果IndexReader中的Token位置有重叠,为了把冗余的Token去掉,TokenStream构造起来会麻烦一些。 对标题列高亮显示的实现代码如下: SimpleHTMLFormatter simpleHTMLFormatter = new SimpleHTMLFormatter("", ""); Highlighter highlighter =new Highlighter(simpleHTMLFormatter,new QueryScorer(query)); highlighter.setTextFragmenter(new SimpleFragmenter(40)); for (int i = 0; i < hits.length; i++){ Document hitDoc = isearcher.doc(hits[i].doc); String text = hitDoc.get("title"); TermPositionVector tpv = (TermPositionVector)isearcher.getIndexReader().getTermFreqVector(hits[i].doc,"title"); TokenStream tokenStream=TokenSources.getTokenStream(tpv); String highLightText =highlighter.getBestFragment(tokenStream,text); System.out.println(highLightText); } FastVectorHighlighter是一个快速的高亮工具,相对于Highlighter它有三个好处: 1.FastVectorHighlighter可以支持n元分词器分出来的列。 2.FastVectorHighlighter可以输出不同颜色的高亮。 3.FastVectorHighlighter 可以对词组高亮。如检索lazy dog,FastVectorHighlighter返回结果是lazy dog,而Highlighter则是dog。 但是FastVectorHighlighter不支持所有的查询,例如WildcardQuery或SpanQuery等。 可以使用内存索引来测试FastVectorHighlighter高亮显示的效果。 FragListBuilder fragListBuilder = new SimpleFragListBuilder(); - - 自然语言处理 //创建多颜色标签ScoreOrderFragmentsBuilder FragmentsBuilder fragmentBuilder = new ScoreOrderFragmentsBuilder( BaseFragmentsBuilder.COLORED_PRE_TAGS, BaseFragmentsBuilder.COLORED_POST_TAGS); FastVectorHighlighter highlighter = new FastVectorHighlighter(true, true, fragListBuilder, fragmentBuilder); // 创建 FastVectorHighlighter实例 FieldQuery fieldQuery = highlighter.getFieldQuery(titleQuery); // 创建FieldQuery String highLightText = highlighter.getBestFragment( fieldQuery, isearcher.getIndexReader(), hits[i].doc, "title", 10000); // 高亮片断 FastVectorHighlighter性能很好,但是SimpleFragListBuilder硬编码了6个字符的边界,导致匹配短文本时,左边的内容显示不全。修改后的SimpleFragListBuilder如下: public class SimpleFragListBuilder implements FragListBuilder { public static final int MARGIN = 6; public static final int MIN_FRAG_CHAR_SIZE = MARGIN * 3; public FieldFragList createFieldFragList(FieldPhraseList fieldPhraseList, int fragCharSize) { if (fragCharSize < MIN_FRAG_CHAR_SIZE) throw new IllegalArgumentException("fragCharSize(" + fragCharSize + ") is too small. It must be " + MIN_FRAG_CHAR_SIZE + " or higher."); FieldFragList ffl = new FieldFragList(fragCharSize); List wpil = new ArrayList(); Iterator ite = fieldPhraseList.phraseList.iterator(); WeightedPhraseInfo phraseInfo = null; int startOffset = 0; boolean taken = false; while (true) { if (!taken) { if (!ite.hasNext()) break; phraseInfo = ite.next(); } taken = false; - - 自然语言处理 if (phraseInfo == null) break; // if the phrase violates the border of previous fragment, discard // it and try next phrase if (phraseInfo.getStartOffset() < startOffset) continue; wpil.clear(); wpil.add(phraseInfo); int firstOffset = phraseInfo.getStartOffset(); int st = phraseInfo.getStartOffset() - MARGIN < startOffset ? startOffset : phraseInfo.getStartOffset() - MARGIN; int en = st + fragCharSize; if (phraseInfo.getEndOffset() > en) en = phraseInfo.getEndOffset(); int lastEndOffset = phraseInfo.getEndOffset(); while (true) { if (ite.hasNext()) { phraseInfo = ite.next(); taken = true; if (phraseInfo == null) break; } else break; if (phraseInfo.getEndOffset() <= en){ wpil.add(phraseInfo); lastEndOffset = phraseInfo.getEndOffset(); } else break; } int matchLen = lastEndOffset - firstOffset; // now recalculate the start and end position to "center" the int newMargin = (fragCharSize - matchLen) / 2; st = firstOffset - newMargin; if (st < startOffset) { st = startOffset; } - - 自然语言处理 en = st + fragCharSize; startOffset = en; ffl.add(st, en, wpil); } return ffl; } } 5.10 文本分类 文本分类程序把一个未见过的文档分成已知类别中的一个或多个,例如把新闻分成国内新闻和国际新闻。利用文本分类技术可以对网页分类,也可以用于为用户提供个性化新闻或者垃圾邮件过滤。 把给定的文档归到两个类别中的一个叫做叫做两类分类,例如垃圾邮件过滤,就只需要确定“是”还是“不是”垃圾邮件。分到多个类别中的一个叫做多类分类,例如中图法分类目录把图书分成22个基本大类。 文本分类主要分为训练阶段和预测阶段。一个典型的文本分类程序框架如图5-3所示: 新文本 预处理 预处理 训练文本 预处理 预处理 特征项 选取 选取 训练文本 再处理 再处理 分类模型 训练阶段 预测阶段 分类和输出 图5-3 文本分类程序框架 首先准备好训练文本集,也就是一些已经分好类的文本。每个类别路径下包含属于该类别的一些文本文件。例如文本路径在“D:\train”,类别路径是: D:\train\政治 - - 自然语言处理 D:\train\体育 D:\train\商业 D:\train\艺术 例如:“D:\train\体育”类别路径下包含属于“体育”类别的一些文本文件,每个文本文件叫做一个实例(instance)。训练文本文件可以手工整理一些或者从网络定向抓取网站中已经按栏目分好类的信息。 常见的分类方法有支持向量机(SVM)、K个最近的邻居(KNN)和朴素贝叶斯(Naive Bayes)等。可以根据应用场景选择合适的文本分类方法。例如,支持向量机适合对长文本分类,朴素贝叶斯对短文本分类准确度也较高。 为了加快分类的执行速度,可以在训练阶段输出分类模型文件,这样在预测新文本类别阶段就不再需要直接访问训练文本集,只需要读取已经保存在分类模型文件中的信息。可以在预测之前,把分类模型文件预加载到内存中。这里采用单件模式加载分类模型文件。 private Classifier() { // 读取分类模型 //... } private static Classifier categoryTrie = null; public static Classifier getInstance() { //取得唯一的实例 if (categoryTrie == null) categoryTrie = new Classifier(); return categoryTrie; } 依据标题、内容和商品在原有网站的类别把商品归类到新的分类,这些值都是字符串类型。使用参数个数不固定的方式定义分类方法: //根据分类模型分类 public String getCategoryName(String... articals) { //获取参数 for (String content : articals) { //根据content是否包含某些关键词分类 } //...返回分类结果 } 这样可以按标题或者标题加内容等参数分类。 //加载已经训练好的分类模型 Classifier theClassifier = Classifier.getInstance(); - - 自然语言处理 //文本分类的内容 String content ="我要买把吉他,希望是二手的,价格2000元以下"; //根据内容分类 String catName = theClassifier.getCategoryName(content); System.out.println("类别名称:"+catName); 交叉验证(Cross Validation)是用来验证分类器的性能的一种统计分析方法。基本思想是把在某种意义下将原始数据集进行分组,一部分做为训练集,另一部分做为验证集。首先用训练集对分类器进行训练,再利用验证集来测试训练得到的模型,以此来做为评价分类器的性能指标。 5.10.1 特征提取 待分类的文本往往包括很多单词,很多单词对分类没有太大的贡献,所以需要提取特征词。可以按词性过滤,只选择某些词性作为分类特征,比如说,只选择名词和动词作为分类特征词。文本分类的精度随分类特征词的个数持续提高。一般至少可以选2000个分类特征词。 分类特征并不一定就是一个词。例如,可以通过检查标题和签名把文本分类成是否是信件内容。可以看标题是否包含“来自**”和“致**”地址,内容结束处是否包含日期和问候用语等。这样的特征集合仅适用于信件类别。 特征选择的常用方法还有:CHI方法和信息增益(Information Gain)方法等。首先介绍特征选择的CHI方法。 利用CHI方法来进行特征抽取是基于如下假设:在指定类别文本中出现频率高的词条与在其他类别文本中出现频率比较高的词条,对判定文档是否属于该类别都是很有帮助的。 CHI方法衡量单词term和类别class之间的依赖关系。如果term和class是互相独立的,则该值接近于0。一个单词的CHI统计通过表5-4计算: 表5-4 CHI统计变量定义表 属于class类 不属于class类 合计 包含单词term a b a+b 不含单词term c d c+d 合计 a+c b+d a+b+ c+d=n 其中,a表示属于类别class的文档集合中出现单词term的文档数;b表示不属于类别class的文档集合中出现单词term的文档数;c表示属于类别class的文档集合中没有出现单词term - - 自然语言处理 的文档数;d表示不属于类别class的文档集合中没有出现单词term的文档数;n代表文档总数。 表5-4中的单词term的CHI统计公式如下: 类别class越依赖单词term,则CHI统计值越大。 表5-4也叫做相依表(contingency table)。开源自然语言处理项目MinorThird(http://minorthird.sourceforge.net/)中ContingencyTable类的CHI统计实现代码: //取对数避免溢出 public double getChiSquared(){ double n = Math.log(total()); double num = 2*Math.log(Math.abs((a*d) - (b*c))); double den = Math.log(a+b)+Math.log(a+c)+Math.log(c+d)+Math.log(b+d); double tmp = n+num-den; return Math.exp(tmp); } 计算每个特征对应的CHI值的实现代码: for (Iterator i=index.featureIterator(); i.hasNext(); ) {//遍历特征集合 Feature f = i.next(); int a = index.size(f,ExampleSchema.POS_CLASS_NAME);//正类中包含特征的文档数 int b = index.size(f,ExampleSchema.NEG_CLASS_NAME);//负类中包含特征的文档数 int c = totalPos - a; //正类中不包含特征的文档数 int d = totalNeg - b; //负类中不包含特征的文档数 ContingencyTable ct = new ContingencyTable(a,b,c,d); double chiScore = ct.getChiSquared();//计算特征的CHI值 filter.addFeature( chiScore,f ); } 如果这里的a+c或者b+d或者a+b或者c+d中的任意一个值为0,则会导致除以零溢出的错误。由于数据稀疏导致了这样的问题,可以采用平滑算法来解决。 对所有的候选特征词,按上面得到的特征区分度排序,如果候选特征词的个数大于5000,则选取前5000。否则选取所有特征词。 - - 自然语言处理 使用ChiSquareTransformLearner测试特征提取: Dataset dataset = CnSampleDatasets.sampleData("toy",false); System.out.println( "old data:\n" + dataset ); ChiSquareTransformLearner learner = new ChiSquareTransformLearner(); ChiSquareInstanceTransform filter = (ChiSquareInstanceTransform)learner.batchTrain( dataset ); filter.setNumberOfFeatures(10); dataset = filter.transform( dataset ); System.out.println( "new data:\n" + dataset ); 信息增益(Information Gain)是广泛使用的特征选择方法。在信息论中,信息增益的概念是:某个特征的值对分类结果的确定程度增加了多少。 信息增益的计算方法是:把文档集合D看成一个符合某种概率分布的信息源,依靠文档集合的信息熵和文档中词语的条件熵之间信息量的增益关系确定该词语在文本分类中所能提供的信息量。 词语w的信息量的计算公式为: IG(w)=H(D)-H(D|w) 根据特征在每个类别的出现次数分布计算一个特征的熵: //输入参数p是特征的出现次数分布,tot是特征出现的总次数 public double Entropy(double[] p, double tot){ double entropy = 0.0; for (int i=0; i0.0) { entropy += -p[i]/tot *Math.log(p[i]/tot) /Math.log(2.0); } } return entropy; } 计算所有特征的熵: double[] classCnt = new double[ N ]; double totalCnt = 0.0; for (int c=0; c i=index.featureIterator(); i.hasNext(); ) { Feature f = i.next(); double[] featureCntWithF = new double[ N ];//出现特征的文档在不同类别中的分布 double[] featureCntWithoutF = new double[ N ]; //不出现特征的文档在不同类别的分布 double totalCntWithF = 0.0; double totalCntWithoutF = 0.0; for (int c=0; c node = rootNode; while(index='a' && c<='z') || (c>='A' && c<='Z')){ index++; } return index; }else if(c==','){ index++; while((c>='a' && c<='z') || (c>='A' && c<='Z')){ index++; } return index; }else if(c==' '){ index++; while((c>='a' && c<='z') || (c>='A' && c<='Z')){ index++; } return index; } } return index; } 5.10.3 朴素贝叶斯 贝叶斯分类的基础是贝叶斯理论。因为贝叶斯理论(Bayes' Theorem)并不是一个显而易见的理论,所以举例说明。 这是一个医生经常会遇到的问题。在四十岁这个年龄段参加例行检查的妇女有1%的概率有乳腺癌。80%患有乳腺癌的妇女做乳房透视会得到阳性结果。9.6%没有得乳腺癌的妇女也会得到阳性的透视结果。一个妇女在这个年龄段例行检查的时候得到阳性的乳房透视结果。问她确实患有乳腺癌的概率?如果你以前还没有遇到过这种问题,在继续阅读之前,请花点时间提出你自己的答案。通常,只有大概15%的医生得到正确答案。在以上的问题中,大多数的医生估计她确实患有乳腺癌的可能性会在70%到80%之间,这是一个很不正确的答案。 - - 自然语言处理 把这个问题换一种描述方式,医生们的回答状况可能会好一些。四十岁这个年龄段的妇女接受例行检查1000人中大概有10个人会患有乳腺癌。1000个患有乳腺癌的妇女中800个人会得到阳性的乳房透视结果。1000个不患有乳腺癌的妇女中也会有96个人得到阳性的乳房透视结果。如果这个年龄段的妇女参加一次例行检查,大概得到阳性乳房透视结果的妇女有多少的概率将会真的患有乳腺癌?这次有46%的医生给出正确答案。 四十岁这个年龄段参加例行检查的妇女10000个中大概有100个会患有乳腺癌。100个患有乳腺癌的人中80个会得到阳性的乳房透视结果。9900个没有患乳腺癌的人中950个会得到阳性的乳房透视结果。一个得到阳性乳房透视结果的妇女大概有多少概率会真的患有乳腺癌?正确答案是7.8%。推导过程如下:10000个妇女中,100个人会患有乳腺癌,这患有乳腺癌的100个人中有80个人会得到阳性的乳房透视结果。在10000名妇女中9900不患乳腺癌,这些人中有950人中也会得到阳性的乳房透视的结果。这表明得到阳性乳房透视结果的妇女人数是(80+950)=1030人。这1030人中有80人患有癌症。结果为(80/1030)=0.07767即7.8%。 换一种方式思考,在乳房透视筛查之前,10000名妇女可以被分成两组:第一组、100名患乳腺癌的妇女。第二组、9900名不患乳腺癌的妇女。两组相加得到总数10000名患者,可以确认分组没有把人漏掉。 乳房透视之后,这些妇女被分成四组:A组:80名妇女,患有癌症并且是阳性的乳房透视。B组:20名妇女,患有癌症并且是阴性的乳房透视。C组:950名妇女,不患有癌症但是阳性的乳房透视。D组:8950名妇女,不患有癌症并且是阴性的乳房透视。这四组的总和依然是10000。 A、B组的总和,对应患有癌症的第一组;B、C组的总和对应没有患癌症的第二组;因此乳房透视的结果并没有真正的改变患有癌症的人数。 癌症病人(A+B)在总患者(A+B+C+D)中的比例跟先前一个妇女患癌症的概率(80+20)/(80+20+950+8950)=1%,正好跟先前提到的患癌症概率一样。 通常犯的错误是忽略掉原始的妇女患有癌症的比例,妇女没有乳腺癌收到误报的比例,还有就是把重点仅仅放在患有乳腺癌并且得到阳性乳房透视的妇女比例上。举个例子,大量的医生在这个问题上好像都认为如果大概80%的患有乳腺癌的妇女乳房透视会得到阳性,那么一个妇女乳房透视得到阳性那么她患乳腺癌的概率也一定是80%。 得乳腺癌的病人的原始概率被认为是先验概率。患有乳腺癌的的病人得到一个乳房透视阳性的概率,没有患乳腺癌的病人得到一个乳房透视的阳性的概率,被认为是条件概率。最终的答案——在乳房透视中是阳性的结果得出病人得乳腺癌的估计概率,被称为修订概率或者后验概率。表5-4是这些概率变量的列表。 概率 值 含义 - - 自然语言处理 概率 值 含义 p(癌症) 0.01 第一组: 100个患有乳腺癌的妇女 p(~癌症) 0.99 第二组: 9900个不患乳腺癌的妇女 p(阳性|癌症) 80.0% 80%的患有乳腺癌的妇女在乳房透视化验中得到阳性结果 p(~阳性|癌症) 20.0% 20%的患有乳腺癌的妇女在乳房透视化验中得到阴性结果 p(阳性|~癌症) 9.6% 9.6%的不患有乳腺癌的妇女在乳房透视化验中得到阳性结果 p(~阳性|~癌症) 90.4% 90.4%的不患有乳腺癌的妇女在乳房透视化验中得到阴性结果 p(癌症&阳性) 0.008 A组:80个患有乳腺癌的妇女并且在乳房透视化验中得到阳性结果 p(癌症&~阳性) 0.002 B组:20个患有乳腺癌的妇女并且在乳房透视化验中得到阴性结果 p(~癌症&阳性) 0.095 C组:950个没有患乳腺癌的妇女并且在乳房透视化验中得到阳性结果 p(~癌症&~阳性) 0.895 D组:8950个没有患乳腺癌的妇女并且在乳房透视化验中得到阴性结果 p(阳性) 0.103 1030个得到阳性结果的妇女 p(~阳性) 0.897 8970个得到阴性结果的妇女 p(癌症|阳性) 7.80% 如果乳房透视化验是阳性,你患有乳腺癌的概率为7.8% p(~癌症|阳性) 92.20% 如果乳房透视化验你是阳性,那么你健康的概率为92.2% p(癌症|~阳性) 0.22% 如果乳房透视化验为阴性,你患乳腺癌的概率为0.22% p(~癌症|~阳性) 99.78% 如果你在乳房透视化验中得到阴性结果,你健康的概率为:99.78% 表5-4概率变量表 为了找到乳房透视结果为阳性的妇女患有乳腺癌的概率,计算公式如下: - - 自然语言处理 这个计算的完整的一般的形式被称为贝叶斯定理或贝叶斯规则: 贝叶斯定理的推理过程如下: 第一个步骤,由P(A|X) 得到P(X&A)/P(X),这看起来相是一个恒真命题,但是在实际上数学的表现却是不同的。P(A|X)是一个单一的数字,是A在X子集中的一个归一化的概率。P(X&A)/P(X)是在全部的样本中X&A 和 X出现的频率。P(癌症|阳性)是一个百分比或者说是一个概率,取值范围在0到1之间。(阳性&癌症)/(阳性)既可以用概率来衡量,例如 0.008/0.103,也可以表示为妇女群体,比如说 194/2494。分母从P(X)转换到P(X&A)+P(X&~A)是一个非常简单的步骤,它的主要目的是作为一个中间步骤到达最终的等式。得到贝叶斯定理的最后一步是将分子和分母中的P(X&A)转换成P(X|A)*P(A),分母中的P(X&~A)转换成P(X|~A)*P(~A)。 贝叶斯理论的一般形式是: 其中C和D是随机变量。 - - 自然语言处理 贝叶斯分类公式是: 其含义是:在所有可能的类集合C中返回类c,使得最大。这里并不直接计算,根据贝叶斯理论,可以通过计算和得到。 为了简化计算过程,朴素贝叶斯模型假定特征变量是相互独立,也就是待分类文本中的词与词之间没有关联。假设d=w1,w2,…,wn。则 给定一个类c,我们为每个词定义一个布尔型的随机变量wi。布尔型事件的结果是0或1。P(wi =1|c)的概率可以说是项wi通过类c产生的概率。相反地P(wi =0|c)的概率可以说是项wi不通过类c产生的概率。这是就多变量伯努利(Multiple Bernoulli)事件空间。 在这个事件空间下,为每个项在某些类c下,估计这个词是由这个类生成的可能性。例如,在垃圾分类中,P(cheap=1|spam)可能有很高的概率,但是P(dinner=1|spam)将有一个很低的概率。 文档id cheap buy banking dinner the class 1 2 3 4 5 6 7 8 9 10 0 1 0 1 1 0 0 0 0 1 0 0 0 0 1 0 1 1 0 0 0 1 0 1 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 not spam spam not spam spam spam not spam not spam not spam not spam not spam 表5-5在多变量伯努利事件空间中如何表示文档 - - 自然语言处理 表5-5显示怎样设置训练文档能被呈现在这个事件空间中。在这个例子中有10个文档,每个文档用唯一的id标识,两类(spam和not spam),和包含“cheap”,“buy”,“banking”,“dinner”和“the”项的词汇表。在这个例子中P(spam)=3/10,P(not spam)=7/10。接着,必须估计的每个词和类搭配的P(w|c)。最直接的方法是使用最大似然法估计概率,公式如下: 这里,dfw, c是在类c中包含词w的训练文档的数量,Nc是类c的训练文档的总数。最大似然估计无非是在类别c中包含项w的文档的比例。使用最大似然估计,容易计算P(the|spam)=1,P(the |not spam)=1,P(dinner|spam)=0,P(dinner|not spam)=1/7等等。 使用多变量伯努利模型,文档似然值P(d|c)可以写为: 其中当且仅当在文档d中出现词w时,δ(w,d)是1。 实际上,由于0概率问题,所以不可能使用最大似然估计。为了解释0概率问题,让我们回到表5-5中的垃圾邮件分类的例子。假设我们接收一封垃圾邮件包含“dinner”一词。无论电子邮件包含或不包含其它的词,P(d|c)一直是0,因为P(dinner|spam)=0,而这个词在文档中出现(也就是δdinner,d=1)。因此,任何包含词“dinner”的文档将都会自动计算成是垃圾的概率是零。这个问题比较普遍,因为每当一个文档包含一个词,而那个词从来不出现在一个或多个类时,零概率问题就会产生。这里的问题是最大似然估计是基于训练集中的出现计数。然而,这个训练集是有限的,因此不可能观察到所有可能的事件。这就是所谓的数据稀疏。稀疏往往是因为训练集太小,但是也会发生在比较大的数据集上。因此,我们必须改变这样一种方式的估计,对所有词,包括那些没有在给定类中观察到的,给予一些概率量。我们必须为所有在词典中的项确保P(w|c)是非零的。通过这样做,就能避免所有的与零概率相关的问题。平滑技术可以克服零概率问题,一个流行的平滑技术是贝叶斯平滑。贝叶斯平滑假设模型上的某些先验概率并使用最大后验估计(max a posterior)。由此产生的平滑估计的多变量伯努利模型的形式: αw 和 βw是依赖于w的参数。不同的参数设置导致不同的估计结果。一种流行的选择是对所有w设置αw= 1和βw= 0。结果是以下的估计: - - 自然语言处理 另一个选择是,对所有的w设置和。其中Nw是在w出现时训练文档的总数,μ是可调参数。结果是如下估计: 此事件空间只记录一个词是否出现;它没有记录这个词出现了多少次,但词频可是一个重要的信息,对长文本来说尤其如此。现在我们描述一个考虑到频率的多项(multinomial)事件空间。 文档id cheap buy banking dinner the class 1 2 3 4 5 6 7 8 9 10 0 3 0 2 5 0 0 0 0 1 0 0 0 0 2 0 1 1 0 0 0 1 0 3 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 1 2 1 1 2 1 1 1 1 1 1 not spam spam not spam spam spam not spam not spam not spam not spam not spam 表5-6 在多项事件空间中如何表示文档 在表5-6的例子中有10个文档(每个文档用唯一的id标识),两类(spam 和not spam),和包含“cheap”,“buy”,“banking”,“dinner”和“the”这些项的词汇表。和多变量伯努利表示唯一的区别是事件不再是布尔型的。多项模型的最大似然估计和多变量伯努利模型很相似,公式是: - - 自然语言处理 这里tfw,c是训练集中的词w在类c中出现的次数,而|c|是属于类c的词总次数。在垃圾分类例子中,P(the|spam)=4/20,P(the|not spam)=9/15,P(dinner|spam)=0,而P(dinner|not spam)=1/15。 因为词是多项分布,所以给定类c的文档d的似然公式是: 这里tfw,d是词w在文档d中出现的次数,而|d|是出现在文档d中的词总次数。P(|d|)是产生长度是|d|的文档的概率,而是多项系数。注意到P(|d|)和多项系数是依赖于文档的,为了分类的目的,可以省略掉这两项。因此实际需要计算的是。 词似然值的贝叶斯平滑估计根据如下公式计算: 这里是一个依赖于w的参数。对所有的w,设置=1是一种可能的选择。对应如下的估计: 另一个流行的选择是设置,这里cfw是词w出现在训练文档中的总次数。|c|是词在所有的训练文档中出现的总次数,而则是一个可调的参数。在这个设置下,得到如下的估计: (w|c)= 多变量伯努利(Multiple Bernoulli)模型又叫做文档型模型。多项(multinomial)模型又叫做词频型模型。这里实现文档型分类模型。 - - 自然语言处理 计算先验概率(Prior Probability)的实现代码如下: private static TrainingData trainingData=TrainingData.getInstance();//得到训练集 /** * 先验概率 * @param c 给定的分类 * @return 给定条件下的先验概率 */ public static float calculatePc(String c){ float Nc = trainingData.getClassDocNum(c);//给定分类的训练文本数 float N = trainingData.getTotalNum();//训练集中文本总数 return (Nc / N); } 然后计算类条件概率。类条件概率的公式是: 其中,N(W=wi, C=cj)表示类别cj中包含词wi的训练文本数量;N(C=cj)表示类别cj中的训练文本数量;M值用于平滑,避免 N(W=wi, C=cj)过小所引发的问题;V表示类别的总数。 /** * 计算类条件概率 * @param w 给定的词 * @param c 给定的分类 * @return 给定条件下的类条件概率 */ public static float calculatePwc(String w, String c) { //返回给定分类中包含分类特征词的训练文本的数目 float dfwc = tdm.getCountContainKeyOfClassification(c, x); //返回训练文本集中在给定分类下的训练文本数目 float Nc = tdm.getClassDocNum(c); //类别数量 float V = tdm.getTraningClassifications().length; return ( (dfwc + 1) / (Nc + M + V)); } 利用样本数据集计算先验概率和各个文本向量属性在分类中的条件概率,从而计算出各个概率值,最后 - - 自然语言处理 比较各个概率值,选出最大的概率值对应的文本类别,即为文本所属的分类。 为了避免结果过小,对乘积的结果取对数,因此计算整个概率的公式是: /** * @param d 给定文本的属性向量 * @param Cj 给定的类别 * @return 类别概率 */ float calcProd(String[] d, String Cj){ float ret = 0.0F; //类条件概率连乘 for (int i = 0; i crs = new ArrayList();//分类结果 for (int i = 0; i maxPro){ c = cr.classification; maxPro = cr.probility; } } return c; 5.10.4 支持向量机 和贝叶斯分类器基于概率来分类不同,支持向量机(SVM)是基于几何学原理来实现分类的。假设“+”是特征空间的一类点,而“-”是特征空间中的另外一类点。SVM通过分类面来判断特征空间中的待分类点的类别。基本思想可用图5-5的两维情况说明: w·x < 0 图5-5 分类间隔 假设有N个点在p维特征空间X={x1, x2, …, xp}中分别属于两类:C+和C-。要解决的问题是找到一个函数f(x1, x2, …, xp)判别出两类,对于C+ 类的点返回正值,而对于C-类的点返回负值。这个函数叫做判别函数(discriminant function)。 如果判别函数是线性的,则可以把判别函数看成如下形式: f(x1, x2, …, xp)= w1*x1+w2*x2+…+wp*xp+b - - 自然语言处理 假设向量w=(w1, w2, …, wp),向量x=(x1, x2, …, xp)。 用表示向量w和x之间的内积(inner product),或者叫做点乘积。 为了快速计算两个数组x和y的点积,只计算相同位置不为0的值就好了。对于稀疏维度,为了节省空间不采用哈希表储存,而是将index全部按升序排列,然后通过归并两个排好序的数组来计算。有点类似搜索引擎中的docList集合求AND的操作。计算的时间复杂度是O(m+n)。计算点积的实现代码如下: static double scalarProduct(Node[] x, Node[] y){ double sum = 0; int xlen = x.length; int ylen = y.length; int i = 0; int j = 0; while(i < xlen && j < ylen){ if(x[i].index == y[j].index) sum += x[i++].value * y[j++].value; else { if(x[i].index > y[j].index) ++j; else ++i; } } return sum; } 判别函数的向量化表示g(x)的形式是: g(x)=sign(+b) 也记做wT·x,也就是向量w的转置点乘向量x。这里sign是符号函数。这里符号函数sign(a)的定义是当a > 0,则返回1;当a=0,则返回0;当a<0,则返回-1。有时候也把符号函数写作σ。 一般把w称作权重向量(weight vector),把b叫做偏移量(bias)。+b=0所定义的面叫做超平面(hyperplane)。 每一个训练样本由一个向量(特征空间中的值组成的向量)和一个标记(标示出这个样本属于哪个类别)组成,记作:Di=(xi,yi)。其中,y的取值只有两种可能:1和-1(分别用来表示属于C+还是属于C-类)。 - - 自然语言处理 一般来说,如果超平面远离分类训练集中的点应该会最小化对新数据错误分类的风险。点i到平面∏w,b的距离d(∏w,b , xi) = | w·xi + b | / ||w|| 。d(∏w,b , xi)有时候也写做δi。 选取w和b,使得超平面到最近点的距离最大。也就是求解: max(w,b) (mini d(∏w,b , xi)), 这里的||w||叫做向量w的欧几里德范数(Euclidean norm),计算公式是:||w||= 对于距离超平面最近的C+类的点x+来说wT·x++b=1;对于距离超平面最近的C-类的点x-来说wT·x-+b=-1。因此: max(w,b) (mini d(∏w,b , xi))= 也就是求解下面这个基本的问题。 在yi(wT xi + b)≥1的条件下,求最小值: minw,b||w||2 满足相等约束条件的这些点叫做支持向量(support vector),因为这些点在支持(约束)超平面。用拉格朗日乘子(Lagrange multiplier)来解决线性约束下的优化问题。 举个用拉格朗日乘子求解极值的例子。把一个拉格朗日乘子的函数整合进需要求最大或最小值的表达式。 例如:f (x,y) = x+2y 有约束条件:g(x,y) = x2+y2-4 = 0 则引入一个拉格朗日乘子后,对应拉格朗日函数:L(x,y,λ) = x+2y+λ(x2+y2-4) 求导数: =1+2λx=0 (1) =2+2λy=0 (2) =x2+y2-4=0 (3) - - 自然语言处理 首先根据(1),(2),(3)解出拉格朗日乘子λ的值,然后就可以计算出f (x,y)的最小值。对于线性可分的问题,可以通过二次规划(Quadratic programming)计算出拉格朗日乘子λ的值。 文本分类抽象成对空间中的点分类。举个对图5-6二维空间中的样本点分类的简单的例子: 假设标志成+的点如下:{(3,1),(3,-1),(6,1),(6,-1)}。 标志成-的点如下:{(1,0),(0,1),(0,-1),(-1,0)}。 图5-6二维空间中的样本点 因为数据是线性可分的,可以使用一个线性SVM,也就是说Φ就是原函数。有三个支持向量。s1 =(1,0) ; s2 =(3,1); s3 =(3,-1),增加一个偏移量输入1,则 计算拉格朗日乘子α。 - - 自然语言处理 计算支持向量的点乘积,得到结果: 2α1+4α2+4α3= -1 4α1+11α2+9α3= +1 4α1+9α2+11α3= +1 求解得到: α1=-3.5,α2=0.75,α3= 0.75 w可以表示成支持向量的线性组合: = = = 求解结果是w=(1,0),b=-2。 设x+是任意一个属于C+的支持向量,x-是任意一个属于C-的支持向量。而且,总是至少会存在一个x+,也总是至少会存在一个x-。 根据等式: wT x++b=1 和 wT x-+b=-1 推导出b的另一个计算公式是:b=-(wT x++ wT x-) b=-(wT s2+ wT s1)= -=-(3+1)=-2 W2=0意味着特征空间的点的第二个维度对分类结果没有影响。判别条件是:如果x - - 自然语言处理 1>2,则该点属于+类;如果x1<2,则该点属于-类。分类超平面如图5-7所示。 图5-7超平面 另外一个计算SVM的例子:在二维空间中有4个点,对应的标记值是y。四个点描述如下: x1=(-2,-2) y1= +1 x2=(-1,1) y2= +1 x3=(1,1) y3= -1 x4=(2,-2) y4= -1 在αi>=0的条件下求下面这个拉格朗日函数的最大值: =α1+α2+α3+α4 - 4α12 - α22 - α32 - 4α42 - 4α1α3 - 4α2α4 得到:α1=0 α2= α3= α4=0 因此支持向量是x2和x3。 - - 自然语言处理 b=y2 – wTx2=0 在一维空间中,没有任何一个线性函数能解决图5-8所示的划分问题(粗线和细线各代表一类数据),可见线性判别函数有一定的局限性。 a b 图5-8 线性函数不能分类的问题 如果建立一个图5-9所示的二次判别函数g(x)=(x-a)(x-b),则可以很好地解决图5-8所示的分类问题。 图5-9 二次判别函数 这里g(x)=w1*x2+w2*x+b 以前的特征空间只有一个维度,这个维度对应的值也就是x的值。现在的特征空间有两个维度,第一个维度对应的值还是x的值,第二个维度对应的值是x2的值。 举个日常生活中的例子:假设有个十字路口。连续不停南来北往的车辆会让这个十字路口拥堵。即使有红绿灯也无法完全解决问题,但是那是因为从平面交叉的二维角度来看才会无解。如果在这里建一个立交桥,车流就会变得顺畅无阻。也就是说加入“高度”这个维度,进入三维空间,问题就得到更好的解决。 - - 自然语言处理 因此,可以通过核函数把特征空间映射到更高的维度,这样有可能找到更好的超平面。图10.3的例子是多项式核,此外常用的还有RBF核。 使用一个映射函数Φ把输入空间中的数据转换成特征空间中的数据使得分类问题变成线性可分的。然后求解出最优分隔超平面。当超平面通过Φ-1映射回输入空间时,超平面变成了一个复杂的决策表面。 分类问题可以转化成相似度计算的问题。如果待分类的点和正类点之间的相似性高则可以把待分类的点分到正类,反之,如果待分类的点和负类点之间的相似性高则可以把待分类的点分到负类。相似性可以用向量间夹角的余弦,或者说两个向量的内积表示。用核函数K(xi,xj)= Φ(xi)T Φ(xj)来度量两个点的相似性。 回顾前面介绍的文本分类的方法把一个文档映射成有很高维度的向量。这种方法丢失了所有的词的顺序信息,而仅仅保留了文档中的词的频率信息。字符串核(String kernel)通过从文档中提取k个前后相连的字作为特征来保留词的顺序信息。字符串核的另外一种实现方法是:首先把字符串转换成后缀树(Suffix Tree),然后构造树核(Tree Kernel)。 因为训练集中噪声的存在,尤其因为训练集不能代表全部判决空间,所以正确处理相对模糊的间隔区域(margin)与全集的比例是关键。最优分类超平面的定义是指:该分类面不但能正确分类而且使各类别的分类间隔最大。最优训练的含义是:在确定的平面分类间隔条件下,不但训练序列的判决正确率高,而且推广到全集序列的判决率也尽可能高。 SVM最基本的方法只能实现两类分类,可以通过组合多个两类分类器来实现多类分类。 例如有N类,一种解决方法是学习N个SVM分类器。 SVM 1 学习 “Output==1” vs “Output != 1” SVM 2 学习 “Output==2” vs “Output != 2” … SVM N 学习 “Output==N” vs “Output != N” 计算文档属于每个类别的隶属度,然后取最大隶属度对应的类别。这个方法叫做一类对余类。取最大隶属度对应的类别实现代码如下: // 输入每个类别的隶属度组成的数组 // 得到文档的所属类别ID private int singleCategory(double[] simRatio) { int catID = 0; double maxNum = simRatio[catID]; for (int i = 1; i < m_nClassNum; i++) { if (simRatio[i] > maxNum) { - - 自然语言处理 maxNum = simRatio[i]; catID = i; } } return catID; } 另外介绍一种两类分类器来实现多类分类的方法,叫做错误校正输出编码(Error Correcting Output Coding)。例如有m = 4 个类别,分别是:政治、体育、商业、艺术。 分配唯一的n位向量给每个类名,这里n > log2m。第i个位向量作为类名i的唯一的编码。m个类名组成的位矩阵记作C,如表5-7所示。 对每列构建独立的二元分类器。这里是10个分类器。正类文本是Cij = 1对应的类别。负类是Cij = 0对应的类别。例如:第三个分类器把{体育、艺术}作为负类,{政治、商业}作为正类。 类名 编码 政治 0110110001 体育 0001111100 商业 1010101101 艺术 1000011010 表5-7 类名编码 通过插件分类器判断文档类别。把预测某一位的值的分类器叫做插件分类器,一个插件分类器预测文档属于某个类别的子集。根据{λ1, λ2 ,…,λn}来判断文档的类别。 计算文档x的类别时,先生成一个n位的向量。 λ(x) = {λ1(x), λ2(x), …, λn(x)} 很有可能,生成的位向量λ(x)不是C中的一行,但是可能更像某些行,也就是和某些行的海明距离更近。把文档分类成这行对应的类别,也就是如下的公式: argmini HamingDistance(Ci,λ(x)) 可以根据海明距离判断λ(x)和哪行最相似。 - - 自然语言处理 假设Ci和λ(x)最相似,则第i个类别作为文档x的类别。例如,如果生成的位向量是(x) = {1010111101},则把这篇文档分到商业类。 自动分类的SVM方法接口分为训练过程接口和执行分类的接口。分类器训练过程的接口如下: //创建一个分类器 Classifier svmClassifier = new Classifier(); //设置训练文本的路径 svmClassifier.setTrainSet("D:/train"); //设置训练输出模型的路径 svmClassifier.setModel("D:/model"); //执行训练 svmClassifier.train(); 分类器训练好的结果写入D:/model目录。然后执行分类的接口如下: //加载已经训练好的分类模型 Classifier theClassifier = new Classifier("D:/model/model.prj"); //文本分类的内容 String content ="我要买把吉他,希望是二手的,价格2000元以下"; //开始使用SVM方法分类 String catName = theClassifier.getCategoryName(content); System.out.println("类别名称:"+catName); 5.10.5 多级分类 以二级分类为例,在路径: D:\train\zippo收藏乐器 下建立子路径: D:\train\zippo收藏乐器\打火机zippo烟具 D:\train\zippo收藏乐器\古董收藏 D:\train\zippo收藏乐器\乐器乐谱 D:\train\zippo收藏乐器\邮币卡字画 下面是训练二级分类的程序: - - 自然语言处理 //训练主分类 String strPath = "D:/lg/work/xiaoxishu/train"; String modelPath = "D:/lg/work/xiaoxishu/model"; Classifier svmClassifier = new Classifier(); svmClassifier.setTrainSet(strPath); svmClassifier.setModel(modelPath); svmClassifier.train(); File dir = new File(strPath); File[] files = dir.listFiles(); for (int i = 0; i < files.length; i++){ File f = files[i]; if (f.isDirectory()) { //训练子分类 System.out.println(f.getAbsoluteFile().getName()); String subTrain = strPath +"/" + f.getAbsoluteFile().getName(); Classifier subClassifier = new Classifier(); subClassifier.setTrainSet(subTrain); String subModelPath = modelPath +"/" + f.getAbsoluteFile().getName(); subClassifier.setModel(subModelPath); subClassifier.train(); } } 下面是分类的执行过程: String modelPath = "D:/lg/work/xiaoxishu/model"; Classifier theClassifier = new Classifier(modelPath+"/model.prj"); String content = "我要买把吉他,希望是二手的,价格2000元以下"; //分类文本内容 System.out.println("分类开始"); String catName = theClassifier.getCategoryName(content); System.out.println("catName:"+catName); if(catName == null){ //如果没有主分类则返回 return; } String subModelPath = modelPath+"/"+catName+"/model.prj"; Classifier subClassifier = new Classifier(subModelPath); String subCatName = subClassifier.getCategoryName(content); - - 自然语言处理 System.out.println("subCatName:"+subCatName); 上面的执行结果将打印出: 分类开始 catName:zippo收藏乐器 subCatName:乐器乐谱 也就是把“我要买把吉他,希望是二手的,价格2000元以下”分成大类属于“zippo收藏乐器”,子类是“乐器乐谱”。 5.10.6 规则方法 朴素贝页斯和SVM的方法基于文档中很多单词的组合加权来判断文档的类别,但是人工无法调整从训练集中学习出来的分类模型。开车可用手动档,文本分类也可以用人工编写的规则。人工编写的规则容易理解,而且可以达到很高的精度,但是完全由人工开发和维护的规则模型代价昂贵。 关键词识别规则不是基于单词频率,而是基于某个单词有没有出现在指定的位置。例如:如果文档中出现“NBA”这个词就把这篇文档归到“体育”类。 NBA=>体育 实现文本分类规则的代码如下: public class Rule { public String[] antecedent;//关键词 public String consequent;//分类结果 public Rule (String[] antd,String classLabel){ antecedent = antd; consequent = classLabel; } /** * 规则是否覆盖分类文档,也就是这个文档是否满足规则的条件 * * @param doc 待分类实例 * @return 如果满足分类条件,则返回true */ public boolean covers(HashSet doc){ - - 自然语言处理 for(int i=0;ic1 R2:p2->c2 … 测试文档 判断类别 类别C 图5-10 基于规则的文本分类流程图 根据规则对文本分类代码如下: /** * 对给定的文本进行分类 * @param text 给定的文本 * @return 分类结果 */ public String classify(String text) { ArrayList terms = spliter.getWords(text);//中文分词处理 - - 自然语言处理 HashSet instance = new HashSet(); instance.addAll(terms); for (int i = 0; i < decisionList.size(); i++) { Rule rule = decisionList.get(i); if (rule.covers(instance)) return rule.consequent; } return ""; } 学习出分类模型可以看成生成规则和对规则排序两个过程。 生成规则的方法: 先用特征选择的方法,例如CHI,生成每个类别的特征词。然后根据共同出现在一个句子中的特征词组合生成规则,最后验证规则的有效性。 规则可以由人工编写。可以采用搜索的方法初步检验规则的有效性。把规则的条件作为搜索词输入,如果返回的搜索结果按类别的分类统计只有一类,则说明这个规则有100%的精确度。例如从:http://search.gongkong.com/SearchProduct.aspx 搜索“变频器 AND 矢量 AND 解耦”,只返回“低压变频器”类别的产品。由此得到规则: 变频器 矢量 解耦=> 低压变频器 除了人工编写,还可以采用机器学习的方法从训练集学习出分类规则。例如PRISM和RIPPER算法。 PRISM算法在构建每个规则后,先删除这个规则覆盖的那些文档,然后再找新规则。从高覆盖度的规则开始,然后通过增加更多的条件来增加它的准确度。要最大化每个规则的准确度。当准确度是1,或者没有更多的文档时,停止增加条件。PRISM算法伪代码如下: for each class C initialise E to the complete instance set while E contains instances with class C create empty rule R if X then C until R is perfect (or no more attributes)? for each attribute A not in R, and each value v, consider adding A=v to R select A and v to maximise accuracy of p/t add A=v to R remove instances covered by R from E - - 自然语言处理 RIPPER规则学习算法对于两类问题,选择一类作为正类,另外一类作为负类。学习分到正类的规则,把负类作为缺省类别。对于多类分类问题,按类的流行程度(有多大比例的文档属于一个类别)增加的方式对类别排序,最小的类首先学习规则集合,把其他的类别看成是负类,重复让下一个最小的类别作为正类。 根据训练集对给定的规则排序就是求解指定规则集合的最优排序,使得正确标注的实例数量最大。 发现决策列表的贪心算法:每次叠代过程选择一个规则,然后把这个规则加入到决策列表的末尾,删除选择出的规则。继续这个过程直到输出所有的规则。每次选择规则时,会使用一个打分函数对每个规则打分,然后选择有最大分值的规则。 5.10.7 网页分类 抓取的新闻网站,比如 http://news.sina.com.cn/ 这个上面本来就有国际新闻和国内新闻的栏目。如果索引页能分成“国际新闻”或“国内新闻”的一种,详细页的类别参考索引页 的分类,那么就可以形成一个“国际新闻”和“国内新闻”的分类训练样本库。 另外,从http://bj.58.com/job.shtml网址就可以知道它可能是和招聘相关的网页,而且可能是北京地区的网页。因此,使用URL地址可以快速分类网页。例如,凤凰网中的新闻的url地址: http://news.ifeng.com/mil/4/detail_2010_09/11/2489355_0.shtml url中包含reading的就分为读书类,url中包含mil的就分为军事类,url中包含culture的就分为文化类。 为了提取“http://bj.58.com/job.shtml”的特征词“bj”、“58”、“job”,首先按“:”、“/” 、“.”切分,然后通过词干化和小写化处理,去除停用词“http”,“com”,“shtml”,最后剩下“bj”、“58”、“job”三个分类特征词。 5.11 文本聚类 将一个数据对象的集合分组成为类似的对象组成的多个类的过程称为聚类。每一个类称为簇,同簇中的对象彼此相似,不同簇中的对象相异。聚类不同于前面提到的分类,它不需要训练集合。 文档聚类就是对文档集合进行划分,使得同类间的文档相似度比较大,不同类的文档相似度较小,不需要预先对文档标记类别,具有较高的自动化能力,已经成为文本信息进行有效地组织、摘要和导航的重要手段。 Carrot2(http://project.carrot2.org/)是一个开源的聚类搜索引擎,可以把其它网站的搜索结果聚类 - - 自然语言处理 。 5.11.1 K均值聚类方法 目前存在着大量的聚类方法。算法的选择取决于数据的类型、聚类的目的和应用。在众多的方法中,K均值方法是一种比较流行的方法且其聚类的效果也比较好。 K均值方法是把含有n个对象的集合划分成指定的k个簇。每一个簇中对象的平均值称为该簇的聚点(中心)。两个簇的相似度就是根据两个聚点而计算出来的。假设聚点x、y都有m个属性,取值分别为x1,x2,…,xm和y1,y2,…,ym,则x和y的距离。 K均值算法有如下5个步骤: 1. 任意从n个对象中选择k个对象做为初始簇的中心; 2. 根据簇中对象的平均值,即簇的聚点,将每个对象(重新)赋给最类似的簇; 3. 重新计算每个簇的平均值,即更改簇的聚点。 4. 若某些簇的聚点发生了变化,转步骤2);若所有的簇的聚点无变化,转步骤5) 5. 输出划分结果。 K均值算法的流程图如图5-5所示。 - - 自然语言处理 选择初始聚点 划分对象 更改聚点 输出划分结果 满足终止条件 Y N 图5-5 K均值算法流程图 对于图5-5流程中的前三个步骤都有各种方法,通过组合可以得到不同的划分方法。下面在K均值算法的基础上,许多改进算法在如何选择初始聚点、如何划分对象及如何修改聚点等方面提出了不同的方法。 初始聚点的选择对最终的划分有很大的影响。选择的初始聚点不同,算法求的解也不同。选择适当的初始聚点可以加快算法的收敛速度并改善解的质量。 选择初始聚点的方法有以下几种: 1)随机选择法。随机的选择K个对象作为初始聚点。 2)最小最大法。现选择所有对象中相距最远的两个对象作为聚点,然后选择第三个聚点,使得它与已确定的聚点的最小距离是其余对象与已确定的聚点的较小距离中最大的,然后按同样的原则选择以后的聚点。 3)最小距离法。选择一个正数r,把所有对象的中心作为第一个聚点,然后依次输入对象,如果当前输入对象与已确定的聚点的距离都大于r,则该对象作为一个新的聚点。 划分方法就是决定当前对象应该分到哪一个簇中。划分方法中最为流行的是最近归类法,即将当前对象归类于距离最近的聚点。 - - 自然语言处理 修改聚点的方法有: 1)按批修改法。把全部对象输入后再修改聚点和划分。步骤如下:①选择一组聚点;②根据聚点划分对象;③计算每个簇的中心作为新的聚点,如果划分合理则停止,否则转②。 2)逐个修改法。每输入一个对象的同时就改变该对象所归簇的聚点。步骤如下:①选择一组聚点;②将余下的对象依次输入,每输入一个对象,按当前的聚点将其归类,并重新计算中心,代替原先的聚点;③如果划分合理则停止,否则转②。 5.11.2 K均值实现 下面的测试方法中,输入20个元素,聚类成3个集合。 public class KMeans { //计算欧氏距离 private static double EuDistance(double array1[], double array2[]) { double Dist = 0.0; if (array1.length != array2.length) { System.out.println("the number of the arrary is ineql"); } else { for (int i = 0; i < array2.length; i++) { Dist = Dist + (array1[i] - array2[i]) * (array1[i] - array2[i]); } } return Math.sqrt(Dist); } //打印整数数组 private static void printArray(int array[]) { System.out.print('['); for (int i = 0; i < array.length; i++) { System.out.print(array[i]); if ((i + 1) < array.length) { System.out.print(", "); } } System.out.println(']'); } - - 自然语言处理 //打印浮点数距离 private static void printMatrix(double Matrix[][], int row, int col){ System.out.println("Matrix is:"); System.out.println('{'); for(int i=0; i= MinPts N = N 与N'合并 if P' 不是任何簇的成员 把P'加到簇C 5.11.4 使用DBScan算法聚类实例 Weka(http://www.cs.waikato.ac.nz/ml/weka/)中有个DBScan算法的实现。源代码在weka.clusterers包中,文件名为DBScan.java - - 自然语言处理 。 其中buildClusterer和expandCluster这两个方法是最核心的方法。buildClusterer是所有聚类方法实现的接口,而 expandCluster是用于扩展样本对象集合的高密度连接区域的。另外还有一个用于查询指定点的ε邻居的方法,叫epsilonRangeQuery,这个方法在Database类中,调用例子如下: seedList = database.epsilonRangeQuery(getEpsilon(),dataObject); 在buildClusterer方法中,通过对每个未聚类的样本点调用expandCluster方法进行处理,查找由这个对象开始的密度相连的最大样本对象集合。在这个方法中处理的主要代码如下: while (iterator.hasNext()) { DataObject dataObject = (DataObject) iterator.next(); if (dataObject.getClusterLabel() == DataObject.UNCLASSIFIED) { if (expandCluster(dataObject)) {//一个簇已经形成 clusterID++;//取下一个聚类标号 numberOfGeneratedClusters++; } } } 下面再来看expandCluster方法,这个方法的输入参数是样本对象dataObject。这个方法首先判断这个样本对象是不是核心对象,如果是核心对象再判断这个样本对象的ε邻域中的每一个对象,检查它们是不是核心对象,如果是核心对象则将其合并到当前的聚类中。源代码分析如下: //查找输入的dataObject这个样本对象的ε邻域中的所有样本对象 List seedList = database.epsilonRangeQuery(getEpsilon(), dataObject); //判断dataObject是不是核心对象 if (seedList.size() < getMinPoints()) {   //如果不是核心对象则将其设置为噪声点   dataObject.setClusterLabel(DataObject.NOISE);   //没有发现新的簇,所以返回false   return false; } //如果样本对象dataObject是核心对象,则对其邻域中的每一个对象进行处理 for (int i = 0; i < seedList.size(); i++) {   DataObject seedListDataObject = (DataObject) seedList.get(i);   //设置dataObject邻域中的每个样本对象的聚类标识,将其归为一个簇 - - 自然语言处理   seedListDataObject.setClusterLabel(clusterID);   //如果邻域中的样本对象与当前这个dataObject是同一个对象那么将其删除   if (seedListDataObject.equals(dataObject)) {     seedList.remove(i);     i--;   } } //对dataObject的ε邻域中的每一个样本对象进行处理 for (int j = 0; j < seedList.size(); j++) {   //从邻域中取出一个样本对象seedListDataObject   DataObject seedListDataObject = (DataObject) seedList.get(j);     //查找seedListDataObject的epsilon邻域并取得其中所有的样本对象   List seedListDataObject_Neighbourhood = database.epsilonRangeQuery(getEpsilon(), seedListDataObject);   //判断seedListDataObject是不是核心对象   if (seedListDataObject_Neighbourhood.size() >= getMinPoints()) {     for (int i = 0; i < seedListDataObject_Neighbourhood.size(); i++){       DataObject p = (DataObject) seedListDataObject_Neighbourhood.get(i);     //如果seedListDataObject样本对象是一个核心对象     //则将这个样本对象邻域中的所有未被聚类的对象添加到seedList中       //并且设置其中未聚类对象或噪声对象的聚类标号为当前聚类标号      if (p.getClusterLabel() == DataObject.UNCLASSIFIED || p.getClusterLabel() == DataObject.NOISE) {        if (p.getClusterLabel() == DataObject.UNCLASSIFIED) {      //在这里将样本对象p添加到seedList列表中          seedList.add(p);        }      p.setClusterLabel(clusterID);    }    } }   //去除当前处理过的样本点   seedList.remove(j); - - 自然语言处理   j--; } //发现新的簇,所以返回true return true; 5.12 拼音转换 当搜索“zhonghuarenmingongheguo”,搜索结果会提示:您是不是要找“中华人民共和国”。有了这项功能以后,用户可以直接输入拼音串搜索。在具体实现上,可以采用用户搜索的高频词和汉语拼音对照的方法来解决。 因为存在多音字的问题,所以不能仅仅只有字和音的对照表。首先准备好拼音词库,例如: jvnguanzheng:军官证:0 shuangchunyin:双唇音:0 bolinqiang:柏林墙:0 jiaoyi:交谊:0 zhanhexiang:战河乡:0 shouzhi:手织:0 liuyixiang:柳驿乡:0 anxiang:安享:0 moganshanzhen:莫干山镇:0 zuochou:柞绸:0 wuxia:无暇:7 meileike:梅雷克:0 这里的词典格式是:每个词一行,第一列是音,第二列是词,第三列是词的频率。 因为同一个拼音串的对应的候选汉字串较多,需要借助于上下文才能选出更有意义的候选串,这里先建立词的二元模型。可以从搜索日志中学习二元连接词典BigramDict.txt。 最后调用Converter 把拼音转换成汉字。 Convertor convertor = new Convertor(); String yin = "dianji"; word = convertor.find(yin); 执行后返回word中返回结果是“电机”。 - - 自然语言处理 5.13 概念搜索 一个词可能有好几个意思。例如“地道”有两个意思,作为名词时表示:在地面下掘成的交通坑道,作为形容词时表示:纯粹的,真正的。另外有些词可以表示同样的意思,例如“西红柿”和“蕃茄”是同义词,“招商行”是“招商银行”的简称,也算同义词。 可以从网络挖掘出同义词词库。例如有下面一些链接到同一个网站的标签。 中国招商银行 招行 招商银行 从上面的链接中可以提取出同义词: 中国招商银行 招行 招商银行 还可以从语料库挖掘同义词。先找这个词与哪些词有比较强的关联,得到一个向量,所有的词通过向量比较就能知道同义程度。 另外可以通过句子模版发现同义词。从Google搜索“大豆 又称”,可以发现:大豆(又称黃豆)。这样可以找到“大豆”的同义词“黃豆”。 这里通过同义词搜索来尝试语义扩展的搜索,也就是通过查询扩展的方式实现语义搜索。当用户输入“计算机”搜索的同时,程序通过查找同义词库,按照“计算机”,“电脑”,“微机”等多个同义词查找。当用户输入“轿车”的同时,也能按照“奥迪”,“奔驰”等进行下位词扩展。 简单的做法是把同义词保存在一个散列表中。如果同义词词表很大,可以把同义词保存在一个索引库中。 SynonymEngine是一个很简单的接口,输入一个词,返回它的同义词。 public interface SynonymEngine { String[] getSynonyms(String s) throws IOException; } DexSynonymEngine使用一个简单的存储在散列表中的同义词实现。 public class DexSynonymEngine implements SynonymEngine { //词和对应的同义词数组 private static Map map = new HashMap(); static { - - 自然语言处理 //数字同义 map.put("1", new String[] { "一" }); map.put("2", new String[] { "二" }); map.put("3", new String[] { "三" }); map.put("4", new String[] { "四" }); map.put("5", new String[] { "五" }); map.put("6", new String[] { "六"}); map.put("7", new String[] { "七" }); map.put("8", new String[] { "八" }); map.put("9", new String[] { "九" }); map.put("10", new String[] { "十" }); //日期同义 map.put("非周末", new String[] { "周一","周二","周三","周四","周五" }); map.put("周末", new String[] { "周六","周日" }); //词同义 map.put("西红柿", new String[] { "番茄" }); map.put("黄豆", new String[] { "大豆" }); } public String[] getSynonyms(String word) throws IOException { return map.get(word); } } 增加一个SynonymFilter用来扩展同义词。 public class SynonymFilter extends TokenFilter { public static final String TOKEN_TYPE_SYNONYM = "SYNONYM"; private Stack synonymStack; private SynonymEngine engine; private TermAttribute termAttr; private AttributeSource save; public SynonymFilter(TokenStream in, SynonymEngine engine) { super(in); - - 自然语言处理 synonymStack = new Stack(); //同义词缓存 termAttr = (TermAttribute) addAttribute(TermAttribute.class); save = in.cloneAttributes(); this.engine = engine; } public boolean incrementToken() throws IOException { //弹出缓存的同义词 if (synonymStack.size() > 0) { State syn = (State) synonymStack.pop(); restoreState(syn); return true; } //读下一个词 if (!input.incrementToken()) return false; //把当前词的同义词压入堆栈 addAliasesToStack(); //返回当前的词 return true; } private void addAliasesToStack() throws IOException { String[] synonyms = engine.getSynonyms(termAttr.term()); //查询同义词 if (synonyms == null) return; State current = captureState(); //把同义词压入堆栈 for (int i = 0; i < synonyms.length; i++) { save.restoreState(current); AnalyzerUtils.setTerm(save, synonyms[i]); AnalyzerUtils.setType(save, TOKEN_TYPE_SYNONYM); //设置位置增量为0 AnalyzerUtils.setPositionIncrement(save, 0); synonymStack.push(save.captureState()); - - 自然语言处理 } } } 然后在SynonymAnalyzer中使用它。 public class SynonymAnalyzer extends Analyzer { private SynonymEngine engine;//保存了一个词的同义词 public SynonymAnalyzer(SynonymEngine engine) { this.engine = engine; } public TokenStream tokenStream(String fieldName, Reader reader) { TokenStream result = new CnTokenizer(reader);//先分词 result = new SingleFilter(result);//再拆分成单字 result = new SynonymFilter(result,engine);//在TokenStream中增加同义词 return result; } } 索引时使用SynonymAnalyzer把同义词扩展放到索引库,在搜索时使用下面这个CnAnalyzer,不做同义词扩展。 public class CnAnalyzer extends Analyzer { public TokenStream tokenStream(String fieldName, Reader reader) { TokenStream stream = new CnTokenizer(reader); stream = new SingleFilter(stream); return stream; } } 实现这个功能的基本步骤如下: 1. 准备语义词库; 2. 把语义词库转换成同义词索引库; 3. 在SynonymAnalyzer中使用同义词索引库。 英文的同义词词库最著名的是WordNet,它的介绍和下载地址在http://wordnet.princeton.edu。中文方面,“同义词词林”和HowNet都有现成的同义词库。以“同义词词林 - - 自然语言处理 ”为例,它用树型结构表示了词的同义和上下位关系。 下面是同义词的例子: Bp31B 表 Bp31B01= 表 手表 Bp31B02= 马表 跑表 停表 Bp31B03= 怀表 挂表 Bp31B04= 防水表 游泳表 Bp31B05= 表针 指针 Bp31B06= 表盘 表面 Bp31B07# 夜光表 秒表 电子表 日历表 自动表 Bp31B08= LONGINES 浪琴 Bp31B09= Rema 瑞尔玛 Bp31B10= ROSSINI 罗西尼 Bp31B11= SEIKO 精工 Bp31B12= SUUNTO 松拓 Bp31B13= Tissot 天梭 Bp31B14= CASIO 卡西欧 整理出这样的词表后,我们通过下面的程序转换成WordNet的prolog同义词数据库wn_s.pl格式: s(synset_id,w_num,'word',ss_type,sense_number,tag_count). 第一列是语义编码,同一个语义有唯一的语义编码列,第二列是单词编号,第三列是单词本身,第四列是词性,第五列代表该词第几个语义,第六列是该词在语料库中出现的频率。例如: s(100006026,1,'person',n,1,7229). s(100006026,2,'individual',n,1,51). - - 自然语言处理 s(100006026,3,'someone',n,1,17). s(100006026,4,'somebody',n,1,0). s(100006026,5,'mortal',n,1,2). s(100006026,6,'human',n,1,7). s(100006026,7,'soul',n,2,6). 同义词词林按照该格式整理如下: s(Dk02A12,1,'中学',n,1,0). s(Dk02A12,2,'国学',n,1,0). s(Dk02A12,3,'旧学',n,1,0). 这个辞典格式还没有词性信息,而词典中的词有词性却没有词义信息。可以借助词典中的词性猜测同义词词林中的义项的词性。 需要给词典文件中的每个词条标注语义。词典文件的格式是: 词:词性:词频:拼音:语义1,语义2 例如: 一举一动:26880:2:yijuyidong:Di20B01 实现过程: 1.给同义词词林的每个义项增加词性。为了更好的消除歧义,可以给每个义项增加词性。例如:“行”作为名词用的时候可能是“行业”的意思,语义编码是Di18B01,“行”作为量词用的时候可能是“队列”的意思,语义编码是Dd07B01。可以参考基本词典中对应的每个词的词性,然后统计最有可能的词性。 2.给基本词典中词频大于1的常用词加上义项到同义词词林。只需要把每个常用词分类到二级子类即可。例如归类到“Be”这类就可以。 Be 地 貌 地 形 全部内容如下: Be01 陆地 原野 沙漠 陆空 Be01A01= 陆地 大陆 陆 洲 地 陆上 次大陆 新大陆 大洲 沂 ... Be03 滩 岸 - - 自然语言处理 Be04A50= 恒山 北岳 ... Be05 海洋 江河 溪涧 濒海 Be05A01= 海洋 大海 大洋 五洋 沧海 海洋 溟 瀛 瀛海 沧溟 重溟 大壑 水宗 海域 浅海 汪洋大海 深海 Be05A02= 远洋 重洋 Be05A03= 洋流 海流 Be05A04= 大陆架 陆棚 大陆棚 陆架 大陆坡 Be05A05= 内海 内陆海 陆海 Be05A06= 海岭 海脊 Be05A07# 海湾 海峡 海沟 海弯 海床 Be05A08= 国际海域 公海 最后可以采用字面搜索分类的方法给未标注语义的词标注上语义。 利用Lucene筛选最相关词的方法。先对同义词词林中所有的词按字建索引,索引库分为两列,第一列是词本身,第二列是每个词所属的类别。然后把要归类的词作为搜索关键词查找同义词索引库。 例如“海勒斯台高勒河”不在已有的同义词表中。看起来既有"海"又有"河"。所以Be类的词匹配的可能比较多。搜索后统计匹配上的词的类别。匹配上结果最多的类作为"海勒斯台高勒河"的类别。 同义词词林格式到WordNet格式实现转换的代码如下: public static void CL2WordNet(String sSourceFile,String save_filename,String dic_file){ DicCore dic = new DicCore(dic_file); BufferedReader fpSource = null; BufferedWriter output=null; String sLine; StringTokenizer st = null; String number= null; - - 自然语言处理 fpSource= new BufferedReader(new FileReader(sSourceFile)); output = new BufferedWriter(new FileWriter(save_filename)); while( (sLine = fpSource.readLine()) != null ) { if(sLine.length()>9 ) { int pos = sLine.indexOf('='); if (pos<=0) { continue; } number = sLine.substring(0,pos); st = new StringTokenizer(sLine.substring(pos + 1 ).trim()," \t\n\r"); String word; int count = 0; String wordPOS = "n"; while(st.hasMoreElements()) { count++; word = st.nextToken(); POSQueue posQ = dic.get(word); if(posQ==null) { System.out.print(word+":"+wordPOS+"\n"); } else if (posQ.size()==1) { wordPOS = DicCore.gePOSName(posQ.getHead().item.nPOS); } output.write("s("+number+","+count+",'"+word+"',"+wordPOS+",1,0)." +"\n"); } } } output.close(); fpSource.close(); } 然后通过程序Syns2Index,把wn_s_cn.pl转换成Lucene同义词索引库。通过Luke索引工具可以察看到索引结构是一个word值对应的多个syn同义词,如图5-7所示。 - - 自然语言处理 图5-7 同义词索引库 最后我们通过SynonymAnalyzer来调用这个同义词索引库实现同义词扩展查找。但是SynonymAnalyzer只是简单的通过词本身来扩展同义词,这样并不一定准确,尤其是对词义很多的词来说。Lucene4.0的灵活索引出来以前,不允许在索引中任意存储信息。但是Lucene 的2.2版本以后支持把对词的额外描述存储在Payload属性中。所以可以把一个词的语义编码存储在词的Payload属性中。 可以思考下动态同义词的实现。搜索星期日的时候同时返回下一个最近的日期,例如离当前日期2010年11月5日最近的一个星期日是2010年11月7日。 5.14 多语言搜索 Lucene支持多种世界上流行的语言,如表5-7所示。 语言 支持包 俄语 Org.apache.lucene.analysis.ru.RussianAnalyzer http://code.google.com/p/russianmorphology/ 法语 org.apache.lucene.analysis.fr.FrenchAnalyzer - - 自然语言处理 语言 支持包 日语  http://sourceforge.jp/projects/chasen-legacy/ 韩语 http://sourceforge.net/projects/lucenekorean/ 简体中文 org.apache.lucene.analysis.cjk.CJKAnalyzer org.apache.lucene.analysis.cn.ChineseAnalyzer 德语 Org.apache.lucene.analysis.de.GermanAnalyzer 泰语 org.apache.lucene.analysis.th.ThaiAnalyzer 表5-7 Lucene对多种语言的支持 https://issues.apache.org/jira/browse/LUCENE-2206 包含了处理丹麦语,荷兰语,英语,芬兰语,法语,德语,匈牙利语,意大利语,挪威语,俄语,西班牙语和瑞典语的停用词表和词干化功能。 举两个通过SnowballAnalyzer词干化的例子: Analyzer analyzer = new SnowballAnalyzer("Spanish"); //西班牙文 Analyzer analyzer = new SnowballAnalyzer("Portuguese"); //葡萄牙文 新中国建立以前曾拥有和使用本民族文字的有藏、蒙古、维吾尔、哈萨克、柯尔克孜、朝鲜、傣、彝、俄罗斯、苗、纳西、水、拉祜、景颇、锡伯等民族。新中国成立以来,国家为促进少数民族文化教育事业的发展,帮助一些少数民族改进和创制了文字,目前,我国已正式使用和经国家批准推行的少数民族文字有蒙古文、藏文、维吾尔文、朝鲜文、壮文等19种。现在世界各国所用的文字多数是拼音文字,我国的藏文、蒙文、维吾尔文等也都是拼音文字。 5.15 跨语言搜索 搜索内容中有繁体的内容。能想办法进行简繁转换吗?比如说搜索简体可以出现含繁体字内容的结果,反之亦然。 可以的,首先要有简繁互相转换的模块,然后可以在索引库中对同样的数据建立两列。一列是简体,还有一列是繁体。搜索用户界面也可以做成可在多种语言之间切换。 因为简体字和繁体字之间的对应关系是“一对多”的关系。所以从繁体转换到简体比较简单,只需要一个如下的汉字对照表,然后查表即可。 窩:窝 - - 自然语言处理 箋:笺 箏:筝 箇:个 綰:绾 綜:综 綽:绰 綾:绫 綠:绿 緊:紧 綴:缀 網:网 简体转换到繁体因为存在一个简体中文的一个字在繁体中对应多个字的情况,所以实现起来比繁转简要复杂。例如“出来演落幕的一出戏”翻译成繁体是:“出來演落幕的一齣戲”。 简体转繁体的过程是:简体文字做分词,通过词级别的对照转换到繁体。如下是一个简繁词语对照表: 完好无恙:完好無恙 完备:完備 完备:完備 完好无缺:完好無缺 完好无损:完好無損 宏伟:宏偉 完璧归赵:完璧歸趙 最后通过简繁转换类SimpTradConv实现简体转换成繁体: String result = SimpTradConv.trad("出来演落幕的一出戏"); System.out.println("the result:"+result); 转换的结果是:“出來演落幕的一齣戲”。 5.16 情感识别 可以结合核磁共振,通过对大脑中的兴奋区域成像来分析“喜欢”或“厌恶”等情感倾向。这里讨论的情感识别又叫做文本倾向性分析,英文叫做sentiment analysis或Opinion mining。基本的目标就是实现区分出正面、负面或者中性,这叫做极性分类(polarity classification)。可以按好恶程度分出更多的级别,例如,1~5星级,这叫做星级评分(multi-way scale)。 有文档级别的情感识别,例如对某个电影或酒店的评论自动分类出极性或者星级,这样区分出好评和差评 - - 自然语言处理 。也许想进一步对好在哪里,差在何处做更细致的分析。所以出现了更细粒度的基于特征的情感识别。例如区分出对手机的屏幕或者照相机的画质的评价。为了准确的识别级性,可以考虑对文本的主客观语句分类,提取出n个最主观的句子来概括整个评论的褒贬倾向。从技术上来说,就是从主客观混合文本语料中抽取表示主观性的文本。 为了实现基于特征的情感识别,需要从上下文提取出评价的对象。需要提取描述对象的特征,然后判断倾向性描述在每个特征上的极性。“特征”一词在这里既表示描述对象的组成也表示属性。 特征抽取是获得关于主题某一方面的具体描述,如汽车的油耗与操控性,数码相机的电池寿命。和信息抽取相比,情感分析中的特征抽取更加自由,因为获得的结果不要求是结构化的。在某些应用中,特征抽取比情感取向判断更加重要,因为需要关注用户的具体意见。例如对某款照相机的评价统计: 照相机: 褒义: 125 <独立的评价句子> 贬义: 7 <独立的评价句子> 特征:画质 褒义: 123 <独立的评价句子> 贬义: 6 <独立的评价句子> 特征: 大小 褒义: 82 <独立的评价句子> 贬义: 10 <独立的评价句子> 对事物的观点有直接观点和对比观点两种: l 直接观点(direct opinion):例如,这款相机的画质的确有点烂。 l 对比观点(comparative opinion):例如,这款相机的画质比Camera-x好。进行这类情感分析时,首先要确定观点的目标对象是谁。在这个例子中需要用到指代消解确定这款相机指哪款照相机。 有时候,作者把情感和事实一起来表达。如“3寸的液晶显示屏取景非常细致清晰”。情感和具体的特征是分不开的。 除了这些经典的问题外,在针对社会媒体的情感分析中,我们面临更多的挑战。例如,并非所有的与主题相关的用户为中心的内容都是重要的,只有其中少部分引起关注和讨论,甚至进而影响其他用户的观念和行为。因此,评估它们的影响力和预测它们是否得到关注具有重要的应用价值。 - - 自然语言处理 除此以外,不合理地利用社会媒体的影响力也值得我们关注。制造事端打击竞争对手,恶作剧心理造谣生事,收受商家好处为特定产品夸大宣传,是典型的误导公众行为。 首先从文本中抽取描述对象的特征。例如,针对汽车的用户体验信息,关于操控性、舒适性、油耗、内饰、配置等方面的评价等被分别抽取列出,因此可以收集到不同用户关于同一特征的描述并在不同品牌、不同时间段、不同用户群的范围内统计加以比较评估,这样的数据能直接地、准确地反映用户的消费情况和市场反应。再次,需要评估一个用户言论的内在价值和预测将来的关注度。从实务操作上来说,有些重要的言论和事件在几个小时内就会引起广泛的关注。相关的厂家可以及时发现和跟进这种对其产品销售和品牌形象具有重要影响的言论。 为了获取标注好的文本倾向,可以从评论网站,比如豆瓣网、或者卓越、携程(www.ctrip.com)等网站抓取所有的评论,这些评论用星级评价来代表褒贬度。 常见具有语义倾向词语的词性及示例如表5-8: 词性编码 词性 示例 a 形容词 美丽、丑陋 n 名词 英雄、熊市、粉丝、流氓 v 动词 发扬、贬低 d 副词 昂然、暗地 i 成语 宾至如归、叶公好龙 p 习惯语 双喜临门、顺竿爬 表5-8 有语义倾向的词语表 事实上,对一篇文章而言,它表达的情感的正面或负面是通过主观语句体现出来的,如“产品质量好!”。但是像“它的售价刚好是¥50元!”这样的客观语句,虽然有“好”这一特征词,但并不表达任何情感。但是如果能区分一篇文章中的主观语句和客观语句,只对主观语句进行特征选择,会对分类的准确率有很大提高。 观点搜索系统使得用户能够查找关于一个对象的评价观点。典型的观点搜索查询包括以下两种类型: a) 搜索关于一个特定对象或对象特征的观点。搜索用户只要简单给出对象和/或对象的特征即可; - - 自然语言处理 b) 搜索一个人或组织关于一个特定对象或者对象特征的观点。用户需要给出观点拥有者的名字和对象的名字。 判断用户的情感取向(polarity)是喜欢,不喜欢还是中性的。通过对大量用户的感情取向进行统计,我们可以了解用户对特定产品的好恶,甚至对具体的某个特征(如数码相机的镜头,电池寿命等)作出直接的判断和比较。 开源项目LingPipe(http://alias-i.com/lingpipe)包含了情感识别的实现。LingPipe从主客观混合文本语料中抽取表示主观性的文本。可以把电影评论分成正面或负面评价。LingPipe主要实现了两种分类问题: l 主观(情感)句和客观句识别。 l 正面(喜欢)或负面(不喜欢)评价。 近年,基于情感的文本分类逐渐被应用到更多的领域中。例如,微软公司开发的商业智能系统Pulse,它能够从大量的评论文本数据中,利用文本聚类技术提取出用户对产品细节的看法;产品信息反馈系统Opinion Observer,利用网络上丰富的顾客评论资源,对评论的主观内容进行分析处理,提取产品各个特征及消费者对其的评价,并给出一个可视化结果。 5.16.1 确定词语的褒贬倾向 在词汇的褒贬计算时,会遇到如下问题:如何发现以及判断潜在的褒贬新词。要不断地扩充我们的褒贬词库,这样才能够使得后续的判断尽可能的准确。通常一个小的褒贬词库在词汇的覆盖程度上并不尽如人意,但如果要穷尽所有的褒贬词汇也非易事,如何去发掘潜在的褒贬词汇,是我们亟待解决的难题;对于一些同义词,它们的褒贬性可能相反(如“宽恕”和“姑息”),我们可以根据现有的褒贬词库和同义词库,进行同义词拓展,确定这些极性相反、词义相同的词汇的褒贬。 以下方法不仅能够分析出词的褒贬性,还能够给出该词的褒贬强度。而且,对于同义词的褒贬的扩展也具有一些效果。 具体步骤如下: 首先,我们从网络以及现有的褒贬词典中,收集出一定数量的褒贬词汇(数量>=10000)作为种子词库。然后对该词库进行词频统计,分别计算出每个单字在褒贬词库中的频率,根据公式计算出每个单字的褒贬性,最终根据公式计算出每个词汇的褒贬性。 具体公式如下: - - 自然语言处理 其中,fpci代表字ci在褒义词库中的词频,fnci代表ci在贬义词库中的词频,Pci和Nci分别表示该字作为褒义词时的权重和贬义词时的权重。 由于褒贬词库在数量上并不一定一致。我们对上述公式修正如下: 其中,n和m分别代表褒贬词库中不同字符的个数。 上式代表字的褒贬倾向。 对于由p个字符构成的词语w,其褒贬倾向定义如下: - - 自然语言处理 下一个问题是,如何得到这个要判断的词语呢? 可以从搜索引擎搜索“褒义词”,然后把所有带引号的词都找出来。例如“保守”是个褒义词,因为搜索结果中有这个带引号的褒义词。再例如一个搜索结果条目:“雷”是时尚界褒义词。所以能自动发现“雷”这个褒义词。 先标注语料,然后通过半监督的学习方法来提取出一些固定的模式,以及扩展出新的评价词语和评价对象。 5.16.2 实现情感识别 识别句子的极性与星级评分的流程如下: 1.关键词匹配; 2.模板提取; 3.模板匹配; 4.计算极性与星级评分。 将词语分为5类: 1. 直接能表达出褒贬倾向的词汇。包括一些名词、形容词、副词和动词,如:精彩,荒诞。 2. 表示程度的副词,例如:很,非常。 3. 否定词,例如:不,没有。 4. 表示转折的连词,例如:但是,却。 5. 某些合成词,即按分词的结果拆开单独看不带情感,但是整体带有情感倾向的词组。例如:创世纪,分词系统将它分成两个词,这两个词分别出现并不带有褒贬倾向,而当同时出现时,则带有一定的褒义倾向。这样的词还有载入史册等。 设计标注格式为:用[a,c,d,n,v,p,i]表示词性。用[1,2,3,4,5]表示类别。用[+,-,#]表示极性(褒贬性)。用[1,2,3,4,5]表示程度。例如: 原始文本是:这部电影很精彩。 分词结果是:这/r 部/q 电影/n 很/d2 精彩/a1 。/w 标注结果是:这/r 部/q 电影/n 很/d2#2 精彩/a1+2 。/w - - 自然语言处理 其中,很/d2#2表示程度副词,本身不具有褒贬性,对于褒贬性的影响因子为2。而精彩/a1+2则表示形容词,具有褒义情感,情感程度为2。 匹配模板,得到关键词序列:很/d2#2 精彩/a1+2 在模板匹配成功之后,需要根据一定的规则计算出整句文本的褒贬倾向。这个规则的设定需要在一定程度上体现出语法规则,否则将很容易导致计算出的整个语句的情感倾向错误。例如,程度副词既可能出现在其中心词的左侧,也可能出现在其中心词的右侧(“很好”,“好得很”)。本系统文本褒贬倾向计算规则设定如下: 1) 根据模板从文本中取出所有模板成分对应的词,去掉不相关的词,组成一个序列。 2) 第一遍扫描序列,找到所有程度副词(类别为2),将其程度值乘到模板中离其最近的一个1 类词的程度值上(考虑到副词可能位于其中心词的前面或者后面,所以这里的“最近”是前后双向的查找,同时由于副词在前的情况比较多,所以前向查找的优先级高)。具体的处理是标注程度为3的因子为1.5,程度为2的因子为1,程度为1的因子为0.5。 3) 第二遍扫描序列,找到所有否定词(类别为3),将其往后碰到的第一个1类词的褒贬性取反。 4) 第三遍扫描序列,以转折词为单位将序列分成几个小部分,对每个小部分累加其1类词的褒贬倾向值,然后按转折词类型的不同乘以转折词相应的权值。让步型如“虽然”,对应部分要减弱;转折型如“但是”,对应部分要加强,最后各部分相加得到文本的褒贬倾向值。计算“这部电影很精彩”得到的褒贬倾向值为2,即最终判定为褒性评论。 5.16.3 用户协同过滤 有时候不是从文本中,而是直接根据其他用户的评价给一个用户推荐商品或图书等。可以用在购物网站的推荐系统。 很多购物网站都存在长尾效益,用户购买或评价的商品都是少数,而大多数商品的用户很少评价。所以存在数据稀疏的问题。SlopeOne算法可以用来解决这个问题。SlopeOne很简单,易于实现,执行效率高,同时推荐的准确性相对很高。 SlopeOne的基本概念很简单,例如表5-9:用户X,Y和A都对项目1打了分。同时用户X,Y还对项目2打了分。用户A对项目2可能会打多少分呢? 用户 对项目1的评分 对项目2的评分 X 5 3 Y 4 3 - - 自然语言处理 用户 对项目1的评分 对项目2的评分 A 4 ? 表5-9 评分表 根据SlopeOne算法,应该是:4 - ((5-3) + (4-3))/2 = 2.5 解释一下:用户X对项目1的评分是5,对项目2的评分是3,那么他可能认为项目2应该比项目1少两分。同时用户Y认为项目2应该比项目1少1分。据此我们知道所有对项目1和项目2都打了分的用户认为项目2会比项目1平均少1.5分。所以我们有理由推荐用户A可能会对项目2打(4-1.5)=2.5分。 找到对项目1和项目2都打过分的用户,算出评分差的平均值,这样我们就能推测出对项目1打过分的用户A对项目2的可能评分,并据此向A用户推荐新项目。 这里我们能看出SlopeOne算法的一个很大的优点,在只有很少的数据时候也能得到一个相对准确的推荐,这一点可以解决数据稀疏的问题。 基本的SlopeOne算法根据项目1的评价来估计项目2的评价,如果需要根据好几个项目的评价来估计某一个项目的评价,则可以使用加权算法(Weighted SlopeOne)。如果有100个用户对项目1和项目2都打过分,有1000个用户对项目3和项目2也打过分。显然这两个评分差的权重是不一样的。因此我们的计算方法是: (100*(Rating 1 to 2) + 1000(Rating 3 to 2)) / (100 + 1000) 对协同过滤问题的抽象,使用基于SlopeOne算法的推荐需要以下数据: 1. 有一组用户; 2. 有一组项目,例如图书、商品等; 3. 用户会对其中某些项目打分表达他们的喜好; 总结一下,SlopeOne算法要解决的问题是:对某个用户,已知道他对其中一些项目的评分,要向他推荐一些他还没有评分的项目,以增加销售机会。 一个推荐系统的实现包括以下三步: 1. 计算出任意两个项目之间评分的差值; 2. 输入某个用户的评分记录,推算出对其它项目的可能评分值; 3. 根据评分的值排序,给出最推荐的项目。 - - 自然语言处理 评分预测问题过度依赖评分数据,而很多网站记录的往往是用户的访问日志,比如视频网站中最多的数据是用户看了什么视频,而用户对视频打分的数据却非常少。 5.17 本章小结 本章介绍的自然语言处理技术用机器学习的方法处理文本信息,让搜索引擎体现了基本的智能。聚类算法除了已经介绍的DBScan和KMeans聚类方法,还有层次聚类、数据流聚类等算法。 知名的句法分析树有:Michael Collins实现的 句法分析包(http://www.cs.columbia.edu/~mcollins/code.html)以及Dan Bikel实现的句法分析包(http://www.cis.upenn.edu/~dbikel/#stat-parser) 基于上下文无关文法(Probabilistic Context-Free Grammars简称PCFG)的句法分析方法一直是该领域研究的主流,但PCFG 存在的一个重要的问题是语法缺少对词汇的敏感性。因此,对PCFG模型的改进可以分为以下两种:引入词汇化信息和扩展非终结符标记。即目前最常见的两大类句法分析模型:词汇化模型和非词汇化模型。词汇化模型中,词汇信息在训练语法规则模型时起主导作用,而非词汇化模型则利用非终结符的潜在信息。 文本分类的方法除了支持向量机的方法和基于规则的方法以外,还有隐含狄利克雷分配(LDA,Latent Dirichlet Allocation)等。 Hoad和Zobel提供了一份语义指纹的综述“Methods for Identifying Versioned and Plagiarised Documents”和一种用于近似重复检测的基于词的相似性度量方法。他们的评估主要集中在查询文件的不同版本和抄袭文件。Bernstein和Zobel在论文“Accurate discovery of co-derivative documents via duplicate text detection”中描述了一种全指纹来寻找来源相同的文件的技术。Bernstein和Zobel在论文“Redundant documents and search effectiveness”中研究了重复对检索效果的影响。他们展示了一个TREC数据集中的15%的相关文件是冗余的,冗余文档对搜索结果集有显著的影响。 Henzinger在论文“Finding near-duplicate web pages: a large-scale evaluation of algorithms”中描述了一种对于Web近似重复检测的大规模评估。这篇论文比较了shingling算法和Simhash算法这两种技术。Henzinger的研究使用了16亿个网页。他的结果表明,对于同一个站点上的冗余页面,这两个算法效果都不好,因为相同站点让网页看上去近似。当网页在不同站点的时候,Simhash算法的精度达到了50%,而Broder算法只有38%。 在Google出现以前,获取的知识往往是局域性的,想要全球性范围内获取知识被Google部分解决了。但是Google的机器翻译仍然不够完美 这导致了一个获取知识的瓶颈。 - - 创建索引库 第6章 Lucene原理与应用 Lucene源码分为核心包和外围功能包。核心包实现搜索功能,外围功能包实现高亮显示等辅助功能。Lucene源码的核心包中共包括7个子包,每个包完成特定的功能。 最基本的是索引管理包(org.apache.lucene.index)和检索管理包(org.apache.lucene.search)。索引管理包实现索引建立、删除等。检索管理包根据查询条件,检索得到结果。 索引管理包调用数据存储管理包(org.apache.lucene.store),主要包括一些底层的I/O操作。同时也会调用一些公用的算法类(org.apache.lucene.util)。文档结构包(org.apache.lucene.document)用于描述索引存储时的文档结构管理,类似于关系型数据库的表结构。 查询分析器包(org.apache.lucene.queryParser),实现查询语法,支持关键词间的运算,如与、或、非等。语言分析器(org.apache.lucene.analysis)主要用于对放入索引的文档和查询词切词,支持中文主要是扩展此类。最后是提供本地语言支持的包(org.apache.lucene.message)。 往Lucene中放的是文档,查询的是词,查询返回的也是文档。 6.1 Lucene深入介绍 为了方便索引大量的文档,Lucene中的一个索引分成若干个子索引,叫做段(segment)。段中包含了一些可搜索的文档。在给定的段中可以快速遍历任何给定索引词在所有文档中出现的频率和位置。IndexWriter收集在内存中的多个文档,然后在某个时间点把这些文档写入一个新的段,写入点可以通过Lucene内部的配置类或者外部程序控制。然后这些文档组成的段会保持不动,直到Lucene 把它合并进入大的段。MergePolicy 控制Lucene 如何合并段。 - - 创建索引库 Segment1 Segment2 Doc1 Doc2 Doc3 Doc1 Doc2 Doc3 . . . . . . 图6-1索引文件的逻辑视图 6.1.1 常用查询 最基本的词条查询使用TermQuery,用来查询不切分的字段。例如查询一个杂志: Term term = new Term("journal_id","1672-6251"); TermQuery query = new TermQuery(term); 组合条件查询使用布尔逻辑查询BooleanQuery。举一个布尔逻辑查询的例子:同时查询标题列和内容列。使用BooleanClause.Occur.SHOULD的查询效果如图6-1所示。 标题列包含查询词 内容列包含查询词 图6-1 BooleanClause.Occur.SHOULD效果 QueryParser parser = new QueryParser(Version.LUCENE_CURRENT, "body", analyzer); Query bodyQuery = parser.parse("NBA");//查询内容列 parser = new QueryParser(Version.LUCENE_CURRENT, "title", analyzer); Query titleQuery = parser.parse("NBA");//查询标题列 - - 创建索引库 BooleanQuery bodyOrTitleQuery = new BooleanQuery(); //用OR条件合并两个查询 bodyOrTitleQuery.add(bodyQuery, BooleanClause.Occur.SHOULD); bodyOrTitleQuery.add(titleQuery, BooleanClause.Occur.SHOULD); //返回前10条结果 ScoreDoc[] hits = isearcher.search(bodyOrTitleQuery, 10).scoreDocs; 关于字符串的前缀匹配问题:只查询产品名字为字母A打头的搜索,可以使用前缀匹配(PrefixQuery)实现。查询语法是A*。注意,查询的不一定是整个字段以A开头的纪录,而只是其中的单词以A开头。另外Lucene的NumericRangeQuery采用了Trie树结构的索引,可以模仿NumericRangeQuery写个字符串的前缀匹配实现。 有些英文单词需要查询扩展。例如搜索“dog”的同时查找“dogs”。AutomatonQuery有限状态查询(Finite-State Query)支持“dogs~”这样的模糊查询语法。AutomatonQuery还可以用于拼写检查。AutomatonQuery内部使用有限状态机实现,所以性能很好。 Lucene中一些常用的查询对象参见表6-1。 Query 说明 用法 PhraseQuery 短语匹配查询 要求精确匹配的查询 SpanQuery 匹配位置相关的查询 (跨度查询) 字词混合查询 FieldScoreQuery 函数查询(通过数字型的字段影响排序结果) 时间加权排序 表6-1 Lucene中的查询对象表 6.1.2 查询语法与解析 l 一个短句查询可以用双引号括起来,这样只有精确匹配该短语的文档才会匹配查询条件出来。比如:搜索"上海世博会"将会只出现包含连续出现"上海"和"世博会"的文档。如果"上海"和"世博会"之间有其他词,则不会匹配这样的情况。所以搜索"上海世博会"比上海 AND 世博会这样的查询返回的结果更少。 l 使用^表示加权。例如搜索solr^4 lucene。 l 修饰符 + - NOT 。 例如搜索+solr lucene。 - - 创建索引库 l 布尔操作符 OR AND。例如搜索(solr OR lucene) AND user。 l 按域查询。一个字段名后面跟冒号,再加上要搜索的词语或短句,就可以把搜索条件限制在该字段。例如搜索title:NBA,匹配标题包含NBA的文档。 QueryParser将输入查询字串解析为Lucene的Query对象,如图6-2所示。QueryParser是使用JavaCC生成的词法解析器。JavaCC(Java Compiler Compiler)是一个类似YACC的工具软件。JavaCC可以用于词法分析和语法分析。JavaCC根据输入文件中定义的语法生成实际用于词法分析和语法分析的程序源代码。在Lucene中,QueryParser.jj中定义了查询语法。 查询对象 QueryParser 查询表达式 Analyzer 文本片断 IndexSearcher 图6-2使用查询表达式搜索 词法解析器是如何工作的呢?首先把用户输入定义的Token转换成为正规文法等价的形式。然后把正规文法转换成NFA。再把NFA转换成DFA。最后生成代码模拟DFA。 要改变查询分析器的某一部分,例如查询实例化,可以通过继承解析器类来实现,改变实际的查询语法需要深入的了解JavaCC解析器生成器。为了实现更好的查询分析功能,可以分离一个查询的语法和语义。例如:'a AND b'与'+a +b'和'AND(a,b)' 是同一个查询的不同的语法。区分不同查询组件的语义,例如,是否符号化/原型化/正规化不同的词,并且如何做到这些。或者对于词用哪个查询对象来创建。需要能够尽可能快的用新的语法写一个解析器,重用底层的语义。 如果把查询看成是一个简单的语言,则可以用巴科斯范式(BNF)用来定义查询语言的文法。 Query ::= ( Clause )* Clause ::= ["+", "-"] [ ":"] ( | "(" Query ")") 这个文法中的每个规则都是一个产生式。每个产生式由左边的一个符号和右边的多个符号组成,用右边的多个符号来描述左边的一个符号。符号有终结符和非终结符两种。这里的终结符是"+"和"-"等,非终结符是Clause和Query。例如,对于查询表达式:“site:lietu.com”,可以表示成":"的形式,最终归约成一个有效的Query。 JavaCC把非终结符对应成Java中的方法。例如非终结符Clause对应的Clause方法。JavaCC的.jj文件文法和标准的巴科斯范式区别是:在JavaCC中,可以在每步推导的过程中执行一些相关的Java语句,在编译原理中,把这些语句叫做动作。 - - 创建索引库 例如StandardSyntaxParser.jj 中对Clause的定义: QueryNode Clause(CharSequence field) : { QueryNode q; //定义局域变量 Token fieldToken=null, boost=null; boolean group = false; } { [ LOOKAHEAD(2) //向前看两个Token ( fieldToken= {field=EscapeQuerySyntaxImpl.discardEscapeChar(fieldToken.image);} //修改列名 ) ] ( q=Term(field) | q=Query(field) ( boost=)? {group=true;} //如果碰到 ^ 字符则对查询加权 ) { if (boost != null) { float f = Float.valueOf(boost.image).floatValue(); q = new BoostQueryNode(q, f); } if (group) { q = new GroupQueryNode(q);} return q; //返回生成的QueryNode } } STATIC是一个布尔选项,缺省值是真。如果是真,在生成出的解析器和token管理器中,所有的方法和类变量都声明成静态的。这样仅仅允许一个解析对象存在,但是查询分析器应该有很多个,所以这个值应该设成假。 JavaCC根据StandardSyntaxParser.jj转换出源代码: Token.java StandardSyntaxParserConstants.java - - 创建索引库 StandardSyntaxParser.java ... StandardSyntaxParser.jj中定义的parse方法定义了对用户查询串的词法分析功能,并完成初步的语法分析。 public QueryNode parse(CharSequence query, CharSequence field) QueryNode对象包含了分析出来的语法树。 查询解析器有三层,而它的核心是QueryNodeTree。是一棵最初表示原始查询的语法的树。例如,'a AND b'的QueryNodeTree是: AND a b 图6-3 QueryNodeTree 查询解析器的三层具体是: 1. QueryParser:最上层是解析层,简单的把查询字符串转换成一个QueryNodeTree。当前使用javacc 实现这一层。 2. QueryNodeProcessor:查询节点处理器做了大部分工作。它实际上是一个可配置的处理器链。每一个处理器可以遍历树和修改节点甚至修改树的结构。这样就有可能在查询执行以前做查询优化或者把字符串分隔成词。 3. QueryBuilder:第三层也是一个构造器的可配置的链,把QueryNodeTree转换成Lucene的Query对象。 新的QueryParser主要实现代码在contrib路径中的queryparser子路径下。 l org.apache.lucene.queryParser.core:包含query parser API 类,需要通过query parser实现扩展。 l org.apache.lucene.queryParser.standard:包含使用新的query parser API的query parser实现。 使用StandardQueryParser的代码: - - 创建索引库 StandardQueryParser qpHelper = new StandardQueryParser(); StandardQueryConfigHandler config = qpHelper.getQueryConfigHandler(); config.setAllowLeadingWildcard(true); config.setAnalyzer(new WhitespaceAnalyzer()); Query query = qpHelper.parse("apache AND lucene", "defaultField"); 标准的QueryParser中的实现代码展示了对这3个阶段的调用。 QueryNode queryTree = this.syntaxParser.parse(query, getField()); queryTree = this.processorPipeline.process(queryTree); return (Query) this.builder.build(queryTree); 对“+DisNey WOrld”的解析过程如图6-4所示。 +DisNey WOrld 文本解析 BooleanQuery ModifierQN REQ FieldQN (content, WOrld) FieldQN (content, DisNey) 缺省列: content 图6-4 解析查询 需要让QueryParser更好的支持中文,例如查询语法支持全角空格,可以把全角空格当半角空格用。 6.1.3 查询原理 用户往往只看搜索结果中和查询词最相关的前面几条。所以可以在IndexSearcher.search方法中指定返回多少条结果,并把查询结果封装成一个TopDocs对象,但是这样就没办法通过hits.length()知道总共有多少条和关键词相关的结果。因此要通过TopDocs.totalHits属性知道一共找到了多少条结果,这样方便翻页处理。可以把搜索结果看成是一个优先队列,相关度分值高的文档排在前面。 - - 创建索引库 查询两个词“NBA AND 视频”,则对“NBA”这个词对应的文档编号列表和“视频”这个词对应的文档编号列表做交集(Intersection)运算后返回。例如在倒排索引表中检索出包含“NBA”一词的文档列表为docList("NBA")=(1,5,9,12),表示这4个文档编号的文档含有“NBA”这个词汇。包含“视频”的文档列表为docList("视频")= (5,7,9,11),这样同时包含“NBA”和“视频”这两个关键词的文档为docList("NBA")∩docList("视频")=(5,9)。这里的“∩”表示文档列表集合求交运算。 多文档列表求交计算的示例代码如下: //从数组a 和 b开始 sort(a); // 对数组a排序 sort(b); //对数组b排序 int aindex = 0; int bindex = 0; while (aindex < a.length && bindex < b.length) { if (a[aindex] == b[bindex]) { System.out.println(a[aindex] + "在两个数组中都出现"); aindex++; bindex++; } else if (a[index] < b[bindex]) { aindex++; } else { bindex++; } } Lucene中的ConjunctionScorer类包含了类似的实现。 6.1.4 遍历索引库 IndexReader提供了遍历索引库的接口。在遍历索引库之前可以先看下索引库中文档的数量。 IndexReader reader = searcher.getIndexReader(); //取得IndexReader对象 System.out.println(reader.maxDoc()); //打印文档数量 遍历索引库相关的几个类是: - - 创建索引库 l TermEnum用来枚举一个给定的域中的所有的项,而不管这个项在哪个文档中。 l TermDocs和TermEnum不同,TermDocs用来识别哪个文档包含指定的项,并且它也会给出该项在文档中的词频。 l TermFreqVector(即Term Frequency Vector或者简称Term Vector)是一个数据结构。包含一个指定文档的项和词频信息,并且当在索引期间存储项向量的时候,才能通过IndexReader检索出TermFreqVector。 例如,源文本是: 词位置 0 1 2 3 4 5 6 7 8 词 the quick brown fox jumps over the lazy dog 词排序后的位置是: 词索引 0 1 2 3 4 5 6 7 词 brown dog fox jump lazy over quick the 用下面的代码发现"the"出现的位置: int index = termPositionVector.indexOf("the"); // 7 int positions = termPositionVector.getTermPositions(index); // {0, 6} 这里使用TermEnum按词遍历索引库,代码如下: public static void getHighFreqTerms(IndexReader reader) throws IOException { TermEnum terms = reader.terms(); // 读取索引文件里所有的Term while (terms.next()) {// 取出一个Term对象出来 String field = terms.term().field(); //列名 String text = terms.term().text(); //词 System.out.println(text+":"+field); } } 经常需要统计索引库中哪些词出现的频率最高。例如,需要统计旅游活动索引库中的热门目的地。可以先对目的地列做索引,索引列不分词,然后取得该列中最常出现的几个词也就是热门目的地。实现方法是:可以用TermEnum遍历索引库中所有的词,取出每个词的文档频率,然后使用优先队列找出频率最高的几个词。 public class TermInfo { public Term term; //索引库中的词 - - 创建索引库 public int docFreq; //文档频率,也就是这个词在多少文档中出现过 public TermInfo(Term t, int df) { this.term = t; this.docFreq = df; } } public static TermInfo[] getHighFreqTerms(IndexReader reader, int numTerms, String field){ //实例化一个TermInfo的队列 TermInfoQueue tiq = new TermInfoQueue(numTerms); TermEnum terms = reader.terms(); //读取索引文件里所有的Term int minFreq = 0; //队列最后一个Term的频率即当前最小频率值 while (terms.next()) {//取出一个Term对象出来 String field = terms.term().field(); if (fields != null && fields.length > 0) { boolean skip = true; //跳过标识 for (int i = 0; i < fields.length; i++) { //当前Field属于fields数组中的某一个则处理对应的Term if (field.equals(fields[i])) { skip = false; break; } } if (skip) continue; } //当前term的内容是过滤词,则直接跳过 if (junkWords != null && junkWords.get(terms.term().text()) != null) continue; //获取最高频率的term。基本方法是: //队列底层是最大频率Term,顶层是最小频率Term, //当插入一个元素后超出初始化队列大小则取出最上面的那个元素, //重新设置最小频率值minFreq if (terms.docFreq() > minFreq) {//当前Term的频率大于最小频率则插入队列 tiq.insertWithOverflow(new TermInfo(terms.term(), terms.docFreq())); if (tiq.size() >= numTerms) { //当队列中的元素个数大于numTerms tiq.pop(); // 取出最小频率的元素即最上面的一个元素 - - 创建索引库 minFreq = ((TermInfo)tiq.top()).docFreq; //重新设置最小频率 } } } //取出队列元素,最终存放在数组中元素的词频率按从大到小排列 TermInfo[] res = new TermInfo[tiq.size()]; for (int i = 0; i < res.length; i++) { res[res.length - i - 1] = (TermInfo)tiq.pop(); } return res; } 6.1.5 索引数值列 一个索引库类似一个数据库的表结构,但是在Lucene2.9以前的版本中只能存储字符串,如果是日期或者数字,需要专门的方法转换成字符串后再索引。新的版本可以直接存储数字: document.add(new NumericField(name).setIntValue(value)); 高级搜索中可能会用到范围查询,例如查询价格区间范围。 Lucene2.9以前版本实现的区间查询性能上有问题。RangeQuery采用扩展成TermQuery来实现,如果查询区间范围太大,RangeQuery会导致TooManyClausesException。为了避免产生这个异常,ConstantScoreRangeQuery 没有采用扩展成布尔查询的方式实现,而是采用Filter来实现,但是当索引很大的时候,查询速度会变慢。 在Lucene2.9以后的版本中,用Trie结构索引日期和数字等类型。例如:把521 这个整数索引成为:百位是5、十位是52、个位是521。这样重复索引的好处是可以用最低的精度搜索匹配区域的中心地带,用较高的精度匹配边界。这样减少了要搜索的Term数量。例如:TrieRange:[423 TO 642]分解为5个子条件来执行: handreds:5 OR tens:[43 TO 49] OR ones:[423 TO 429] OR tens:[60 TO 63] OR ones:[640 TO 642] TrieRange的工作原理如图6-5。 - - 创建索引库 Range 4 42 44 421 423 445 521 448 446 522 63 52 6 5 64 632 633 644 642 641 图 6-5 TrieRange工作原理 可以用NumericField来实现Trie结构索引数字。这样做的好处是更有效的按区间查找数字和排序。下面是增加一个整数列到索引的例子: document.add(new NumericField(name).setIntValue(value)); 为了优化索引性能,可以重用NumericField和Document实例: NumericField field = new NumericField(name); Document document = new Document(); document.add(field); for(all documents) { ... field.setIntValue(value) writer.addDocument(document); ... } 然后可以使用NumericRangeQuery来查询这样的数字列。例如: Query q = NumericRangeQuery.newFloatRange("weight", //列名称 new Float(0.3f), //最小值从它开始 new Float(0.10f),//最大值到它结束 true, //是否包含最小值 true); //是否包含最大值 如果要索引日期列,如果是精确到天的搜索。只需要把日期类型的值用DateTools转换成 - - 创建索引库 "yyyyMMdd"格式的字符串,然后再转换成整数来索引就可以了。比如: DateTime d=DateTime.Now; int n= Integer.valueOf(d.ToString("yyyyMMdd")); doc.Add(new NumericField("num", Field.Store.YES, true).SetIntValue(if)); 如果精确到秒,需要先用Date.getTime()把日期转换成long 类型。索引过程如下: NumericField documentDateTimeField = new NumericField("Pub_Date", 1,Field.Store.NO, true); Document document = new Document(); document.add(documentDateTimeField); while(hasDoc) { documentDateTimeField.setLongValue(scoreDetails.getDocumentDate().getTime()); writer.addDocument(document); } 查询过程如下: Long begin = esq.getBeginDate().getTime(); //开始时间 Long end = esq.getEndDate().getTime(); //结束时间 NumericRangeQuery rangeQuery = NumericRangeQuery.newLongRange("Pub_Date", 1, begin, end, esq.isBeginDateInclusive(), esq.isEndDateInclusive()); 如果精确到天,可以用DateTools把日期转换成int类型。搜索过程如下: int int_Date_Begin = Integer.valueOf(DateTools.dateToString(date1, Resolution.DAY)); int int_Date_End = Integer.valueOf(DateTools.dateToString(date2, Resolution.DAY)); SortField sortField = new SortField("Pub_Date",SortField.INT,false); Sort sort = new Sort(sortField); //检索 NumericRangeQuery rangeQuery = NumericRangeQuery.newIntRange("Pub_Date", int_Date_Begin,int_Date_End, true, true); ScoreDoc[] hits = searcher.search(rangeQuery, null, 1000, sort).scoreDocs; 可能需要索引数值列并按数值排序。 - - 创建索引库 NumericField priceField = new NumericField(“price"); Document document = new Document(); document.add(priceField); for(all documents) { ... priceField.setIntValue(value); writer.addDocument(document); ... } 按价格升序排列: Sort sort= new Sort(new SortField("price",SortField.INT,false)); ScoreDoc[] hits = searcher.search(query,null,1000,sort).scoreDocs; 6.1.6 检索结果排序 检索出来的文档一般按相关度排序后返回。虽然搜索结果中可能有很多文档,但是大部分的用户使用搜索引擎查询时只关注搜索结果的首页,所以实际上需要返回最相关的前n项结果即可。这个计算过程叫做“top-n查询”。 因此,IndexSearcher.search方法设计成为:TopDocs search(Query query, int n) Lucene用优先队列(PriorityQueue)记录前n个评分最高的文档。通过遍历词计算前n个评分最高的文档的伪代码如下: inverted_search(query){ double[] scores = new double[N]; // 文档对应的评分初始化为0 PriorityQueue queue = new PriorityQueue(); //按评分排列的优先队列 for(t∈query) { //遍历用户查询中的词 ps = postings(t) //得到词 t 的 posting stream while (p = nextposting(ps) ) {//遍历 postings,也就是所有包含词 t的文档 int id = p.id;//得到文档编号 double weight = p.weight; scores[id] += qt * weight; if(queue.length()>n) queue.pop(); queue.insert(id,scores[id]); } } - - 创建索引库 return queue; } 还可以采用并发合并(Parallel Merge)进一步加快计算前n个最相关的文档。 6.1.7 处理价格 Java内部有个表示价格相关的类:java.util.Currency。 Number number = NumberFormat.getCurrencyInstance(Locale.US).parse("$123.45"); 对于国际化的搜索网站,价格由值和货币类型组成。货币类型可能是美元或者英镑等。索引中存储两列。 价格一般是精确到小数点后两位。 public static String CurrencyToString(System.Double d){ long l = (long)(d * 100); return LongToString(l); } public static Double StringToCurrency(System.String str){ long l = StringToLong(str); System.Double d = (double)l / 100; return d; } 查询单个价格,例如"price:4.00USD"。或者查找价格区间,例如"price:[$5.00 TO $10.00]"。 6.2 Lucene中的压缩算法 倒排索引的大小可能和文档内容本身差不多大。当文档数量多时,倒排索引也会很大。为了节省存储空间,可以采用压缩格式存储倒排索引。从一个硬盘读入索引数据时,采用压缩存储后,能降低磁头需要移动的距离,从而提高性能。另外,为了在搜索系统内部快速的传输文档编号数组,可以压缩文档编号。 因为信息存在冗余,所以可以压缩。例如在生活中登记信息时,如果发现当前这行的信息和上面一行的信息相同时,为了少写字,可以采用压缩写法:同上。这样的压缩原理叫做预测编码(Predictive Encoding),可以对前后相似的内容压缩。 压缩算法存在两个过程:编码过程和解码过程。编码过程也就是压缩过程,而解码过程 - - 创建索引库 也就是解压缩过程。编码过程可以时间稍长,而解码过程则需要速度快。这有点类似ADSL优化上网速度的机制:用户往往下载文件的时候多,而上传文件的时候少。所以ADSL设计成下载速度快,而上传速度慢。因为在索引数据阶段执行编码过程,而在搜索阶段执行解码过程。索引数据的速度可以稍慢,但是搜索速度不能慢。因为索引一般只需要在后台完成一次,而搜索则需要经常调用。 6.2.1 变长压缩 全文索引中的文档编号和词频都是正整数,所以Lucene的内部实现需要考虑压缩存储正整数的问题。压缩的原理是出现次数较多的数用较短的编码表示。这样一来,变短的数相对于变长的数更多,文件的总长度就会减少。Lucene中的文档编号是一个正整数。在倒排索引中,小的数字出现概率大,如图6-2所示,说明较小的数值用较短的编码可以取得不错的压缩效果。 出现概率 1 2 3 4 5 6 7 8 9 10 11 图6-5 倒排索引中数值的出现频率 Lucene采用了变长压缩方法(Variable byte encoding)。变长压缩算法的原理是:较小的数使用较短的编码,较大的数使用较长的编码。和压缩相关的类有用于压缩单个整数的Vint类和压缩排好序的整数数组的SortedVIntList类。为了实现更好的压缩,在有些地方可以使用PForDelta来代替VInt。 VInt是一个变长的正整数表示格式,是一种整数的压缩格式表示方法。每字节分成两部分,最高位和剩下的低7位。最高位表明是否有更多的字节在后面,如果是0表示这个字节是尾字节,1表示还有后续字节。低7位表示数值。按如下的规则编码正整数x: l if (x < 128),则使用一个字节(最高位置0,低7位表示数值); l if (x< 128*128),则使用2个字节(第一个字节最高位置1,低7位表示低位数值,第二个字节最高位置0 ,低7位表示高位数值); l if (x< 128^3),则使用3个字节,依次类推。 每字节的低七位表明整数的值,可以把VInt看成是128进制的表示方法,低位优先,也就是说随着数值的增大,向后面的字节进位,从VInt值表示示例表6-2可以看出。 - - 创建索引库 表6-2 VInt编码示例 值 二进制编码 16进制编码 0 00000000 00 1 00000001 01 2 00000010 02 127 01111111 7F 128 10000000 00000001 00 01 129 10000001 00000001 01 01 130 10000010 00000001 02 01 16383 11111111 01111111 7F 7F 16384 10000000 10000000 00000001 00 00 01 16385 10000001 10000000 00000001 01 00 01 从0到127的值可以存储在一个字节中,从128 到128*128 = 16383的值可以存储在2个字节中。这里16,383 = 127*128+127,二进制表示方式是11111111 01111111。可以认为Vint能表示的整数值范围没有上限。因为小的数值更经常出现,相对于int的4个字节的表示方法而言,VInt表示方法对小的数值使用的字节数更少。所以VInt提供了正整数的压缩表示,而且解码效率高。 org.apache.lucene.store.IndexOutput.writeVInt(int i)方法包含了正整数编码成VInt的实现: while ((i & ~0x7F) != 0) { writeByte((byte)((i & 0x7f) | 0x80)); //先写入低位字节 i >>>= 7; //右移7位 } writeByte((byte)i); //写入高位字节 org.apache.lucene.store.IndexInput.readVInt()方法包含了VInt解码方法的实现: byte b = readByte(); //读入一个字节 int i = b & 0x7F; //取低7位的值 //每个高位的字节多乘个2的7次方,也就是128 for (int shift = 7; (b & 0x80) != 0; shift += 7) { b = readByte(); i |= (b & 0x7F) << shift; //当前字节表示的位乘2的shift次方 - - 创建索引库 } return i;//返回最终结果i 6.2.2 PForDelta VInt对每个整数都选择不同的位数来编码,因为有判断分支,导致解码速度慢。和VInt按单个整数的压缩算法不同,PForDelta压缩算法按批压缩整数。每批可以选取连续的128个整数。把128个整数中的90%较小的数用统一长度的短编码表示。把剩下的10%的整数看成是异常的大数,异常数用普通的4个字节的int型来表示。一批数的空间有128个b位槽空间。使用没有用到的b位槽空间构造链接表,一个异常数的b位槽空间存储碰到下一个异常数的偏移量。异常数的原始值追加存储在后面。VInt算法每个整数的编码长度都不一定相同,而PForDelta压缩算法对于一批整数只有两个编码长度,分别是b和4。 例如,可以做直方图来统计用多少位就可以表示这批数据中的90%,如果统计出来是b位。也就是说,有90%的数小于2的b次方。 假设在128个整数中,其中90%的数是小于32的,另外10%是大于32的,那么就把这128个整数以32为界,把小于32的作为一种处理,大于32的作为异常情况进行处理。32是2的5次方,因此 b=5。整个压缩空间由两部分组成:分配128 * 5 位给90%的正常数的空间,加上分配给剩下的10%的异常数的空间。例如:b=5 整数序列是:23, 41, 8, 12, 30, 68, 18, 45, 21, 9, ..压缩存储成如图6-3所示的形式。 1 23 3 8 45 68 12 30 411 1 18 2 21 9 … 第一个异常数的存储位置 128个5位数的空间 异常数的空间 每个数4字节,从后往前存 图6-3 PForDelta压缩空间示例 为了处理方便,如果有连续32个相邻的数是小于32的,那么把第32个数强制作为异常情况处理。 开源项目Kamikaze(http://sna-projects.com/kamikaze/)中提供了PforDelta的Lucene实现版本。Kamikaze来源于LinkedIn网站,是一个java工具包,封装了文档id号集合的实现,它实现了排好序的整数段的PForDelta压缩算法。Kamikaze也为Lucene提供了倒排索引列表的压缩。PForDelta压缩算法实现在com.kamikaze.docidset.compression.P4DsetNoBase的compressAlt方法: public long[] compressAlt(int[] input){ //缺省情况下,b=32, 编码长度是32 int BATCH_MAX = 1 << (_b - 1); - - 创建索引库 //分配数据空间大小,这里 BASE_MASK=32 long[] compressedSet = new long[((((_batchSize) * _b + HEADER_MASK + _exceptionCount * (BASE_MASK)))>>>6)+1]; //把b 加入数据空间 copyBits(compressedSet, _b, 0, BYTE_MASK); // Offset是下一个存储值的位置偏移量,在这里HEADER_MASK的值是8 int offset = HEADER_MASK; int exceptionOffset = _exceptionOffset; int exceptionIndex = 0; //遍历需要压缩的数组 for (int i = 0; i < _batchSize; i++) { // 如果是正常数则放在前面的空间,否则拷贝到压缩空间最后的位置 if (input[i] < BATCH_MAX) { copyBits(compressedSet, input[i] << 1, offset, _b); } else { // 记录异常点的编号到b位槽空间,并且增加一个位标志 copyBits(compressedSet, ((exceptionIndex << 1) | 0x1), offset, _b); // 把异常数值放到数据空间的最后的位置 copyBits(compressedSet, input[i], exceptionOffset, BASE_MASK); // 增加数据空间最后位置的长度 exceptionOffset += BASE_MASK; exceptionIndex++; } offset += _b; } return compressedSet; } PforDelta的解码过程: public int[] decompress(long[] compressedSet) { int[] op = new int[_batchSize]; - - 创建索引库 // reuse o/p op[0] = _base; //异常列表的偏移量 int exceptionOffset = HEADER_MASK + _b * _batchSize; //扩展和修补数据 for (int i = 1; i < _batchSize; i++) { int val = getBitSlice(compressedSet, i * _b + HEADER_MASK, _b); if ((val & 0x1) != 0) { //碰到一个异常 op[i] = getBitSlice(compressedSet, exceptionOffset, BASE_MASK); exceptionOffset += BASE_MASK; } else { op[i] = val >>> 1; } op[i] += op[i - 1]; } return op; } PForDelta的算法在实际的使用中,虽然压缩速度稍慢,但是有非常好的解压速度。因为压缩过程在索引阶段完成,解压过程在搜索阶段完成,所以在全文索引库实现中流行采用PForDelta压缩算法。解压速率快的原因是它符合现代计算机的流水线工艺——即解压过程中没有判断语句,不会打断CPU的流水线;同时每次128个数据可以缓存在CPU内。对比与VInt算法,由于VInt中的128是一个较小的数,无论怎样安排判断语句的顺序,都会造成CPU流水线的预测失误,从而造成较大的性能损失。 6.2.3 VSEncoding 前面已经总结过很多压缩算法了,总的来说分两类,一类是单个元素压缩,这个主要是考虑每个值出现的频率,很常用的一类编码是无前缀编码,简单来说,就是N个值的编码,都不会是另一个值的编码的前缀,这样确保解码的时候无二义性。根本不同的分布,有不同的算法。HUFFMAN则根据统计信息,算出全局最优的。 另一类是一段的压缩,这一类主要是认为相邻的元素会比较相似。根据不同的情况,也会有不同的算法。那是否会有一个类似HUFFMAN一样的算法,根据统计算法最优的编码。 VSEncoding就是这么一类算法,当然它不一定是最优的。 - - 创建索引库 这个算法主要是把长度为N的拉链分成M部分,每部分有三个部分,一是B,表示这部分的数字都用B个字节表示,一个是L,表示这部分有多长,第三部分就是具体的数字了,共B*L个BIT。显然分段不能太长,这样B可能会很大,因为B要取整段里最大的数字的位数;分段也不能太短,这样B跟L的开销会比较大。B与K的编码方式待定。 然后用动态规划去求解最优的分割。为了使得问题无后效性,B与K的编码方式必须是基于单个元素的压缩,而且不能是HUFFMAN这种需要统计全局信息的。 如果续写一个序列,三部分的开销都可以明确算出来,这个问题用动态规划就比较好解了,也就是复杂度高低的问题。 这个方法的优劣从理论上不好评价,明显可以看到的是,如果很多很相近的大数字,这种算法是不如pfordelta的。但是这种思路很好,pfrodelta之类分段压缩的算法,也可以改一下用动态规划的方法确定分段的方法,而不是预定义的方式。但是这样会带来压缩开销的大幅增加,不清楚是否可以接受。 任何整数列如下:考虑L L = 1,3,2,3,5,9,1,3,2 任意分割成一个整数列,每套代表固定的连续化位设置的最低能力。 例如,在L划分如下。 L'= [1,3,2] [3,5,9],[1,3,2] 最左边的一对2位,4位中的一对,3bit可设置在所有整数的集合权分别代表,欧莱雅(2位* 3 + 4位* 3 + 3位 * 3)的总27bit可序列化。如果你改变了分裂,但这种变化的总规模。欧莱雅'for例如:如果总的大小是24位。 ※事实上,记录对如何分割的其他信息 L '' = [1],[3,2,3],[5,9],[1,3,2] 这种方法的DP分裂要优化VSEncoder功能。相同的固定长度的位放置一个用于CPU,它也解码效率优化的连续区域的整数列硬化速度非常快。 6.2.4 前缀压缩 对于全文索引中的索引词也进行了压缩存储。索引词压缩算法采用了前缀压缩(Front Encoding)。因为索引词是排序后写入索引的,所以前后两个索引词词形差别往往不大。前缀压缩算法省略存储相邻两个单词的共同前缀。每个词的存储格式是: - - 创建索引库 <相同前缀的字符长度,不同的字符长度,不同的字符> 例如,顺序存储如下三个词:term、termagancy、termagant。 不用压缩算法的存储方式是:<4,term> <10,termagancy> <9,termagant> 如果应用前缀压缩算法,实际存储的内容如下: <4,term> <4,6, agancy> <8,1,t> 在TermInfosWriter类中实现的前缀压缩代码如下: int start = 0; final int limit = termBytesLength < lastTermBytesLength ? termBytesLength : lastTermBytesLength; while(start < limit) { if (termBytes[start] != lastTermBytes[start]) break; start++; } final int length = termBytesLength - start; output.writeVInt(start); //写入相同前缀的长度 output.writeVInt(length); //写入差量长度 output.writeBytes(termBytes, start, length); //写入差量字节 if (lastTermBytes.length < termBytesLength) { byte[] newArray = new byte[(int) (termBytesLength*1.5)]; System.arraycopy(lastTermBytes, 0, newArray, 0, start); lastTermBytes = newArray; } System.arraycopy(termBytes, start, lastTermBytes, start, length); lastTermBytesLength = termBytesLength; 在TermVectorsReader类中实现的解压缩代码如下: start = tvf.readVInt(); //读入相同前缀的长度 deltaLength = tvf.readVInt();//读入差量长度 totalLength = start + deltaLength; final String term; //要解压缩的索引词 - - 创建索引库 if (byteBuffer.length < totalLength) { byte[] newByteBuffer = new byte[(int) (1.5*totalLength)]; System.arraycopy(byteBuffer, 0, newByteBuffer, 0, start); byteBuffer = newByteBuffer; } tvf.readBytes(byteBuffer, start, deltaLength); term = new String(byteBuffer, 0, totalLength, "UTF-8"); 此外,字符串压缩算法还有BurrowsWheeler转换等。 6.2.5 差分编码 变长压缩算法对于较小的数字有较好的压缩比。差分编码(Differential Encoding)可以把数组中较大的数值用较小的数来表示,所以可以和变长压缩算法联合使用来实现压缩。差分编码中存储的是数组序列中前后两个数之间的差异。差分编码压缩过程示例如下: => 差分编码解压缩的过程示例如下: => 例如,对于排好序的DocId序列: 编码前是:345, 777, 11437, … 编码后是:345, 432, 10660, … 在压缩过程中,先取出一个已经从小到大排好的数组的第一个数字,通过longToBytes方法把此数值转换成一个byte数组,用BufferedOutputStream实例把它写入文件,然后通过一个循环,用一个变量记录数组中后一个数减前一个数的差,如第二个数等于数组的第二个数减上数组的第一个数,再通过writeVLong方法将这个变量写入文件中。 在解压缩过程中,先用DataInputStream实例的readLong方法读出文件的第一个数字写入数组,再通过readVLong方法将把该文件的剩下的数字读取出来,同时把此数组剩下的数字都变成后一个数和前一个数字的和,还原成原来的数据。 //把一个long型的数据变成二进制 public static byte[] longToBytes(long n) { byte[] buf=new byte[8];//新建一个byte数组 - - 创建索引库 for(int i=buf.length-1;i>=0;i--){ buf[i]=(byte)(n&0x00000000000000ff);//取低8位的值 n>>>=8;//右移8位 } return buf; } //把一个long型的数据进行压缩 public static void writeVLong(long i,BufferedOutputStream dos) throws IOException{ while ((i & ~0x7F) != 0) { dos.write((byte)((i & 0x7f) | 0x80)); //写入低位字节 i >>>= 7; //右移7位 } dos.write((byte)i); } //把一个压缩后的long型的数据读取出来 private static long readVLong(DataInputStream dis) throws IOException { byte b = dis.readByte(); //读入一个字节 int i = b & 0x7F; //取低7位的值 //每个高位的字节多乘个2的7次方,也就是128 for (int shift = 7; (b & 0x80) != 0; shift += 7) { if(dis.available()!=0){ b = dis.readByte(); i |= (b & 0x7F) << shift; //当前字节表示的位乘2的shift次方 } } return i;//返回最终结果i } //把long型数组simHashSet写入fileName指定的文件中去 private static int write(long[] simHashSet,String fileName) { BufferedOutputStream dos = new BufferedOutputStream(new FileOutputStream(fileName)); byte[] b = longToBytes(simHashSet[0]);// 数组的第一个数字一个转换成二进制 dos.write(b);//把它写到文件中 for (int i = 1; i < simHashSet.length; i++) { long deta=simHashSet[i]-simHashSet[i-1];//数组中后一个数减前一个数的差 writeVLong(deta, dos);// 把这个差值写入文件 } - - 创建索引库 dos.close(); return simHashSet.length; } //从fileName指定的文件中把long型数组读出来 private static long[] read(int len,String fileName) { DataInputStream dis = new DataInputStream(new BufferedInputStream( new FileInputStream(fileName))); long[] simHashSet = new long[len]; simHashSet[0] = dis.readLong();//从文件读取第一个long型数字放入数组 for (int i = 1; i < len; i++) { simHashSet[i] = readVLong(dis);//读取文件剩下的元素 //将元素都变成数组后一个数和前一个数字的和 simHashSet[i] = simHashSet[i] + simHashSet[i - 1]; } dis.close(); return simHashSet; } 6.2.6 设计索引库结构 假设需要按关键词搜索新闻,需要搜索的列包括标题和内容。每篇新闻文档本身还有发布日期和对应的URL地址,这两列不需要按关键词查询,只需要能够取出来对应的值就可以了。表6-3是一个新闻索引库的结构: 表6-3 索引库结构表 名称 描述 在Lucene中的定义 title 存储新闻标题,需要分词和关键词加亮 new Field("title", title , Field.Store.YES, Field.Index.TOKENIZED, Field.TermVector.WITH_POSITIONS_OFFSETS); body 存储新闻正文,需要分词和关键词加亮 new Field("body", body , Field.Store.YES, Field.Index.TOKENIZED, Field.TermVector.WITH_POSITIONS_OFFSETS); url 存储新闻的来源URL地址 new Field("url", url , Field.Store.YES, Field.Index.UN_TOKENIZED, Field.TermVector.NO); - - 创建索引库 名称 描述 在Lucene中的定义 date 存储日期字段,在Lucene中需要转换成数值存储 new NumericField ("date").setLongValue(pubDate, getTime()); 这个新闻索引库相当于SQL语句create table news ("title","body","url","date") 6.3 创建和维护索引库 可以用Lucene提供的API创建和更新索引。在生成索引过程中涉及到的几个类关系如图6-5所示: Document Analyzer IndexWriter Field(Title) Field(Body) 图6-5 创建索引过程中用到的类 6.3.1 创建索引库 索引一般存放在硬盘中的一个路径中。可以通过IndexWriter来创建一个新的索引库。相关的参数有:索引路径,分词类,和是否增量索引。第三个参数是表示是否重新建立索引,类似文件是否追加写。 IndexWriter index = new IndexWriter(new File(indexDir), new StandardAnalyzer(), !incremental); 根据该文件夹下是否存在索引文件来决定是否追加索引: boolean createIndex = false; String indexDir = "d:/index"; Directory indexDirectory = FSDirectory.open(new File(indexDir)); // 该文件夹下是否存在索引文件 if (!IndexReader.indexExists(indexDirectory)) { createIndex = true; } // 根据该文件夹下是否存在索引文件来决定是否追加索引 IndexWriter index = new IndexWriter(indexDirectory, - - 创建索引库 new StandardAnalyzer(Version.LUCENE_CURRENT), createIndex, IndexWriter.MaxFieldLength.UNLIMITED); 如果有多个索引,一般不会放在同一个索引。除了在可以长期保存的物理路径中,索引库也可以仅在内存中。在测试分词或索引的时候,可能会用到内存索引。 Directory dir = new RAMDirectory(); //内存路径 IndexWriter writer = new IndexWriter(dir, analyzer, true, MaxFieldLength.LIMITED); 6.3.2 向索引库中添加索引文档 和数据库表一样,索引库是结构化的,一个文档往往有很多列,例如标题和内容列等。往索引添加数据时涉及到的几个类的关系如图6-6: Index Document Document Document Document Field Field Field Field Field Name Value 图6-6 往索引中添加文档 下面这段程序向索引库中添加网页地址,标题和内容列: //如果初次使用Lucene,往索引中写入的每条记录最好都新创建一个Document与之对应, //也就是说Document对象不要重用,否则可能会出现意想不到的错误。 Document doc = new Document(); //创建网址列 Field f = new Field("url", news.URL , Field.Store.YES, Field.Index.UN_TOKENIZED, Field.TermVector.NO); doc.add(f); //创建标题列 f = new Field("title", news.title , - - 创建索引库 Field.Store.YES, Field.Index.TOKENIZED, Field.TermVector.WITH_POSITIONS_OFFSETS); doc.add(f); //创建内容列 f = new Field("body", news.body.toString() , Field.Store.YES, Field.Index.TOKENIZED, Field.TermVector.WITH_POSITIONS_OFFSETS); doc.add(f); index.addDocument(doc); 列选项组合: Index Store TermVector 用法例子 NOT_ANALYZED YES NO 文件名、主键等 ANALYZED YES WITH_POSITIONS_OFFSETS 标题、摘要 ANALYZED NO WITH_POSITIONS_OFFSETS 很长的全文 NO YES NO 文档类型 NOT_ANALYZED NO NO 隐藏的关键词 在增加文档的阶段,给新的词分配TokenID,新的文档分配DocID。 索引创建完成后可以用索引查看工具Luke(http://code.google.com/p/luke/)来查看索引内容并维护索引库。Luke是一个可以执行的jar包,是用Java实现的windows程序。在Windows下可以双击lukeall-1.0.1.jar,启动luke。然后,可以选择菜单“File”==>“Open Lucene index”,打开“data/index”文件夹,然后可以在窗口看到索引创建的详细信息。 为了提高索引速度,可以重用Field,而不是每次都创建新的。从Lucene 2.3开始,有新的setValue方法,可以改变一个Field的值。这样可以在增加许多Document的时候重用单个的Field实例,可以节省许多GC消耗的时间。 最后新建一个独立的Document实例,然后增加许多Field实例,并且增加每个文档到索引的时候都重用这些Field。例如,有一个idField和bodyField, nameField等。当加入一个Document后,可以通过idField.setValue(...)等直接改变Field值,然后再增加文档实例。下面是一个重用Field的例子: Field idField = new Field("cid", null , Field.Store.YES, Field.Index.UN_TOKENIZED, Field.TermVector.NO); //定义id重用列 Field nameField = new Field("cname", null , - - 创建索引库 Field.Store.YES, Field.Index.TOKENIZED, Field.TermVector.WITH_POSITIONS_OFFSETS);//定义name重用列 while(rs.next()) { Document doc = new Document(); idField.setValue(String.valueOf(rs.getInt("companyID"))); doc.add(idField); nameField.setValue(rs.getString("companyCname")); doc.add(nameField); index.addDocument(doc); } 注意,不能在一个文档中重用单个Field实例,不应该改变一个列的值,直到包含这个Field的Document已经加入到索引库。 当在Windows 系统下使用的时候,最好关闭杀毒软件的自动删除已感染病毒文件的选项。否则当索引带病毒特征的文档时,杀毒软件可能破坏Lucene的索引文件。 每一个添加的文档都被传递给DocConsumer类,它处理该文档并且与索引链表中(indexing chain)其它的consumers相互发生作用。确定的consumers,就像StoredFieldWriter和TermVectorsTermsWriter,提取一个文档中的词,并且马上把字节写入文件。 IndexWriter.setRAMBufferSizeMB方法。可以设置更新文档使用的内存达到指定大小之后才写入到硬盘。这样可以提高写索引的速度,尤其是在批量建索引的时候。 6.3.3 删除索引库中的索引文档 在Lucene 2.1.0之前,删除文档是在IndexReader中实现的。从Lucene2.1.0开始,可以从IndexWriter中删除文档了。 indexWriter.delete(new Term("id", "1")); IndexReader和IndexWriter都能够进行文档删除,其中的区别是:当IndexWriter打开索引的时候,IndexReader的删除操作会抛出LockObtainFailedException异常。 6.3.4 更新索引库中的索引文档 Lucene早期的版本,先通过IndexReader删除文档,然后再通过IndexWriter增加文档。在Lucene2.1.0以后IndexWriter直接提供了更新文档的接口。 indexWriter.updateDocument(new Term("url", "http://www.lietu.com"),document); Lucene的updateDocument方法仍然是先删除旧文档然后再向索引增加传入的新文档。如果只希望更新个别列而保持其他的列不动就会有问题。为了解决这个问题,可以搜索索引中的当前文档,改变要改变的列,然后把修改后的文档作为参数传给updateDocument - - 创建索引库 。 public void searchAndUpdateDocument(IndexWriter writer, IndexSearcher searcher, Document updateDoc, Term term) throws IOException { TermQuery query = new TermQuery(term); TopDocs hits = searcher.search(query, 10); if (hits.scoreDocs.length == 0) { throw new IllegalArgumentException("索引中没有匹配的结果"); } else if (hits.scoreDocs.length > 1) { throw new IllegalArgumentException("Given Term matches more than 1 document in the index."); } else { int docId = hits.scoreDocs[0].doc; //retrieve the old document Document doc = searcher.doc(docId); List replacementFields = updateDoc.getFields(); for (Field field : replacementFields) { String name = field.name(); String currentValue = doc.get(name); if (currentValue != null) { //replacement field value //remove all occurrences of the old field doc.removeFields(name); //insert the replacement doc.add(field); } else { //new field doc.add(field); } } //write the old document to the index with the modifications - - 创建索引库 writer.updateDocument(term, doc); } } 6.3.5 索引的合并 索引很多文档的过程通常比较慢。为了加快索引速度,可以多台机器同时索引不同内容,然后合并。要合并的几个不同的索引结构要一致。下面的程序可以把多个目录下的索引合并到一个目录下: IndexWriter writer = new IndexWriter(args[0], null, true); writer.setMergeFactor(50); //参数越大,用到的内存越多。影响内存的使用。 //影响索引文件的数量 writer.setUseCompoundFile(false); Directory[] dirs = new Directory[args.length -1]; System.out.println("begin :"+args[0]); for (int i=1 ;i 其中:字段数量表示索引中字段的数量;字段名称是用字符串表示的字段名称;字段的二进制位描述是个byte值和int值。byte值用1个最低位表示是否索引这个字段。 例如: doc.add(new Field(“text", “content”, Field.Store.NO, Field.Index.TOKENIZED)); 在列信息文件中存储成: - - 创建索引库 1, 词典文件以tis为文件后缀。这个文件中的词是按顺序存放的。词首先按词对应的字段名称排序,然后按词的正文排序。词典文件格式是: 词的数量, <词, 词的文档频率> 其中,词用前缀压缩的方式存储,格式是:前缀的长度, 后缀, 字段编号。词的文档频率指词在多少个文档中出现过。排好序的词表中,前后两个词往往包括共同的前缀。前缀的长度变量就是表示与前一项相同的前缀的字数。例如,如果前一个词是"bone",后一个是"boy"的话,前缀的长度值为2,后缀值为"y"。 例如有两个文档,内容是: 文档1:Penn State Football …football 文档2:Football players … State 在索引中的存储形式是: 4,<<0,football,1>,2> <<0,penn,1>, 1> <<1,layers,1>,1> <<0,state,1>,2> 词典文件太大,为了能把词信息完整地读入内存,设计出了词信息索引文件(.tii)。词信息索引文件不存储词本身,但是保存随机读取的文件的位置信息。 词在文档中出现的频率文件以frq为文件后缀。词频率首先是按照tis中的词序来排列,在每个词内部,存储这个词在文档中出现的频率按照docID排序。频率文件中并没有存储docID,而是存储前后两个docID的增量相关的一个值DocDelta。频率文件格式是: DocDelta同时决定了文档号和频数。详细的说,DocDelta/2表示当前docID相对于前一个docID的偏移量(或者是0,表示这是TermFreqs里面的第一项)。当DocDelta是奇数时表示在该文档中频数为1,当DocDelta是偶数时,下一个整数就表示在该文档中出现的频数。 例如,假设某一项在文档7中出现一次,在文档11中出现了3次,在TermFreqs中就存在如下的整数序列:15, 8, 3。 在这里: 15 = 2 * 7 + 1 --> 在文档 7 中出现频率是 1 8 = 2 * (11 - 7) --> 在文档 11 中出现频率 > 1 - - 创建索引库 3 --> 在文档 11 中出现频率 = 3 Posting id 词 docID offset 1 football 1 3     1 67     2 1 2 penn 1 1 3 players 2 2 4 state 1 2     2 13 表6-3 Posting List 表6-3中的Posting List对应的频率文件存储内容是: <<2, 2, 3> <3> <5> <3, 3>> 存储词在文档中出现过的位置的位置文件以prx为后缀。TermPositions按照词来排序(依据.tis文件中词的位置)。Positions数值按照docID升序排列。实际存储的是PositionDelta值,PositionDelta是当前位置相对于前一个出现位置(或者为0,表示这是第一次在这个文档中出现)的增量值。例如,假设某词在某文档第4项出现,在接下来的一个文档中第5项和第9项出现,将表示为如下的整数序列:4, 5, 4。表6-3对应的位置文件存储内容是: <<3, 64> <1>> <<1> <0>> <<0> <2>> <<2> <13>> Lucene的查询过程访问的文件如图6-8: - - 创建索引库 查询 词典文件 (随机文件访问) 词信息索引文件(内存中) 常数时间 常数时间 词频率文件 (随机文件访问) 常数时间 词位置文件 (随机文件访问) 常数时间 列信息文件(内存中) 常数时间 图6-8 查询访问文件 复合文件格式的索引优化后只有三个文件,其中segments_N和segments.gen是固定不变的,因为这两个文件是在索引级别存在的文件,还有一个是复合索引文件格式(.cfs)。可以通过setUseCompoundFile 方法设定是否使用复合文件格式。 6.3.7 多线程写索引 Lucene缺省只使用一个线程写索引。而且一个索引只能由一个进程打开。 public class ThreadedIndexWriter extends IndexWriter { private ExecutorService threadPool; private Analyzer defaultAnalyzer; private class Job implements Runnable { //保留要加入索引的一个文档 Document doc; Analyzer analyzer; Term delTerm; public Job(Document doc, Term delTerm, Analyzer analyzer) { this.doc = doc; this.analyzer = analyzer; this.delTerm = delTerm; - - 创建索引库 } public void run() { //实际增加和更新文档 try { if (delTerm != null) { ThreadedIndexWriter.super.updateDocument(delTerm, doc, analyzer); } else { ThreadedIndexWriter.super.addDocument(doc, analyzer); } } catch (IOException ioe) { throw new RuntimeException(ioe); } } } public ThreadedIndexWriter(Directory dir, Analyzer a, boolean create, int numThreads, int maxQueueSize, IndexWriter.MaxFieldLength mfl) throws CorruptIndexException, IOException { super(dir, a, create, mfl); defaultAnalyzer = a; threadPool = new ThreadPoolExecutor( //创建线程池 numThreads, numThreads, 0, TimeUnit.SECONDS, new ArrayBlockingQueue(maxQueueSize, false), new ThreadPoolExecutor.CallerRunsPolicy()); } public void addDocument(Document doc) { //让线程池增加文档 threadPool.execute(new Job(doc, null, defaultAnalyzer)); } public void addDocument(Document doc, Analyzer a) { //让线程池增加文档 threadPool.execute(new Job(doc, null, a)); } public void updateDocument(Term term, Document doc) { //让线程池更新文档 threadPool.execute(new Job(doc, term, defaultAnalyzer)); - - 创建索引库 } //让线程池更新文档 public void updateDocument(Term term, Document doc, Analyzer a) { threadPool.execute(new Job(doc, term, a)); } public void close() throws CorruptIndexException, IOException { finish(); super.close(); } public void close(boolean doWait) throws CorruptIndexException, IOException { finish(); super.close(doWait); } public void rollback() throws CorruptIndexException, IOException { finish(); super.rollback(); } private void finish() { //关闭线程池 threadPool.shutdown(); while(true) { try { if (threadPool.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS)) { break; } } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw new RuntimeException(ie); } } - - 创建索引库 } } 6.3.8 分发索引 为了实现分布式的搜索,需要把索引从一台服务器备份到另外一台服务器。最简单的一个方法是:在生成索引的服务器上用tar命令压缩索引目录,然后在另外一台搜索服务器上执行wget获得压缩成一个文件的索引。在Windows下,可以使用7z软件中的7z压缩格式备份正在写入的索引。 当增加、修改索引的时候,Lucene 索引文件一般不会发生太大的变化。优化索引的时候,索引文件才会有大变化。所以,实际中可以通过只传送修改的文件(也就是同步索引文件目录)的方式实现索引分发。rsync(http://samba.anu.edu.au/rsync/)是一个开源的工具,提供了快速的增量文件传输功能。使用rsync可以保持主服务器中的索引目录能够定期同步到从服务器的索引目录中。rsync支持通过ssh进行网络加密传输,也可以利用ssh客户端密钥建立服务器之间的信任关系。当在两台服务器之间保持大型、复杂目录结构的同步时候,使用rsync比tar或wget等方式都要快,而且可以做到精确同步。使用rsync的分布式垂直搜索架构如图6-8所示。 前台搜索机器 rsync 爬虫与索引机器 rsync --daemon 信息采集 主索引 备份索引 Web界面 文件传输 图6-8 分布式垂直搜索架构 rsync命令的基本形式是: rsync [OPTION]... 来源地址路径 目的地地址路径 rsync可以通过两种不同的方式连接一个远程系统:使用一个远程shell程序(例如ssh或rsh)作为中转,或者通过TCP直接连接一个rsync守护进程。当地址路径信息包含"::"分隔符时启动服务器传输模式。当地址路径包含单个冒号":"分隔符时启动远程shell传输模式。 首先可以在主服务器和从服务器上都安装最新的rsync软件。rsync的编译安装非常简单,只需要以下简单的几步: - - 创建索引库 # ./configure # make # make install 然后用一个命令就可以一次性同步索引目录: #rsync -ave ssh master:/home/index/ /home/index/ 这个命令把远端master机器上的/home/index/目录中的内容同步到本地的/home/index/目录下。 在创建索引的机器上运行rsync守护进程可以通过在rsync命令中声明--daemon参数。索引机器上建立配置文件rsyncd.conf和密码文件rsyncd.secrets。前台搜索机器通过crontab定时运行rsync获取最新的索引文件。 配置rsync守护进程的方法是修改/etc/rsyncd.conf并把rsync守护进程设置成自启动方式。Linux中的大部分的网络服务都是由inetd启动的, 修改/etc/xinetd.d/rsync文件把其中的 disable = yes改成 disable = no 。在/etc/ 目录下建立配置文件rsyncd.conf。 #cat /etc/rsyncd.conf use chroot = yes # 使用chroot max connections = 4 # 最大连接数为4 pid file = /var/run/rsyncd.pid lock file = /var/run/rsync.lock log file = /var/log/rsyncd.log # 日志记录文件 [index] # 这里是认证的模块名,在client端需要指定 path = /home/index # 需要做镜像的目录 auth users = index # 认证的用户名 uid = index gid = index secrets file = /etc/rsyncd.secrets # 认证文件名 read only = yes # 只读 在/etc/目录下建立配置文件rsyncd.secrets。rsyncd.secrets是rsyncd的密码文件,里面写登陆Linux系统的用户名和密码。 #cat /etc/rsyncd.secrets index:abcde # 用户名index 密码abcde 然后配置客户端。为了避免交互的方式输入密码,大多使用密码文件。也可以将两台服务器生成密钥互相设为信任认证,这样做的麻烦是程序不通用,每两台服务器都需要生成证书。 - - 创建索引库 假设密码文件是rsyncd.secrets。 #cat /home/index/rsyncd.secrets abcde 需要把密码文件改为只有所属人有权限。 #chmod 600 然后把文件同步命令写到一个可执行的脚本: #cat index_rsyncd.sh #!/bin/bash rsync -tvzrp --progress --password-file=/home/index/rsyncd.secrets --delete --exclude /home/index/logs index@master::index /home/index/ 然后通过crontab设定,让这个脚本每10分钟运行一次。 #echo "0,10,20,30,40,50 index_rsyncd.sh">>/etc/crontab 为了实现搜索从服务器和索引主服务器的索引同步,在索引主服务器上,为索引做阶段性的检查点。每分钟的时候,关闭IndexWriter,并且从Java中执行 'cp -lr index index.DATE'命令,这里DATE是指当前时间。这样通过构建硬连接树,而不是制作完全的备份有效的制作了一个索引的拷贝。如果 Lucene重写了任何文件(例如segments文件),将会创建新的inode,而原来的拷贝不变。 在每个搜索从服务器上,定期检查新的检查点。当发现一个新的index.DATE,使用'cp -lr index index.DATE'准备一份拷贝,然后使用'rsync -W --delete master:index.DATE index.DATE'得到增量的索引改变。然后使用原子性的符号连接操作安装更新的索引 (ln -fsn index.DATE index)。 在从服务器上,当索引版本改变的时候重新打开'index'。最好是在一个独立的线程中定时检查索引版本。当索引改变时,打开索引新的版本,执行一些热门查询操作,预加载Lucene缓存。然后在一个同步块中,替换在线服务的Searcher变量。 在主索引服务器的一个crontab中,定时移走最旧的检查点索引。 这样可以实现每分钟的同步,在主索引服务器上的mergeFactor 设为2以最小化在产品中的segments数量。主服务器有一个热备份。 当增加文档到已经存在的索引库,会生成新的.cfs文件。这个文件需要全部传输(而不是差量传输),因为文件名改了。所以,为了使增量更新更有效率,尽量让索引不要用复合文件格式。 - - 创建索引库 6.3.9 修复索引 当索引崩溃时,可以重建索引,但是重建大的索引往往比较耗时,所以还可以考虑修复索引。CheckIndex是一个Lucene包中的工具。它检查文件并创建新的不包含有问题的入口的段。这意味着这个工具以很小的数据丢失为代价来修复坏索引。这个工具一个字节一个字节的分析索引,因此对于大的索引,分析和修复的时间可能比较长。 可以先使用CheckIndex来检查索引的完整性。例如: CheckIndex D:\index 如果有问题再修复索引。修复索引的命令: java -cp lucene-core-2.9.3.jar org.apache.lucene.index.CheckIndex d:\index\ -fix 6.4 查找索引库 可以按关键词查询指定的列,根据相关度返回结果,也可以自定义搜索结果排序方式。 6.4.1 基本查询 使用IndexSearcher的search来执行搜索,返回一个TopDocs对象。基本的关键词查询代码如下: String indexDir = "D:/indexdir"; //索引库路径 Directory directory = FSDirectory.open(new File(indexDir)); //打开一个文件路径 // read-only=true IndexSearcher searcher = new IndexSearcher(directory, true); //搜索 String defaultField = "title"; //缺省查询列 String queryString ="NBA"; //查询词 Analyzer analyzer = new StandardAnalyzer(Version.LUCENE_31); //指定Lucene版本号 QueryParser parser = new QueryParser(Version.LUCENE_31, defaultField, analyzer); Query query = parser.parse(queryString); TopDocs hits = searcher.search(query, 10); //查询最多只返回前10条结果 TopDocs对象中的totalHits属性记录了搜索返回结果总条数。TopDocs.totalHits的值往往比n大。 - - 创建索引库 FSDirectory表示硬盘中的索引。RAMDirectory表示内存中的索引,可以用来测试。 可以使用QueryParser查询分词列。一般不需要查询使用没有分词的列,如果要查询可以使用TermQuery查询,例如“url”列。 6.4.2 排序 为了实现情景搜索。根据用户IP判断用户所在地区,然后把和用户所在区域的文档排在前面。把通过排序把地名相同的搜索结果放在前面。 采用自定义排序对象的方法实现把和用户所在地域相同的结果放在前面。需要实现FieldComparatorSource和FieldComparator接口。FieldComparatorSource功能是返回一个用来排序的比较器。这个接口只定义了一个方法newComparator,用来生成FieldComparator对象。生成的FieldComparator对象可作为指定文档域的排序比较器。FieldComparator对象需要实现setScorer、compare、compareBottom、copy、setBottom、setNextReader等方法,如果需要用到文档的评分值,还需要实现setScorer方法。 首先要定义一个继承抽象类:FieldComparatorSource 的实现类CityFieldComparator,重写其中的抽象方法: public FieldComparator newComparator(String field, int numHits, int sortPos, boolean reversed) throws IOException; 该方法返回一个FieldComparator实例实现自定义排序。 然后定义一个类CitySortComparator继承自FieldComparator抽象类。重写其中的抽象方法,在public int compare(int slot1, int slot2)方法中实现排序业务逻辑。 最后再调用该自定义排序类。 //这里设置为true是正序,false为倒序。调用CityFieldComparator的构造方法初始化城市名,这里城市名称可以根据用户IP获得。 SortField citySort = new SortField("city", new CityFieldComparator(city), true); //按时间排序,true为时间减序,false为增序。 SortField dateSort = new SortField("date", SortField.INT, true); //首先按城市排序,然后按日期排序 Sort sort = new Sort(new SortField[] {citySort,dateSort}); TopFieldCollector collector = TopFieldCollector.create(sort, offset+rows, false, true, false, false); //isearcher是IndexSearcher的实例。 isearcher.search(bq, collector); - - 创建索引库 ScoreDoc[] hits = collector.topDocs().scoreDocs; 6.4.3 使用Filter筛选搜索结果 可以定义Filter类来过滤查询结果。也可以缓存和重用Filter。如下条件可用Filter来实现: l 根据不同的安全权限显示搜索结果; l 仅查看上个月的数据; l 在某个类别中查找。 下面定义一个BestDriversFilter,把搜索结果限定到score是5的司机。 public class BestDriversFilter extends Filter{ @Override public DocIdSet getDocIdSet(IndexReader reader) throws IOException { OpenBitSet bitSet = new OpenBitSet( reader.maxDoc() ); //查询出score是5的文档 TermDocs termDocs = reader.termDocs( new Term( "score", "5" ) ); while ( termDocs.next() ) { bitSet.set( termDocs.doc() );// 把符合条件的文档对应的位置为1 } return bitSet; } } 在查询中使用这个Filter: Filter bestDriversFilter = new BestDriversFilter(); //query不变, 增加bestDriversFilter ScoreDoc[] hits = isearcher.search(query, bestDriversFilter, 1000).scoreDocs; //因为不是每个司机都能得5分,所以返回的结果可能比以前少了 6.5 读写并发 在一个时刻只能够有一个线程修改索引库。Lucene通过锁文件控制并发访问。在Lucene 2.1版本以后,控制写入的锁文件write.lock现在默认存储在index路径。 如果出现多余的锁文件,则有可能会抛出“Lock obtain timed out”异常。如果确定没有线程在修改索引,可以手工删除write.lock文件。 - - 创建索引库 例如当全文索引放在只读光盘中时,需要设置这个索引只读。只读索引的初始化设置如下: Directory indexDir = FSDirectory.getDirectory(indexPath,NoLockFactory.getNoLockFactory()); 如果一台机器用来索引的同时也用来执行搜索,就不会预热reader,这样会是灾难性的。当搜索的时候,用户会突然经历长时间的延时。因为一个大的合并能花费数小时,这样就意味着数小时的突然搜索性能变差。 因为Java没有暴露底层的API,例如文件咨询信息(posix_fadvise)和内存咨询信息和对齐控制(posix_madvise),所以要使用一个小的JNI扩展来改进性能。操作系统级别的功能应该能修复这个问题。 仅仅在合并索引的时候完全忽略所有的操作系统缓存,通过使用Linux特定的O_DIRECT标志实现这点。合并索引的性能会变差,因为操作系统不再做预读也不写缓存,每个IO请求都接触到硬盘。但这可能是一个很好的折衷。 有些类似的应用,例如视频解码,可能不需要缓存。因此创建了一个原型Directory实现。一个DirectNIOFSDirectory的变体。 (currently a patch on LUCENE-2056)使用O_DIRECT打开所有的输入和输出文件,这是通过jni实现的。因为所有的IO都必须用某种规则对齐,所以实现代码有点乱。 由于按顺序的读,linux倾向于赶出已经加载的页面。 最后,这个方法工作的很好。在执行索引优化时,搜索性能没有改变。 但是,优化阶段的时间从1336秒延长到了1680秒(慢了26%)。 However, the optimize call slowed down from 1336 to 1680 seconds (26% slower). This could likely be reduced by further increasing the buffer sizess (I used 1 MB buffer for each IndexInput and IndexOutput, which is already large), or possibly creating our own readahead / write cache scheme. 6.6 优化使用Lucene Lucene优化包括性能的优化和搜索结果优化等方面。性能优化包括在索引阶段的优化和查询阶段的优化。搜索结果优化包括修改匹配结果的打分公式以影响搜索结果排序或修改Tokenizer等来影响搜索结果数量。 6.6.1 索引优化 Lucene打开文件数量很多的时候,可能会达到操作系统允许打开文件数量的上限,导致写索引 - - 创建索引库 时出现“Too Many Open Files”的错误。Linux允许打开文件数量的上限默认值是1024,索引文件数量很容易达到这个限制值。可以通过ulimit –a 命令来查看当前参数。 # ulimit -a core file size (blocks, -c) 0 data seg size (kbytes, -d) unlimited max nice (-e) 0 file size (blocks, -f) unlimited pending signals (-i) 81920 max locked memory (kbytes, -l) 32 max memory size (kbytes, -m) unlimited open files (-n) 1024 pipe size (512 bytes, -p) 8 POSIX message queues (bytes, -q) 819200 max rt priority (-r) 0 stack size (kbytes, -s) 10240 cpu time (seconds, -t) unlimited max user processes (-u) 81920 virtual memory (kbytes, -v) unlimited file locks (-x) unlimited 可以使用命令 ulimit -n 65535 增大最大打开文件数量到65535。为了保存配置参数,需要修改操作系统配置文件/etc/security/limits.conf,增加如下行: *                -       nofile          65535 在Lcene2.3版本之前,存入索引的每个Token都是新创建的。重复利用Token可以加快索引速度。新的Tokenizer类可以回收利用用过的Token。 6.6.2 查询优化 在英文中,用户的输入中包含空格,中文的用户输入一般不包含空格。英文空格本身既作为单词的边界,在QueryParser解释查询语法的时候,又当做了OR来处理。例如,在Lucene搜索中输入“Olympic Torch” 和 “Olympic OR Torch”返回的结果一样。 因为中文用户的输入往往不包含空格, Lucene自带的QueryParser是为解析英文查询设计的,用来解析中文查询时,返回的Query显得太严格了。比如搜索“日本小仓离合器”,只有连续出现“日本小仓离合器”的记录才能匹配上,将无法匹配到下面这段文字: 日本: MUTOH编码器、MUTOH控制器 - - 创建索引库 OGURA(小仓)离合器 这时候,需要把查询约束条件改成 “日本” “小仓” “离合器” 三个词都出现在同一条记录即可匹配。下面的BlankAndQueryParser重写了QueryParser 类的getFieldQuery方法。 public class BlankAndQueryParser extends QueryParser{ public BlankAndQueryParser(String field, Analyzer analyzer) { super(field, analyzer); } protected Query getFieldQuery(String field, String queryText, int slop) throws ParseException { TokenStream source = analyzer.tokenStream(field, new StringReader(queryText)); ArrayList v = new ArrayList(10); Token t; while (true) { try { t = source.next(); } catch (IOException e) { t = null; } if (t == null) break; v.add(t); } try { source.close(); } catch (IOException e) { } if (v.size() == 0) return null; else if (v.size() == 1) return new TermQuery(new Term(field, ((Token)v.get(0)).termText())); else { PhraseQuery q = new PhraseQuery(); BooleanQuery b = new BooleanQuery(); - - 创建索引库 q.setBoost(2048.0f); b.setBoost(0.001f); for (int i = 0; i < v.size(); i++) { Token token = v.get(i); q.add(new Term(field, token.termText())); TermQuery tmp = new TermQuery(new Term(field, token.termText())); tmp.setBoost(0.01f); b.add(tmp, BooleanClause.Occur.SHOULD); } BooleanQuery bQuery = new BooleanQuery(); // 用OR条件合并两个查询 bQuery.add(q,BooleanClause.Occur.SHOULD); bQuery.add(b,BooleanClause.Occur.SHOULD); return bQuery; } } protected Query getFieldQuery(String field, String queryText) throws ParseException { return getFieldQuery(field, queryText, 0); } } 当用户在搜索框中输入整句话“机床设备的最新退税率是多少?”,直接用上面的BlankAndQueryParser搜索可能不会返回任何结果。相对来讲,把 “机床”和 “退税率”当作搜索关键词是更好的选择。可以把“设备”,“的”,“最新”,“是”,“多少”这些意义不大的词从必选词中去掉。这些意义不大的词可以放在一个大的停用词表stopSet中。这样getFieldQuery方法中的循环条件改为: for (int i = 0; i < v.size(); i++){ Token token = v.get(i); q.add(new Term(field, token.termText())); //如果在停用词中,则不把这个词加入AND条件 if( stopSet.contains(token.termText()) ) { continue; } TermQuery tmp = new TermQuery(new Term(field, token.termText())); tmp.setBoost(0.01f); b.add(tmp, BooleanClause.Occur.MUST); - - 创建索引库 } 如果用户输入较短的查询串,上面的布尔查询b 容易匹配到一些看起来不太相关的长的文本,因为长文本中可能出现各种各样的Term组合。这时候,可能希望匹配的Term是分布在文本中较集中的区域,为了计算匹配的位置,可以利用Lucene中的Span对象,它记录了匹配词在文档中所处的位置。SpanNearQuery像一个PhraseQuery和BooleanQuery的组合,匹配文档中的查询词必须出现在一定间隔范围内。利用SpanNearQuery,BlankAndQueryParser可以进一步修改为: PhraseQuery q = new PhraseQuery(); q.setBoost(2048.0f); ArrayList s = new ArrayList(v.size()); for (int i = 0; i < v.size(); i++){ Token token = v.get(i); q.add(new Term(field, token.termText())); if( stopSet.contains(token.termText()) ) { continue; } SpanTermQuery tmp = new SpanTermQuery (new Term(field, token.termText())); s.add(tmp); } BooleanQuery bQuery = new BooleanQuery(); // 用OR条件合并PhraseQuery和SpanNearQuery bQuery.add(q,BooleanClause.Occur.SHOULD); SpanNearQuery nearQuery = new SpanNearQuery(s.toArray(new SpanQuery[s.size()]),s.size(),false); nearQuery.setBoost(0.001f); bQuery.add(nearQuery,BooleanClause.Occur.SHOULD); 这样搜索“美丽人生”时,不会匹配“父母对子女的态度会影响他们日后的性格、感情,乃至整个人生。…个性好的人更美丽”。 6.6.3 实现时间加权排序 情境搜索是对用户搜索的各类数据和信息进行深入理解后,提供给用户最贴切的搜索服务。根据用户搜索行为的时间、地点、输入、需求、习惯、背景等因素,由情境计算得到最适合的搜索结果。在索引列中存储文档的日期、时间和类别等属性。根据用户搜索的时间把索引库中时间最近的文档放在前面。也就是根据非全文性的值来影响排序结果。 有时候希望当两个搜索结果的相关度分值score差不多的时候新的搜索结果显示在前面。这个特性无法用查询语言来实现。FieldScoreQuery是对lucene的一个最新的增加类。lucene 2.3以后才有这个版本,基本上,FieldScoreQuery的功能是把索引中的一列解释成浮点数并且获得一个分值(score)。然后使用CustomScoreQuery 把这个分值与最初查询的分值合并起来。索引中增加一个浮点数的列 - - 创建索引库 “timestampscore”,这个列的格式是“0.” +时间戳,其中时间戳格式化为yyyyMMddhhmm字符串(lucene索引中只能存储字符串列)。实现代码如下: String query="foo" QueryParser parser =new QueryParser("name", new StandardAnalyzer()); Query q = parser.parse(query); Sort updatedSort = new Sort(); FieldScoreQuery dateBooster = new FieldScoreQuery("timestampscore", FieldScoreQuery.Type.FLOAT); CustomScoreQuery customQuery = new CustomScoreQuery(q, dateBooster); Hits results = getSearcher().search(customQuery, updatedSort); 结果,最新时间戳有一个稍微高的分数。或者还可以通过权重来进一步调整查询。 尽管这个模型可以运行,但是并不完美。不能同样对待一周前的文档和两周前的文档之间的差距与一年前的文档和一年零一周前的文档之间的差距。可以通过文档年纪取对数后导出的分值来提高这个模型。为了达到这个目标,先深入了解下function包。FieldScoreQuery基于类 ValueSourceQuery,这个类用一个ValueSource得到文档的值。如下的例子创建一个定制的ValueSource,叫做AgeFieldSource。这个ValueSource和上面的FieldScoreQuery类似。然而,AgeFieldSource并不是仅仅返回索引列中的值,它返回一个基于文档年纪取对数后的倒数的权重。 public class AgeFieldSource extends FieldCacheSource { private int now; public AgeFieldSource(String field) { super(field); now = (int)(System.currentTimeMillis() / 1000); } @Override public boolean cachedFieldSourceEquals(FieldCacheSource other) { return other.getClass() == MyFieldSource.class; } @Override public int cachedFieldSourceHashCode() { return Integer.class.hashCode(); } - - 创建索引库 @Override public DocValues getCachedFieldValues(FieldCache cache, String field, IndexReader reader) throws IOException { int[] times = cache.getInts(reader, field); float[] weights = new float[times.length]; for (int i=0; i失败!"); ex.printStackTrace(); } finally { try { if (inputFile != null) inputFile.close(); } catch (IOException e) { e.printStackTrace(); } } try { if (indexDir != null) { Directory directory = - - 用户界面设计与实现 FSDirectory.open(new File(indexDir)); searcherManager = new SearcherManager(directory); } } catch (IOException e) { e.printStackTrace(); } } /** * 根据查找关键词返回结果集 * * @param word * @return * @throws ParseException * @throws IOException * @throws InterruptedException * @throws Exception */ public List getResults(String word) throws ParseException, InterruptedException, IOException { Analyzer analyzer = new StandardAnalyzer(Version.LUCENE_30); QueryParser parser = new QueryParser(Version.LUCENE_30, "title", analyzer); Query titleQuery = parser.parse(word); searcherManager.maybeReopen(); IndexSearcher searcher = searcherManager.get(); try { ScoreDoc[] hits = searcher.search(titleQuery, 10000).scoreDocs; List lst = new ArrayList(); // 遍历结果 for (int i = 0; i < hits.length; i++) { Document hitDoc = searcher.doc(hits[i].doc); String title = hitDoc.get("title"); String url = hitDoc.get("url"); String des = hitDoc.get("body"); - - 用户界面设计与实现 String date = hitDoc.get("date"); Date d = DateTools.stringToDate(date); Date nowDay = new Date(); SimpleHTMLFormatter simpleHTMLFormatter = new SimpleHTMLFormatter( "", ""); Highlighter highlighter = new Highlighter(simpleHTMLFormatter, new QueryScorer(titleQuery)); highlighter.setTextFragmenter( new SimpleFragmenter()); TokenStream tokenStream = new StandardAnalyzer(Version.LUCENE_30). tokenStream("title", new StringReader(title)); String highLightText = highlighter.getBestFragment(tokenStream, title); GoodInfo good = new GoodInfo(); good.setGoodsName(highLightText); good.setGoodsNameURL(url); good.setGoodsDescription(des); lst.add(good); } return lst; } catch (Exception e) { e.printStackTrace(); logger.error("searchbyQuery",e); } finally { searcherManager.release(searcher); } return null; } } 为了防止关闭还在被其他线程使用着的IndexReader。搜索界面可以先借出IndexReader,然后再还回,这样告诉IndexReader对象缓存池,已经可以关闭这个对象了。 - - 用户界面设计与实现 类似借书,先借出去一本书,然后把这本书还回来。 public class ReaderPool { private final Queue objects; private IndexReader _reader; public ReaderPool(IndexReader r) { objects = new ConcurrentLinkedQueue(); _reader = r; } //借出对象 public IndexReader borrow() throws Exception { IndexReader t; if ((t = objects.poll()) == null) { t = _reader.reopen(); } return t; } //还回对象 public void giveBack(IndexReader object) { this.objects.offer(object); } } 搜索界面使用的例子: //借出对象 IndexReader reader = readerPool.borrow(); // 执行搜索 IndexSearcher searcher = new IndexSearcher(reader); //... //还回对象 readerPool.returnIndexReaders(reader); 可以用一个专门的守护线程检查缓存池中的IndexReader是否是新的。 - - 用户界面设计与实现 7.4 历史搜索词记录 可以通过Cookie记录用户经常搜索的关键字,然后就可以从用户经常搜索的关键字来判断用户的兴趣。先看下怎么设置用户查询词。Cookie在用户电脑中是以一种类似map的方式存放,且只能存放字符串类型的对象。通过response对象增加Cookie,代码如下: Cookie cookie = new Cookie("query", query); cookie.setMaxAge(60*60*24*30); //设置cookie的存放时间(单位是秒)。 //然后通过response对象的addCookie方法添加cookie才能生效。 response.addCookie(cookie); 通过request对象的getCookies方法得到一个包含所有Cookie的数组。 Cookie[] cookies = request.getCookies(); //然后遍历这个数组就能得到记录查询词的Cookie String query = null; for (int i = 0; i < cookies.length; i++) { Cookie c = cookies[i]; if (c.getName().equals("query")) { query = c.getValue(); } } 上面的例子显示了如何设置并获取名称叫做query的Cookie。如果要记录5个查询词,则需要设置5个不同的Cookie。如果要在Web界面显示历史查询词,则需要把这些关键词去重,然后再显示出来。 7.5 实现关键词高亮显示 在搜索结果中一般都有和用户搜索关键词相关的摘要。关键词一般都会高亮显示出来。从实现上说就是把要突出显示的关键词前加上“”标签,关键词后加上“”标签。Lucene的highlighter包可以做到这一点。 doSearching("汽车"); //使用一个查询初始化Highlighter对象 Highlighter highlighter = new Highlighter(new QueryScorer(query)); //设置分段显示的文本长度 highlighter.setTextFragmenter(new SimpleFragmenter(40)); //设置最多显示的段落数量 int maxNumFragmentsRequired = 2; for (int i = 0; i < hits.length(); i++) { - - 用户界面设计与实现 //取得索引库中存储的原始文本 String text = hits.doc(i).get(FIELD_NAME); TokenStream tokenStream=analyzer.tokenStream(FIELD_NAME, new StringReader(text)); //取得关键词加亮后的结果 String result = highlighter.getBestFragments(tokenStream, text, maxNumFragmentsRequired, "..."); System.out.println("\t" + result); } QueryScorer设置查询的query,这里还可以加上对字段列的限制,比如只对body条件的Term高亮显示,可以使用:new QueryScorer(query,” body”)。 为了实现关键词高亮,必须知道关键词在文本中的位置。对英文来说,可以在搜索的时候实时切分出位置。但是中文分词的速度一般相对来说慢很多。在Lucene1.4.3以后的版本中,Term Vector支持保存Token.getPositionIncrement() 和Token.startOffset() 以及Token.endOffset() 信息。利用Lucene中新增加的Token信息的保存结果以后,就不需要为了高亮显示而在运行时解析每篇文档。为了实现一列的高亮显示,索引的时候通过Field对象保存该位置信息。 //增加文档时保存Term位置信息。 private void addDoc(IndexWriter writer, String text) throws IOException{ Document d = new Document(); Field f = new Field(FIELD_NAME, text , Field.Store.YES, Field.Index.TOKENIZED, Field.TermVector.WITH_POSITIONS_OFFSETS); d.add(f); writer.addDocument(d); } //利用Term位置信息节省Highlight时间。 void doStandardHighlights() throws Exception{ Highlighter highlighter =new Highlighter(this,new QueryScorer(query)); highlighter.setTextFragmenter(new SimpleFragmenter(20)); for (int i = 0; i < hits.length(); i++) { String text = hits.doc(i).get(FIELD_NAME); int maxNumFragmentsRequired = 2; - - 用户界面设计与实现 String fragmentSeparator = "..."; TermPositionVector tpv = (TermPositionVector)reader.getTermFreqVector(hits.id(i),FIELD_NAME); TokenStream tokenStream=TokenSources.getTokenStream(tpv); String result = highlighter.getBestFragments( tokenStream, text, maxNumFragmentsRequired, fragmentSeparator); System.out.println("\t" + result); } } 最后把highlight包中的一个额外的判断去掉。对于中文来说没有明显的单词界限,所以下面这个判断是错误的: tokenGroup.isDistinct(token) 注意上面的highlighter.setTextFragmenter(new SimpleFragmenter(20)); 这句话。SimpleFragmenter是一个最简单的段落分割器。它把文章分成20个字的一个段落。这种方式简单易行,但显得比较初步。有时候会有一些没意义的符号出现在摘要的起始部分,如图7-2所示。 图7-2 摘要之前符号实例 RegexFragmenter是一个改进版本的段落分割器。它通过一个正则表达式匹配可能的热点区域。但它是为英文定制的。我们可以让它认识中文的字符段。 protected static final Pattern textRE = Pattern.compile("[\\w\u4e00-\u9fa5]+"); 这样使用highlighter就变成了: highlighter.setTextFragmenter(new RegexFragmenter(descLenth)); - - 用户界面设计与实现 7.6 实现分类统计视图 一个职位搜索网站需要统计出某一关键词下的要求本科学历的有多少岗位,要求专科学历的有多少岗位,薪资范围在4000-6000的有多少岗位,薪资范围在6000-8000的有多少岗位。从术语上讲,就是要从各个角度(维)进行分类并统计搜索结果数在相关分类中的分布情况。这个功能叫做搜索结果分类统计搜索(Faceted search)。分类可以是多层次的,用户可以沿着某一类继续细化,这有点象数据仓库中的向下钻取,但它不是用数据库而是用Lucene完成的。这也是Lucene的一个很有特色的应用案例。 分类统计搜索的基本功能有: l 根据刻面(facet)把搜索结果分组。 l 显示每个刻面值命中的总数。 l 可以通过刻面值细化搜索结果。 首先定义一个XML文件存储分类目录。可以使用W3C的DOM创建这个类别文件或者遍历文件。 这个项目需要和xercesImpl.jar用于DOM接口,serializer.jar用于保存XML文件。DOM提供了很多方便的类来创建XML文件。首先要使用DocumentBuilder创建一个Document,定义所有的XML内容-节点、Element类上的属性。 写XML文件的代码如下: Set categoriesSet = readerCategory(); //读入类别到categoriesSet //新建工厂类 DocumentBuilderFactory docFactory = DocumentBuilderFactory.newInstance(); //新建文档构建器 DocumentBuilder builder = docFactory.newDocumentBuilder(); - - 用户界面设计与实现 Document doc = builder.newDocument(); //创建根节点 doc.setXmlVersion("1.0"); Element catRoot = doc.createElement("categories"); //根节点下创建元素 doc.appendChild(catRoot); //元素节点增加到根节点 appendChild(doc, catRoot, categoriesSet); //增加孩子节点 writeDom(doc); //写文件 增加孩子的方法实现如下: private static void appendChild(Document doc, Element node, Set categories) { for (String line : categorys) { Element element = doc.createElement("category"); //创建元素 element.setAttribute("name", line); //设置元素节点的内容 node.appendChild(element); //增加孩子节点 } } 使用Transformer输出所有的XML内容到一个文件。写XML文件的方法实现如下: public static void writeDom(Document doc) throws ParserConfigurationException, IOException, TransformerException { TransformerFactory factory = TransformerFactory.newInstance(); Transformer trans = factory.newTransformer();//获得一个转换器 //使用一个DOM树作为来源对象 DOMSource source = new DOMSource(doc); File file = new File(FILE_CATEGORY_XML_PATH); if (!file.exists()) { file.createNewFile(); } FileOutputStream out = new FileOutputStream(file); StreamResult result = new StreamResult(out); //把来源对象转换到输出文件 trans.transform(source, result); } 按指定类别搜索,对搜索结果的分类统计功能,这两个功能是不一样的。按类别搜索类似SQL语句的where条件,分类统计类似SQL语句中的Group by 功能。 - - 用户界面设计与实现 最基本的想法是:按类别搜索和按关键词搜索,然后把这两个搜索结果做与运算,最后计算结果数量。 包含查询词 属于类别 图7-3 计算中间的交集大小 把计算交集大小最终转换成计算二进制位中1的个数。用一个Bit位来表示一个文档是否属于集合。一个这样的集合就是一个BitSet。计算一个刻面值的流程如图7-4。 1 0 1 1 0 1 0 类别条件BitSet 查询词条件BitSet 1 0 0 1 0 0 1 与运算结果 1 0 0 1 0 0 0 计算其中1的个数 2 图7-4 分类统计计算流程 可以利用QueryFilter来实现搜索结果分类统计。QueryFilter有个bits方法返回一个BitSet集,这个BitSet的大小是所针对的Lucene库的大小(也就是new BitSet(reader.maxDoc())),凡符合filter条件的文档在位集合中相应位置上置为1(true)。 这个BitSet集对特定QueryFilter对象来说是cache保存的,下次调用不会重新计算。然后利用这个BitSet集,将满足各个基本属性值的BitSet值计算出,根据特定用户需要进行相关的BitSet与(交集)操作,最后利用BitSet集的cardinality()方法就可计算出满足该类的总数。 用QueryFilter实现搜索结果分类统计的参考代码如下: String[] cats = {"001004003","001008003021","001004014" }; //类别数组 long[] catCounts = new long[cats.length];//分类统计结果 //原始查询 - - 用户界面设计与实现 Filter all = new QueryWrapperFilter(q); //用AND逻辑合并Filter ChainedFilter.DEFAULT = ChainedFilter.AND; for (int i=0;i>> 8 )& 0xff]; } } //计算给定数组A的二进制势表 public static long pop_array2(long A[],int wlen) { long _count = 0; for (int i = 0; i < wlen; i++) { _count += _bitsSetArray65536[(int) (A[i]& 0Xffff)] + _bitsSetArray65536[(int) ((A[i] >>> 16 )& 0xffff)] + _bitsSetArray65536[(int) ((A[i] >>> 32) & 0xffff)] + _bitsSetArray65536[(int) ((A[i] >>> 48) & 0xffff)]; } return _count; } 查表法和Java内部实现同样功能的bitCount方法测试比较性能: long x = 10000000000000000l; for(int i=0;i<1000;i++){ pop(x);//查表法实现的位计算 } long end = System.nanoTime(); System.out.println(end-start);//输出计算时间 long start2 = System.nanoTime(); for(int i=0;i<1000;i++){ Long.bitCount(x);//java内部实现的实现的位计算 } long end2 = System.nanoTime(); - - 用户界面设计与实现 System.out.println(end2-start2);//输出计算时间 下面的搜索结果分类统计功能实现通过DocSet的intersectionSize方法减少计算步骤又比上面的实现至少快了百分之几。 String[] cats = new System.String[] {"105","93","125" }; //类别数组 DocSetHitCollector all = new DocSetHitCollector(reader.MaxDoc()); searcher.Search(q, all); DocSet allDocSet = all.DocSet; int[] catCounts = new int[cats.Length]; for (int i = 0; i < mfgs.Length; ++i){ DocSetHitCollector these = new DocSetHitCollector(reader.MaxDoc()); searcher.Search(new TermQuery(new Term("type", cats)), these); //集合求交运算和计算集合大小运算两个操作 //在QueryFilter方法中是分开计算和独立优化的, //现在把这两个操作放入一个函数中整体优化来提高程序运算效率。 catCounts = these.DocSet.intersectionSize(allDocSet); } 上面这个实现比起最初的QueryFilter实现,在于合并了以下两个步骤: these.and(all); mfg_counts[i] = these.cardinality(); 这样得到搜索结果在类别中的分布图: 4个符合查询关键词的文档 体育 商业 艺术 教育 1 3 0 0 图7-5 搜索结果在类别中的分布图 - - 用户界面设计与实现 7.7 实现相似文档搜索 l 有时候需要检索与给定文档(例如BBS讨论区内某一帖子)相似的文档。打开一个新闻网页,往往在下面有块区域,显示和这篇新闻相关的新闻。 在Lucene的外围资源中有个 MoreLikeThis类,可以实现对索引内部的文档查询相似文档。顾名思义,这个类的作用就是找出更多类似于这个(This)文档的结果。 MoreLikeThis mlt = new MoreLikeThis(reader); mlt.setFieldNames(new String[] {"title", "content"}); mlt.setMaxQueryTerms(5); if(queryString.startsWith("related:")){ int docId = Integer.parseInt(queryString.substring(8)); query = mlt.like(docId); } 另外举个例子说明这个类的作用。比如对一个卖商品的网站来说,当顾客正在浏览一件商品时,如果能把和这件商品性能、作用很相近的商品也同时罗列在网页的左边,万一顾客想要的商品正好就在其中,那么这个网站的营业额肯定会有所提高。 MoreLikeThis类有一个主要的方法“like(int docNum)”,这个方法的参数还可以是File、InputStream、Reader或URL,返回值是一个Query对象,MoreLikeThis类的构造函数即MoreLikeThis(IndexReader ir),它需要传进一个IndexReader。下面举例说明like 方法的用法。就拿第一段的需求为例: public static void main(String[] a) throws Throwable { String indexName = "indexpath"; IndexReader r = IndexReader.open(indexName); PrintStream o = System.out; o.println("Open index " + indexName + " which has " + r.numDocs() + " docs"); MoreLikeThis mlt = new MoreLikeThis(r); mlt.setMaxQueryTerms(5); o.println("Query generation parameters:"); o.println(mlt.describeParams()); o.println(); String keygoodid = ""; String similarGoodsid = ""; String keygoodName = ""; String similarGoodsName = ""; if(!r.isDeleted(100)){ - - 用户界面设计与实现 Document keyDoc = r.document(j); keygoodid = keyDoc.get("id"); keygoodName = keyDoc.get("name"); Query query = mlt.like(100); IndexSearcher searcher = new IndexSearcher(indexName); Hits hits = searcher.search(query); int len = hits.length(); for (int i = 0; i < Math.min(5, len); i++) { Document d = hits.doc(i); similarGoodsid += d.get("id") + ","; similarGoodsName += d.get("name") + ","; } o.println("keygoodid:" + keygoodid + "|" + "similarGoodsid:" + similarGoodsid); o.println("keygoodName:" + keygoodName + "|" + "similarGoodsName:" + similarGoodsName); } Indexpath下面存放的是对所有商品的索引,构造一个MoreLikeThis的对象mlt,然后调用mlt.like(100),这里的100是Lucene内部的docNum。然后搜索一下,取前几个结果就是与此doc最为相似的。 like(int docNum)方法返回的Query是怎么产生的呢?它首先根据传入的docNum找出该文档里去除停用词后的高频词,然后用这些高频词生成Queue,最后把Queue传进 search方法得到最后结果。主要思想就是认为这些高频词足以表示文档信息,然后通过搜索得到最后与此doc类似的结果。 缺省的MoreLikeThis没有定义停用词,也不支持中文分词,这都是需要进一步完善的。 为了把这个功能集成到界面,可以修改查询语法,当用户输入“related:doc_id”的时候返回索引库中的相关文档。 7.8 实现AJAX搜索联想词 搜索输入框中的下拉提示给用户一个有参考意义的搜索词表,有时候还提供用户搜索该词预期的结果数量。这个功能有时候也叫做自动完成(AutoCompleter)。这个功能一般是由浏览器端的AJAX代码完成的。 搜索词表可以从用户搜索日志中统计出来,搜索次数多的词排在前面。除了按一般的设计,为每个用户提供统一的词表,还可以对每个用户提供个性化的推荐词表。例如:用户输入“汽”时,“汽车”的搜索次数比“汽油发电机”多,所以排在提示词列表的前面。如果搜索日志比较少,无法挖掘出足够多的推荐搜索词,可以考虑从文本中挖掘一些关键词作为推荐搜索词。 - - 用户界面设计与实现 因为后台索引一般都在不断的变化,推荐搜索词右侧显示的“**结果”并不是实时搜索出的结果,只是一个估计值,只具有参考价值。为了实现搜索提示效果,需要用到词典的前缀匹配。 7.8.1 估计查询词的文档频率 为了对于用户输入任何词都可以显示一个估计的搜索结果数量,需要计算这个词的文档频率。例如用户输入“NBA直播”,可以根据“NBA”和“直播”的文档频率估计“NBA直播”的文档频率。假设“NBA”和“直播”之间的出现没有依赖关系,则可以简化计算如下: 这里的是联合概率,可以认为是“NBA直播”的出现概率,而和是每个词出现的概率。假设索引中的总文档数量是N,而 = Freq(“NBA”)/N,= Freq(“直播”)/N。 因此“NBA直播”的文档频率 而Freq(“NBA”)和(Freq(“直播”)所代表的文档频率在Lucene中可以通过org.apache.lucene.search.Searcher 的 docFreq(Term term) 方法得到。因为多个词之间并不一定满足独立出现的假设,因此这个估计值有可能偏低。 7.8.2 搜索联想词总体结构 当用户在浏览器的输入框输入查询词时,JavaScript代码捕获用户即时输入的数据并向服务器发送请求。自动完成功能的总体结构如图7-6所示: - - 用户界面设计与实现 Web服务器 浏览器 AutoCompleteServlet Autocomplete插件 JSON格式数据 图7-6 自动完成功能总体结构 如果在使用,则不要让Struts2的FilterDispatcher把所有的URL都拦截了。 struts2 org.apache.struts2.dispatcher.FilterDispatcher struts2 *.action 不推荐使用Struts2的JSON插件(https://cwiki.apache.org/S2PLUGINS/json-plugin.html),因为有点过度封装。 7.8.3 服务器端处理 当用户输入一个搜索字的同时由Web服务器中的一个Servelet(AutoCompleteServlet)从后台取数。可以直接在内存中管理查询,而不是访问数据库取数。先设计词典格式。它是三列组成,第一列是词,第二列是搜索返回结果数量,第三列是用户搜索次数,中间用%隔开,例如: 综合教程第一册%34%2 搜索词是“综合教程第一册”,搜索返回结果数量是34,用户搜索了2次。这样把用户搜索次数多的关键词放在前面优先显示。我们构造一个在“词典查找算法”部分已经介绍过的“Trie”树词典来实现快速的前缀匹配查找: /** - - 用户界面设计与实现 * 返回以一个前缀开始的所有关键词的数组 * *@param prefix 前缀 *@param numReturnValues 返回数组的最大长度 *@return 返回数组结果 */ public TSTItem[] matchPrefix(String prefix, int numReturnValues) { TSTNode startNode = getNode(prefix); if (startNode == null) { return null; } ArrayList sortKeysResult = new ArrayList(); ArrayList wordTable = sortKeysRecursion( startNode.EQKID, ((numReturnValues < 0) ? -1 : numReturnValues), sortKeysResult); int retNum = Math.min(numReturnValues,wordTable.size()); Select.selectRandom(wordTable,wordTable.size(),retNum,0); TSTItem[] fullResults = new TSTItem[retNum]; for(int i=0;i sortKeysRecursion( - - 用户界面设计与实现 TSTNode currentNode, int sortKeysNumReturnValues, ArrayList sortKeysResult2) { if (currentNode == null) { return sortKeysResult2; } ArrayList sortKeysResult = sortKeysRecursion( currentNode.LOKID, sortKeysNumReturnValues, sortKeysResult2); if (currentNode.data != 0) { sortKeysResult.add( new TSTItem(getKey(currentNode), currentNode.data, currentNode.weight) ); } sortKeysResult = sortKeysRecursion( currentNode.EQKID, sortKeysNumReturnValues, sortKeysResult); return sortKeysRecursion( currentNode.HIKID, sortKeysNumReturnValues, sortKeysResult); } 可以写一个简单的测试代码: public static void main(String[] args) { SuggestDic sugDic = SuggestDic.getInstance(); - - 用户界面设计与实现 String prefix = "m"; TSTItem[] ret = sugDic.matchPrefix(prefix, 10); for(TSTItem i:ret ) { System.out.println(i.key+":"+i.data+":"+i.weight); } } 服务器传递给客户端的JSON数据格式是一个数组,例如: ["lietu","lucene"] 因为JSON格式是一个比较简单的数据传输格式,所以采用了JSON.org提供的一个简单的生成包。 JSONArray jsonarray = new JSONArray(); jsonarray.put("lietu"); jsonarray.put("lucene"); System.out.println(jsonarray.toString()); 通过 Servlet输出 JSON 时,需要设置正确的 MIME 类型(application/json)和字符编码。假定服务器使用 UTF-8 编码,则可以使用以下代码输出编码后的 JSON 文本: response.setContentType("application/json;charset=UTF-8"); response.setCharacterEncoding("UTF-8"); 这样自动完成的Servlet类可以写成下面这样: String val = request.getParameter("term"); //取得参数term的值 StringBuilder message = new StringBuilder("
    "); try { SuggestDic sugDic = SuggestDic.getInstance(); TSTItem[] ret = sugDic.matchPrefix(val, 10); for(TSTItem i:ret ) { String Word = i.key; String Count = String.valueOf(i.data); message.append("
  • "); message.append(Word); message.append("
    "); - - 用户界面设计与实现 message.append(Count); message.append(" 结果
  • "); } } catch (Exception e) { e.printStackTrace(); } message.append("
"); response.setContentType("application/json; charset=utf-8"); response.setCharacterEncoding("utf-8"); PrintWriter out = response.getWriter(); out.println(message.toString()); 把AutoCompleteServlet通过web.xml部署到URL地址“/autoComplete”。 AutoCompleteServlet com.lietu.autocomplete.AutoCompleteServlet AutoCompleteServlet /autoComplete 服务器端修改成: public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException { String val = request.getParameter("q"); String message = null; SuggestItem[] items = null; try { SuggestDic sugDic = SuggestDic.getInstance(); items = sugDic.matchPrefix(val, 10);//查找trie树 - - 用户界面设计与实现 JSONValue lMyValue = JSONMapper.toJSON(items); message = Escape.toUnicodeEscapeString( lMyValue.render(false) ); } catch (Exception e) { e.printStackTrace(); } response.setContentType("text/html; charset=utf-8"); PrintWriter out = response.getWriter(); if(message!=null) { out.println(message); } else { out.println(""); } } 可以通过EasyMock测试这个Servlet的返回值: String queryWord = "P"; //录制mock对象 HttpServletRequest request = createMock(HttpServletRequest.class); HttpServletResponse response = createMock(HttpServletResponse.class); ServletConfig servletConfig = createMock(ServletConfig.class); ServletContext servletContext = createMock(ServletContext.class); AutoCompleteServlet instance = new AutoCompleteServlet(); //初始化servlet,一般由容器承担,用servletConfig作为参数初始化,此处模拟容器行为 instance.init(servletConfig); //在某些方法被调用时设置期望的返回值, //如下这样就不会去实际调用servletConfig的getServletContext方法,而是直接返回 //servletContext,由于servletConfig是mock出来的,所以可以完全控制。 expect(servletConfig.getServletContext()).andReturn(servletContext).anyTimes(); expect(request.getParameter("q")).andReturn(queryWord); PrintWriter pw=new PrintWriter(System.out,true); - - 用户界面设计与实现 expect(response.getWriter()).andReturn(pw).anyTimes(); response.setContentType("text/html; charset=utf-8"); //重放mock对象 replay(request); replay(response); replay(servletConfig); replay(servletContext); instance.doPost(request, response); pw.flush(); //检查预期和实际结果 verify(request); verify(response); verify(servletConfig); verify(servletContext); 返回一个JSON数组格式的数据,如果要支持中文,还要对汉字编码。 7.8.4 浏览器端处理 剩下的就是在前台通过AJAX组件库 jQuery中的Autocomplete插件(http://jqueryui.com/demos/autocomplete/)来完成显示了。 先到官方网站下载jQuery的最新版本。然后需要jQuery UI的三个核心组件:Core、Widget、Position,还有AutoComplete插件。 将插件中的JavaScript文件和css文件分别置于Web项目中的js文件夹和css文件夹中。最后将这些文件导入到需要搜索联想词的页面,一般是搜索首页和搜索结果页面。也就是网页的头信息中包含这些文件。 - - 用户界面设计与实现 AutoComplete插件与一个HTML的Input标签相结合。 现在网页加一个输入标签: 注意对输入标签来说需要增加autocomplete="off"。如果不加,浏览器可能不会提交HTTP请求到后台。 当DOM(文档对象模型)已经加载,并且页面(包括图像)已经完全呈现时,会发生ready事件。在jQuery中,使用$(function)定义ready事件的处理函数。 在ready事件中增加autocomplete控件: 通过AJAX方式取得数据,访问后台url地址位于“./autoComplete”的数据源的代码: 它自动传递一个叫做"term"的参数,这个参数中包括"query"输入框中的值。例如,当用户在搜索框输入o这个字母,浏览器就会发送下面这个请求给服务器: HTTP GET autoComplete?term=o 服务器传递给客户端的JSON数据格式是一个搜索词组成的数组,例如: ["open office","online backup","opera","onkyo",...] 用户选择词后,一般直接跳转到这个词的搜索结果,而不是只在输入框显示这个搜索词,之后用户需要再次按搜索按钮才返回搜索结果。有时候需要支持用户选择提示词直接跳转到搜索结果页面。在JavaScript中,通过location.href实现跳转。 - - 用户界面设计与实现 用户选择提示词后直接搜索的实现如下: $(function() { $("#query").autocomplete({ source: "./autoComplete", minLength: 2, select: function( event, ui ) { window.location.href=“./searchAction.action?query=" + ui.item.value; } }); }); 在这里,通过ui.item.value得到用户选择的搜索词。 如果不能正常工作,首先看浏览器是否已经发送HTTP请求。然后再看Servlet返回的结果。为了方便跟踪错误,可以使用FireFox中的Firebug插件调试网页中的JavaScript代码。Firebug中,可以为JavaScript设置断点,可以暂停执行JavaScript并且看到每个变量的当前值。如果代码速度慢,还可以通过JavaScript配置器看性能,快速发现性能瓶颈。 7.8.5 拼音提示 为了支持汉语拼音感应,需要把所有的词生成出拼音列。Trie树可以看成关键词和值的映射。拼音列和词本身都可以做为关键词,值这一列则存放词原型。例如对于“厦门”这个词,会存储两个关键词和值的映射。 xiamen -> 厦门 厦门 -> 厦门 这样当用户输入“厦”或“xia”都可能提示出“厦门”这个词。 对于基本的中文词提示来说,关键词和值都是一样。另外,注音程序把中文词转换成拼音,这部分数据支持汉语拼音感应功能。 因为存在多音字,按词注音会有好的结果。可以在Trie树的值域中存储一个词对应的拼音。 public static String yin(String sentence){//传入一个字符串作为要处理的对象。 int senLen = sentence.length();//首先计算出传入的这句话的字符长度 int i = 0;//用来控制匹配的起始位置的变量 - - 用户界面设计与实现 StringBuilder result = new StringBuilder(senLen); TernarySearchTrie.MatchRet matchRet = new TernarySearchTrie.MatchRet("",0); while (i < senLen){// 如果i小于此句话的长度就进入循环 boolean match = dic.matchLong(sentence, i, matchRet);//正向最大长度匹配 if (match){//已经匹配上,按词注音 i = matchRet.end; result.append(matchRet.data); } else//如果没有找到匹配上的词,就按单字注音 { result.append(ziYin.zi2Yin(sentence.charAt(i))); ++i;// 下次匹配点在这个字符之后 } } return result.toString(); } 7.8.6 部署总结 提示词词典suggestDic.txt可以放在 WEB-INF/classes/dic/ 路径。AutoCompleteServlet可以放在 WEB-INF/lib/路径,通过web.xml发布。界面用到的JavaScript脚本jquery.js 、jquery.ajaxQueue.js、jquery.autocomplete.css和jquery.autocomplete.js可以放在 ROOT/js 路径。 7.9 集成其他功能 搜索引擎用户界面的一些功能还有:对用户输入的拼写纠错提示,搜索结果的分类统计,根据用户搜索词返回相关搜索词,在搜索结果中再次查找,记录和统计搜索日志。 7.9.1 拼写检查 因为用户的查询本身是一个符合查询语法的字符串,所以不能把用户的查询本身直接输入给拼写检查模块,而要通过一个didYouMeanParser给出这个提示。在Lucene中使用拼写检查的例子: String indexDir = "D:/lg/work/nextag/indexdir"; Directory directory = FSDirectory.open(new File(indexDir)); IndexSearcher searcher = new IndexSearcher(directory, true); //只读方式打开索引 String defaultField = "title"; - - 用户界面设计与实现 String queryString ="代款"; Analyzer analyzer = new StandardAnalyzer(Version.LUCENE_31); QueryParser parser = new QueryParser(Version.LUCENE_31, defaultField, analyzer); Query query = parser.parse(queryString); ScoreDoc[] hits = searcher.search(query, 1000).scoreDocs; //原始查询 searcher.close(); String suggestedQueryString = null; //提示查询的字符串 int minimumHits=5; float minimumScore=0.6f; //如果搜索返回结果的数量小于阈值或者匹配第一个结果的分值小于最小值就查找提示词 if (hits.length < minimumHits || hits[0].score < minimumScore) { CompositeDidYouMeanParser didYouMeanParser = new CompositeDidYouMeanParser(defaultField); Query didYouMean = didYouMeanParser.suggest(queryString); //调用拼写检查算法 if (didYouMean != null) { suggestedQueryString = didYouMean.toString(defaultField); } } System.out.println("您是不是要找:"+suggestedQueryString); 实现一个复杂的DidYouMeanParser: public class CompositeDidYouMeanParser implements DidYouMeanParser { private class QuerySuggester extends QueryParser { private boolean suggestedQuery = false; //是否有推荐的查询对象 public QuerySuggester(String field, Analyzer analyzer) { super(Version.LUCENE_31, field, analyzer); } - - 用户界面设计与实现 protected Query getFieldQuery(String field, String queryText, boolean quoted) throws ParseException { TokenStream source = getAnalyzer().tokenStream(field, new StringReader(queryText)); CharTermAttribute termAtt = source .getAttribute(CharTermAttribute.class); Vector v = new Vector(); boolean hasMoreTokens = false; try { hasMoreTokens = source.incrementToken(); while (hasMoreTokens) { String term = termAtt.toString(); v.addElement(term); hasMoreTokens = source.incrementToken(); } } catch (IOException e) { } try { source.close(); } catch (IOException e) { // ignore } if (v.size() == 0) return null; else if (v.size() == 1) return new TermQuery(getTerm(field, v.elementAt(0))); else { PhraseQuery q = new PhraseQuery(); q.setSlop(getPhraseSlop()); for (int i = 0; i < v.size(); i++) { q.add(getTerm(field, (String) v.elementAt(i))); } return q; } - - 用户界面设计与实现 } private Term getTerm(String field, String queryText) throws ParseException { String aux = spellCheck.findMostSimilar(queryText); if (aux == null || aux.equals(queryText)) { return new Term(field, queryText); } suggestedQuery = true; return new Term(field, aux); } public boolean hasSuggestedQuery() { return suggestedQuery; } } private String defaultField; //缺省搜索列 public static SpellChecker spellCheck = null; //拼写检查类 public CompositeDidYouMeanParser(String defaultField, String dicFile, //正确词典类 String commonMisspellingsFile //正误词表 ) throws Exception { this.defaultField = defaultField; spellCheck = new SpellChecker(); spellCheck.initialize(dicFile, commonMisspellingsFile); } public Query parse(String queryString) throws ParseException { QueryParser queryParser = new QueryParser(Version.LUCENE_31, defaultField, new WhitespaceAnalyzer(Version.LUCENE_31)); queryParser.setDefaultOperator(QueryParser.AND_OPERATOR); return queryParser.parse(queryString); } public Query suggest(String queryString) throws ParseException { - - 用户界面设计与实现 QuerySuggester querySuggester = new QuerySuggester(defaultField, new WhitespaceAnalyzer(Version.LUCENE_31)); querySuggester.setDefaultOperator(QueryParser.AND_OPERATOR); Query query = querySuggester.parse(queryString); return querySuggester.hasSuggestedQuery() ? query : null; } } 设计一个供界面调用的Bean,根据输入的查询结果返回、缺省查询列和查询字符串判断是否给出提示词。 public class DidYouMean { static String defaultField; static int minimumHits=5; static float minimumScore=0.6f; public DidYouMean(String f) { defaultField = f; } public String getSuggest(TopDocs hits,String queryString) throws Exception { String suggestedQueryString = null; //提示查询的字符串 //如果搜索返回结果的数量小于阈值 //或者匹配第一个结果的分值小于最小值就查找提示词 if (hits.totalHits < minimumHits || hits.scoreDocs [0].score < minimumScore) { CompositeDidYouMeanParser didYouMeanParser = new CompositeDidYouMeanParser(defaultField); Query didYouMean = didYouMeanParser.suggest(queryString); //调用拼写检查算法 if (didYouMean != null) { suggestedQueryString = didYouMean.toString(defaultField); } } return suggestedQueryString; } } 在Jsp页面使用这个类: - - 用户界面设计与实现 <% suggestBean = new DidYouMean("title"); %> 您是不是要找:<%= suggestBean.getSuggest(hits,queryString) %> 如果返回结果数量很少,可以直接把提示词的搜索结果放在原查询词返回结果的下面。 7.9.2 分类统计 首先定义分类统计信息类: public class CatInf implements Comparable { public String name;// 分类名 public int count; //类别数量 public int compareTo(CatInf obj){ return (int)(obj.count - this.count); } } 为了保证分类统计和查询的结果一致性,需要共用一个查询对象。实现分类统计的方法声明如下: ArrayList factedCounter(IndexSearcher searcher, Query q) 二级子树展开的效果图如图7-4所示: 图7-4 二级子树的展开效果 节点类定义如下: public class CatNode { public int no; //编码 public String name; //节点名 public boolean isLeaf; //是否页节点 public List children = null; //孩子节点 - - 用户界面设计与实现 public CatNode parent; //父节点 public int level; //级别 public CatNode(int no, String name,CatNode parentNo,int l, boolean isLeaf) { this.no = no; this.name = name; this.parent = parentNo; this.level = l; this.isLeaf = isLeaf; if (!isLeaf) { children = new ArrayList(5); } } public void addChildren(CatNode node) throws Exception { if (isLeaf) { throw new Exception("add child error to leaf node:" + no); } children.add(node); } public String toString() { String temp = this.name; for (CatNode child : children) { temp += "\n"+ "child:"+ child.no +":"+child.name ; } temp += "\n"; return temp; } } 用于查找父子节点的映射表: public class CategoryMap extends HashMap { public CategoryMap(){ //构造方法 String sql = "SELECT ID,isnull(FATHER_ID,0) FATHER_ID,CAT_NAME,CAT_LEVEL "+ " FROM DP_DISPLAY order by ID" ; - - 用户界面设计与实现 PreparedStatement stmt = con.prepareStatement(sql); ResultSet rs = stmt.executeQuery(); CatNode thisNode = new CatNode(0,"ROOT",null,0,false); this.put(0, thisNode ); while(rs.next()) { int code = rs.getInt("ID"); String name = rs.getString("CAT_NAME"); int fatherID = rs.getInt("FATHER_ID"); int level = rs.getInt("CAT_LEVEL"); boolean isLeaf = true; CatNode parentNode = this.get(fatherID); thisNode = new CatNode(code,name,parentNode,level,isLeaf); if(parentNode.isLeaf) { parentNode.isLeaf = false; parentNode.children = new ArrayList(5); } parentNode.children.add(thisNode); this.put(code, thisNode ); } } } 搜索页面用于计数的类: public class CountNode { public int no;// 分类号 public String name;// 分类名 public boolean isLeaf;// 是否为末级 public List children = null; public CountNode parent; public int count; - - 用户界面设计与实现 public CountNode(int no, String name,CountNode parentNo,boolean isLeaf) { this.no = no; this.name = name; this.parent = parentNo; this.isLeaf = isLeaf; if (!isLeaf) { children = new ArrayList(5); } } @Override public boolean equals(Object o) { if(o instanceof CountNode) { CountNode t = (CountNode)o; return (t.no == this.no); } return false; } @Override public int hashCode(){ return this.no; } } 二级子树展开的实现: //搜索形成的二级子树 HashMap cat1Set = new HashMap(); for (Count c : facetCounts) { Integer cat2Id = Integer.parseInt(c.getName()); CatNode cat2Node = catMap.get(cat2Id); CountNode newParen = cat1Set.get(cat2Node.parent.no); if (limitCat > 0) { if (cat2Node.parent.no != limitCat) { continue; - - 用户界面设计与实现 } } if (newParen == null) { newParen = new CountNode(cat2Node.parent.no, cat2Node.parent.name, null, false); CountNode childNode = new CountNode(cat2Node.no, cat2Node.name, newParen, true); childNode.count = c.getCount(); newParen.children.add(childNode); cat1Set.put(newParen.no, newParen); } else { CountNode childNode = new CountNode(cat2Node.no, cat2Node.name, newParen, true); childNode.count = c.getCount(); newParen.children.add(childNode); } } 使用MatchAllDocsQuery返回所有的记录,然后再分类统计,这样可以实现一个分类导航的页面。 有个专门实现分类统计功能的项目bobo-browse(http://code.google.com/p/bobo-browse/)。不过它需要集成Spring框架,用起来麻烦一点。 然后在页面执行搜索时执行如下过程: /** * @param cat 当前类别编号 * @param q 当前查询 * @return 分类统计列表 * @throws Exception */ public List catCounter(int cat,Query q) throws Exception { CategoryNode thisNode = ListContainer.catMap.get(String.valueOf(cat)); if(thisNode.isLeaf) { //已经到达最后一级,不能再展开统计 return null; } - - 用户界面设计与实现 List children = thisNode.children; if(children == null) { return null; } ArrayList catList = new ArrayList( children.size() ); DocSetHitCollector all = new DocSetHitCollector(reader.maxDoc()); searcher.search(q, all ); DocSet allDocSet = all.getDocSet(); String termField = null;//层次列 if (cat<=0) { termField = "hs1";//第一层的ID号存储在hs1列 } else if (cat<100) { termField = "hs2";//第二层的ID号存储在hs2列 } //如果还有后续层,则按前缀匹配来搜 for (int i=0;i0) { CatInf catInf = new CatInf(); catInf.name = currentNode.name; catInf.no = currentNode.no; catInf.count = count; catList.add(catInf); } } Collections.sort(catList);//对分类统计结果排序后输出 return catList; - - 用户界面设计与实现 } 可以通过类别编码来控制是否按类别查找,但不推荐这样实现。为了实现REST风格的链接,可以直接根据类名按条件查询,例如cat=Music。有些类名可能包含特殊的符号,例如:“Health & Beauty”。可以使用URLEncoder类对类名中的特殊符号转义,例如:把空格转换成加号。代码如下: URLEncoder.encode(catName,"UTF-8"); 在TagLib中输出在table标签中的分类统计结果: public String getCatView() { if(_catList == null) return ""; StringBuffer output = new StringBuffer(); output.append(""); output.append(" "); int count =0 ; for(CatInf e : _catList) { output.append(""); if(count%3 ==2) { output.append(""); } count++; } output.append("
"); output.append(e.name); //类别名 output.append("("); output.append(e.count); //该类别下的文档数量 output.append(")
"); - - 用户界面设计与实现 return output.toString(); } 最后JSP界面通过Tag调用getCatView: 7.9.3 相关搜索 一种方法是从搜索日志中挖掘字面相似的词作为相关搜索词列表。首先从一个给定的词语挖掘多个相关搜索词,可以用编辑距离为主的方法查找一个词的字面相似词,如果候选的相关搜索词很多,就要筛选出最相关的10个词。下面是利用Lucene筛选最相关词的方法。 private static final String TEXT_FIELD = "text"; /** * * @param words 候选相关词列表 * @param word 要找相关搜索词的种子词 * @return * @throws IOException * @throws ParseException */ static String[] filterRelated(HashSet words, String word) { StringBuilder sb = new StringBuilder(); for(int i=0;i relatedWords com.bitmechanic.listlib.RelatedTag index false true url false true query - - 用户界面设计与实现 true true 最后在JSP页面中引用标签: 7.9.4 再次查找 经常需要从结果中缩小范围再次查找信息。一个实现方法是通过+连接符连接上次查询和当前查询。例如:inputstr记录了上次查询词,queryString记录当前查询词,实现代码如下: if (refind) //如果需要再次查找 queryString = " + ("+queryString+") + ("+inputstr+")"; 使用这个新的查询词就可以实现再次搜索的功能。 7.9.5 搜索日志 搜索日志是用来分析用户搜索行为和信息需求的重要依据。一般记录如下信息: l 搜索关键字; l 用户来源ip; l 本次搜索返回结果数量; l 搜索时间; l 其他需要记录的应用相关信息。 IP地址是最容易获取的信息,但其局限性也较为明显:伪IP、代理、动态IP、局域网共享同一公网IP出口……这些情况都会影响基于IP来识别用户的准确性,所以IP识别用户的准确性比较低,目前一般不会直接采用IP来识别用户。 可以通过Cookie记录用户ID。Cookie是从用户端存放的Cookie文件记录中获取的,这个文件里面一般在包含一个Cookieid的同时也会记下用户在该网站的userid(如果你的网站需要注册登陆并且该用户曾经登录过你的网站且Cookie未被删除),所以在记录日志文件中cookie项的时候可以优先去查询Cookie中是否含有用户ID类的信息,如果存在则将用户ID写到日志的Cookie项,如果不存在则查找是否有Cookieid,如果有则记录,没有则记为 - - 用户界面设计与实现 ”-”,这样日志中的Cookie就可以直接作为最有效的用户唯一标识符被用作统计。当然这里需要注意该方法只有网站本身才能够实现,因为用户ID作为用户隐私信息只有该网站才知道其在Cookie的设置及存放位置,第三方统计工具一般很难获取。 通过以上的方法实现用户身份的唯一标识后,我们可以通过一些途径来采集用户的基础信息、特征信息及行为信息,然后为每位用户建立起详细的Profile,具体途径有: 1) 用户注册时填写的用户注册信息及基本资料; 2) 从网站日志中得到的用户浏览行为数据; 3) 从数据库中获取的用户网站业务应用数据; 4) 基于用户历史数据的推导和预测; 5) 通过直接联系用户或者用户调研的途径获得的用户数据; 6) 有第三方服务机构提供的用户数据。 通过用户身份识别及用户基本信息的采集,我们可以通过网站分析的各种方法在网站实现一些有价值的应用: l 基于用户特征信息的用户细分; l 基于用户的个性化页面设置; l 基于用户行为数据的关联推荐; l 基于用户兴趣的定向营销。 为了不影响即时搜索的速度,一般不把搜索日志记录直接记录在数据库中,而是写在文本文件中。推荐使用logback(http://logback.qos.ch/)的日志功能实现。Logback 提供了三个jar包Core、classic、access。其中Core是基础,其他两个包依赖于这个包。logback-classic是SLF4J原生的实现。所以你可以用其他logging系统去替换它。当然logback-classic依赖于slf4j-api。logback-access与servlet容器集成。提供http-access的log功能。SLF4J(http://www.slf4j.org/)几乎已经称为业界日志的统一接口。 这里的项目一共需要三个包:slf4j-api-1.6.1.jar、logback-classic-0.9.21.jar和logback-core-0.9.21.jar。Logback通过logback.xml进行配置。 这里把当前日志写到:D:/logs/log 文件中,新的一天日志开始的时候,昨天的日志生成一个新文件。 在搜索类中初始化日志类: private static Logger logger = LoggerFactory.getLogger(SearchBbs.class); 然后当用户执行一次搜索时,记录查询词、返回结果数量、用户IP以及查询时间等: logger.info(_query+"|"+desc.count+"|"+"bbs"+"|"+ip); 日志文件log.txt记录的结果例子如下: - - 用户界面设计与实现 什么是新生儿|37|topic|124.1.0.0|2007-11-21 12:25:36 什么是新生儿|28|bbs|124.1.0.0|2007-11-21 12:25:42 怀孕|18|topic|124.1.0.0|2007-11-21 12:26:05 怀孕|2|shangjia|124.1.0.0|2007-11-21 12:26:05 怀孕|145|bbs|124.1.0.0|2007-11-21 12:26:06 怀孕|18|topic|124.1.0.0|2007-11-21 12:30:33 这里,第一列是用户搜索词,第二列是搜索返回结果数量,第三列是搜索类别,第四列是IP地址,第五列是搜索的时间。 然后定义搜索日志统计表,例如我们需要统计搜索最多的词,可以把搜索最多的词放在keywordAnalysis表中: CREATE TABLE [keywordAnalysis] ( [searchTerms] [varchar] (50) NOT NULL ,--搜索词 [AccessCount] [int] NULL , --搜索计数 [Result] [int] NULL --该词返回结果数 ) 7.10 搜索日志分析 因为搜索关键词是用户输入的文本信息,所以可以从搜索日志中了解用户使用搜索的意图。有人把Google的搜索关键词排行榜称作人类意图数据库。这来源于对搜索日志的分析。 7.10.1 日志信息过滤 公开的搜索会有很多爬虫的访问。搜索日志中包括大量的Google爬虫信息,需要把它和普通用户的搜索区分出来。 可以从请求的信息中判断出是哪一种爬虫。例如,baidu爬虫的“User-Agent”信息: Baiduspider+(+http://help.baidu.jp/system/05.html) goolge爬虫的“User-Agent”信息: Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html) 下面是程序实现: String userAgent = request.getHeader( "User-Agent" ); public static String[] getBotName(String userAgent) { userAgent = userAgent.toLowerCase(); - - 用户界面设计与实现 int pos=0; String res=null; if ((pos=userAgent.indexOf("google/"))>-1) { res= "Google"; pos+=7; } else if ((pos=userAgent.indexOf("msnbot/"))>-1) { res= "MSNBot"; pos+=7; } else if ((pos=userAgent.indexOf("googlebot/"))>-1) { res= "Google"; pos+=10; } else if ((pos=userAgent.indexOf("webcrawler/"))>-1) { res= "WebCrawler"; pos+=11; } else if ((pos=userAgent.indexOf("inktomi"))>-1) { res= "Inktomi"; pos=-1; } else if ((pos=userAgent.indexOf("teoma"))>-1) { res= "Teoma"; pos=-1; } if (res==null) return null; return getArray(res,res,res + getVersionNumber(userAgent,pos)); } 7.10.2 信息统计 搜索热词的统计界面如图7-8所示: - - 用户界面设计与实现 图7-8 搜索热词的统计页面 按地区来源的搜索日志分析界面如图7-9所示: 图7-9 按地区来源的搜索日志分析页面 - - 用户界面设计与实现 按地区统计搜索词需要从用户的访问IP查询出对应的地址。如表7-1所示IP地址表记录了一个地区的IP范围: ip1 ip2 country city countryNo provinceNo 3740518268 3740518268 湖南省永州市 金鹰网吧 1 12 3740518269 3740518400 湖南省永州市 电信 1 12 3740518401 3740518401 湖南省永州市祁阳县 怡心苑网吧 1 12 3740518402 3740518417 湖南省永州市 电信 1 12 表7-1 IP地址表 下面的例子代码返回用户IP所在省: String ipin[] = getIp.split("\\."); long ipinfo[] = new long[4]; for (int i = 0; i < ipinfo.length; i++) { //从String类型的IP地址到long型的转换可以用查表法更快的实现 ipinfo[i] = Integer.parseInt(ipin[i]); } long num=ipinfo[0] * 256 * 256 * 256 + ipinfo[1] * 256 * 256 + ipinfo[2] * 256 + ipinfo[3] - 1; String sql = "select DIC_Province.caption from ip,DIC_Province where ip.provinceNo = DIC_Province.id and ip.ip1<=" + num + " and ip.ip2>= " + num + ""; st = con.createStatement(); rs = st.executeQuery(sql); if (rs.next()) { area = rs.getString(1); } 首先建立搜索日志统计表: CREATE TABLE SC_SEARCH_STAT ( ID NUMERIC(12 , 0) IDENTITY , // 自增长ID SEARCH_WORD VARCHAR(90) NULL, // 搜索词 SEARCH_NUM INT NULL, // 搜索次数 SEARCH_DATE DATE NULL, // 搜索日期 SEARCH_RESULT INT NULL // 搜索词的返回结果数量 ) 搜索统计表每天更新一次。每次把上一天的搜索日志文件中的数据统计后写入该表。搜索次数 SEARCH_NUM指一天内搜索SEARCH_WORD这个词的独立IP的统计数量,同日同地址同搜索词只算一次。搜索统计用到的主要数据结构有: HashMap> word2IP = - - 用户界面设计与实现 new HashMap>();//搜索词到IP的映射 HashMap word2ResultNum = new HashMap();//搜索词到搜索结果数的映射 … //统计信息 HashSet ips = word2IP.get(key);//如果搜索词已存在 if (ips!=null) { ips.add(strIP);//增加当前IP word2ResultNum.put(key, resultNum); } else if (resultNum > 0) {//如果搜索返回结果数大于零 ips = new HashSet(); ips.add(strIP); word2IP.put(key, ips); word2ResultNum.put(key, resultNum); } … //统计信息写入统计表 String sql = "insert into SC_SEARCH_STAT(SEARCH_WORD,SEARCH_NUM,SEARCH_DATE,SEARCH_RESULT) values(?,?,?,?)"; PreparedStatement pstmt = con.prepareStatement(sql); for (Entry> e : word2IP.entrySet()) { pstmt.setString(1, e.getKey()); pstmt.setInt(2, e.getValue().size()); pstmt.setString(3, yesDate); pstmt.setInt(4, word2ResultNum.get(e.getKey())); pstmt.executeUpdate(); } 7.10.3 挖掘日志信息 可以从不同的角度挖掘搜索日志。例如: l 挖掘单个词。可以挖掘出用户对查询语法的使用情况,例如:filetype、site等查询语法。可以统计用户搜索中使用空格分开多个词来搜索的比例。 - - 用户界面设计与实现 l 或者按用户会话(session)挖掘词。有用户先搜索“工程电磁学基础(第6版)”,没有结果返回,几秒钟后他换了一个搜索词“工程电磁学基础”,这次有22条结果返回。因此可以把用户对查询词的修改分为四种情况:减少查询词、增加查询词、部分替换查询词、完全更换查询词。另外,还可以统计用户搜索词之间的词序,考虑上一个词到下一个词之间的转移概率。 l 根据用户与词之间的关联可以挖掘出词与词之间的关联或者用户与用户之间的关联。观察到一个用户搜索“眉笔”后,又搜索了“粉底液”和“紧肤水”,这些词都是美容相关的商品名,所以考虑使用关联规则挖掘出相关词。可以根据用户的搜索词对用户分类,计算出用户对每个类别的隶属度。 7.11 部署网站 服务器端操作系统推荐采用Linux。Linux有各种版本,这里以CentOS为例,配置Java环境,修改脚本文件: #vi /etc/bashrc 增加如下行: export JAVA_HOME=/usr/local/jdk1.6.0_21 export PATH=$JAVA_HOME/bin:$PATH 从Tomcat网站下载tar.gz包。 #wget http://mirror.bjtu.edu.cn/apache/tomcat/tomcat-7/v7.0.14/bin/apache-tomcat-7.0.14.zip 解压缩: #tar -xf apache-tomcat-7.0.14.tar.gz 然后增加Tomcat内存,修改配置文件: #vi /usr/local/apache-tomcat-6.0.32/bin/catalina.sh 在文件的开始位置增加如下行: JAVA_OPTS=-Xmx1024m 修改Tomcat配置文件,修改端口号到80,并且支持UTF-8编码: #vi /usr/local/apache-tomcat-6.0.32/conf/server.xml - - 用户界面设计与实现 增加配置: useBodyEncodingForURI="true" URIEncoding="UTF-8" 压缩测试环境中的文件: #tar -cjf price.tar.bz2 ./price 在正式环境中下载压缩好的文件price.tar.bz2,然后解压缩文件: #tar -xjf price.tar.bz2 启动Tomcat: #startup.sh 察看Tomcat是否已经起来了: #pgrep -l java 或者使用命令: #ps -ef |grep java 服务器提供商可能提供这样的服务:当Tomcat服务停止的时候,会自动起来一个Apache显示错误信息。启动Tomcat之前,先停止Apache服务: #httpd -k stop 查看Apache服务是否已经停止了: #pgrep httpd 如果需要,可以给网站买一个好记的域名。在域名供应商购买域名后,增加DNS信息。如果修改DNS信息后,要清空本地的DNS缓存信息。Windows下使用如下的命令清空缓存: ipconfig /flushdns 7.12 本章小结 本章介绍了使用Java框架Struts2实现的搜索界面。并且介绍了很多重要而且基本的搜索功能界面实现,例如复杂条件搜索界面和用户输入提示词、分类查找界面等。 - - 用户界面设计与实现 因为有的浏览器禁用JavaScript,所以尽量用普通的HTML标签,只是在必要的时候才用JavaScript。使用FireFox中的Alexa插件查看网站访问量。 - - 用户界面设计与实现 第8章 使用Solr实现企业搜索 Lucene仅仅是一个全文检索包,不是一个独立的搜索服务。如果在异构环境直接调用Lucene可能会导致系统不稳定,例如开发Web界面的工具采用PHP时,采用桥接方式调用Lucene容易导致内存泄露。本章介绍的Solr搜索服务器就是一个支持多种前台开发语言的搜索框架。Solr提供了XML接口的全文检索服务,支持ASP.NET、JSP、PHP、Python等多种前台开发语言。 8.1 Solr简介 Solr把对Lucene索引的调用和管理做了一个REST风格的封装。它是一个Web方式的索引服务器。需要搜索的应用可以通过http请求调用Solr索引服务。可以把Solr看成类似MySql一样的数据库系统,在配置文件schema.xml中定义表结构。和用JDBC访问数据库不同,通过HTTP协议的POST方式发送需要增加的数据给Solr,通过HTTP协议的GET方式查询Solr管理的索引库。外部程序首先把HTTP请求提交给类似Tomcat或者Resin的Web服务器,然后Solr内部包含Servlet来写数据到索引库或者查询索引库。Solr的内部结构如图8-1所示。 XML Update Request Handler CSV Update Request Handler Extracting Update Request Handler (Solr Cell项目) Data Import Request Handler Update Request Processor Chain Update Processors Lucene Analyzer和Filter Chains IndexReader/Writer,Searcher Response Writer XSLT Response Writer JSON Response Writer Request Handler /admin /select /dismax Search Component Query Highlighting SpellCheck Faceting Cluster Statistics XML Response Writer 图8-1 Solr内部结构图 Solr是一个网站的内部系统演化出来的一个基于Lucene的开源项目。在2004年秋天,CNET启动Solr项目的前身Solar。2005夏天,CNET产品目录搜索开始使用Solar。2006年1月CNET把这个搜索项目代码捐赠给Apache并命名为Solr。2007年1月Solr毕业成为Lucene的子项目并发布1.2版本。2008年9月发布 - - 用户界面设计与实现 1.3.0版本。2009年11月发布1.4版本。Solr当前主要由Yonik维护代码,Yonik是斯坦福大学计算机专业硕士毕业。关于Solr的文档介绍在:http://wiki.apache.org/solr/。 单台机器的计算能力有限,可以采用Solr搭建多机集群的分布式搜索来实现高负载和高可用性。一个完整的分布结构如图8-1所示: 动态网页生成 负载均衡器 Web服务器 updater 数据库 Solr搜索服务器费 索引复制 更新 Solr 主服务器 管理终端 更新 管理员查询 Http 搜索请求 图8-1 Solr的完整分布结构图 本书中介绍的Solr是1.4版本,从Solr的官方网站http://lucene.apache.org/solr/可以下载到这个版本和当前最新的版本。 8.2 Solr基本用法 Solr自身由服务器端和客户端组成。服务器端提供搜索服务,客户端提供Web界面开发支持。 - - 用户界面设计与实现 8.2.1 Solr服务器端的配置与中文支持 可以把Solr的服务器端看成是包装在Web应用服务器中的Lucene服务器。和Lucene索引库相比的好处是可以实现缓存预加载。Web应用服务器通常可以选用Resin或者Tomcat。 首先保证Web服务器的内存使用量。以Resin的3.1版本为例,修改resin.conf中关于JVM的配置,增加最大内存使用量到500M: -Xmx500m 服务器主要需要通过JNDI中的solr/home来指定Solr存储库的路径。例如,在Resin中的resin.conf的内容如下: solr/home java.lang.String ${resin.home}/solr 从分布路径上来说,Solr由两部分组成。一部分是后台路径,可以放在Web应用服务器的根路径下的solr子路径下,其中的conf子路径存放配置文件,其中的data子路径存放索引数据;还有一部分是Web管理界面,存放在webapps目录下。Tomcat的最简单配置如图8-2所示: 图8-2 Tomcat的最简单配置 直接把Solr配置路径拷贝到Tomcat 的子目录下就可以。初次配置Solr的时候,建议在命令行运行Tomcat,这样容易发现错误。 - - 用户界面设计与实现 另外也可以通过Java虚拟机的系统属性solr.solr.home来指定存储库路径。 #export JAVA_OPTS="$JAVA_OPTS -Dsolr.solr.home=/my/custom/solr/home/dir/" 有时候需要建立好几个不同格式的索引。例如垂直行业搜索中需要分别搜索产品和公司,需要把产品信息和公司信息放在不同的索引格式中。可以同一个Tomcat下可以配置多个Solr实例来对应不同的索引格式。例如要配置两个Solr实例:company和product。可以在/usr/local/tomcat55/conf/Catalina/localhost 路径下建立两个配置文件:company.xml和 product.xml,分别对应两个Web应用,其中product.xml的内容如下: #cat ./product.xml 和部署于同一个Web服务器中的多个Web应用不同,Solr中的"multi core"功能指在同一个Web应用中管理多个索引。在并发量很少的情况下,"multi core"在性能上有优势,但当并发量增加后,"multi core"响应速度反而比标准配置慢。因为"multi core"用的较少,所以这里不介绍。 为了支持中文,Tomcat中的相关配置如下,在server.xml中增加对UTF-8的处理,这样是为了支持HTTP的GET方法: useBodyEncodingForURI="true" 设置这个值的意思是用页面的编码去处理POST请求。另外在Solr的Web项目的web.xml配置中增加对UTF-8的转码: Set Character Encoding filters.SetCharacterEncodingFilter encoding utf-8 Set Character Encoding /* 增加这个Filter是为了支持HTTP的POST方法。SetCharacterEncodingFilter类可以在 - - 用户界面设计与实现 webapps\examples\WEB-INF\classes路径下找到。 Solr中有两个在conf路径下的配置文件,分别是: l solrconfig.xml:用来配置Solr运行的系统参数,例如,缓存,插件等。 l schema.xml:主要定义索引结构相关的信息,例如,types、fields和其他的一些缺省设置。 然后需要调整的是solrconfig.xml。 4 自动加载缓存的线程数缺省是4,可以调整小一点,最好少于CPU核数量。否则可能使一个双CPU双核的机器CPU使用率达到近100%。 schema.xml配置文件定义了Solr中索引库的结构,可以把它类比作数据库中的一张表。可以通过field来定义列,fieldType定义列类型,uniqueKey指定索引库的唯一标识列(类似数据库表中的主键),defaultSearchField指定默认搜索列。schema.xml使用的基本的数据类型如下: //整型 //长整型 //浮点数 //双精度 //日期 和Lucene比较起来,这些数据类型有更优化的存储方式。 下面定义一个基本的文字搜索列类型。 CnTokenizerFactory 这里的CnTokenizerFactory是一个分词类,它扩展了BaseTokenizerFactory。当然也可以定义自己的做Tokenizer的类。所有这样的类都必须是BaseTokenizerFactory的子类。 package org.apache.solr.analysis; public class CnTokenizerFactory extends BaseTokenizerFactory{ public TokenStream create(Reader input) { return new CnTokenizer(input); } } - - 用户界面设计与实现 在schema.xml中定义了索引库的结构,下面定义三列,分别是唯一id,标题和内容。 在这里,multiValued="false"的意思是一列只允许存储一个值。如果需要一列存储多个值,例如一个商品属于多个类别(cat),则定义: 定义id为唯一标识列(id即为上面定义的名字是id的列)。 id 定义body为默认搜索列。 body 它的服务器端管理界面包含Solr和索引库中的一些宏观的参数。相比较而言,Luke则显示更细一些的Document和Term等信息。http://localhost/solr/admin/是一个已经安装好的Solr界面。可以先通过http:// localhost/solr/admin/analysis.jsp?highlight=on来对每个列做基本的测试,看看对定义好的title列是否能对中文做正确的处理。 有时候需要调试某个查询词是否能匹配上某个文档内容。可能会困惑于一个查询词不能匹配上某个文档。就好像一把钥匙打不开一把锁。所以可以把查询词和文档看成是钥匙和锁的关系,查询词是钥匙,而待查找的文档则是锁。当查询词正好匹配上文档,感觉像用正确的钥匙打开正确的锁一样。图8-3的调试界面能够帮忙找出问题所在。 - - 用户界面设计与实现 图8-3 Solr的Web管理界面 顶部灰色部分介绍如下: l 头部信息,当启动多个Solr实例时,可以帮助了解在操作哪个实例。IP地址和端口号都是可见的。 l example(Admin旁边)是对这个schema的引用,仅仅是标识这个schema。如果你有很多schema,可以用这个标识去区分。 l 当前工作目录(cwd),和Solr的根目录(SolrHome)。 在Tomcat中配置多个Solr实例的方法是,用JNDI的方法配置多个solr.home。例如下面在$CATALINA_HOME/conf/Catalina/localhost下为每个Solr实例 的webapp创建独立的context: $ cat /tomcat55/conf/Catalina/localhost/solr1.xml - - 用户界面设计与实现 $ cat /tomcat55/conf/Catalina/localhost/solr2.xml 把每个Solr实例服务器端配置都放在不同的目录,修改solrconfig.xml的索引数据存储路径: ${solr.data.dir:./solr1/data} solrconfig.xml文件记录一个Solr实例的配置信息。如上述索引数据存储路径,还可以配置自动加载缓存的线程数量: 2 自动加载缓存的一个线程数缺省是4,有时候要调整小一点。否则会使一个双CPU双核的机器CPU使用率达到近100%。 还可以在solrconfig.xml定义默认搜索处理类及其他Handler信息等。 默认搜索处理器standard Handler的定义: explicit 有个小问题是Solr的日志在缺省情况下增加的很快,可以通过日志的输出级别来控制日志输出,Solr中的配置界面是:http://localhost/solr/admin/logging.jsp。 8.2.2 把数据放进Solr 可以通过post.jar把数据放到Solr存储库。post.jar读取的是XML文件。如果处理中文,这个XML文件必须是utf-8编码的,否则就会出现乱码了。下面是一个XML文件的样例。 126788 中文内容测试,TEST 上海 - - 用户界面设计与实现 我们通过客户端程序SolrJ把数据库中的数据转换成符合Solr格式的XML数据流,然后通过HTTP协议发送出去。 SolrServer server = new CommonsHttpSolrServer("http://localhost:8983/solr/rss"); Random rand = new Random(); //索引一些文档 Collection docs = new HashSet(); for (int i = 0; i < 10; i++) { SolrInputDocument doc = new SolrInputDocument(); doc.addField("link", "http://non-existent-url.foo/" + i + ".html"); doc.addField("source", "Blog #" + i); doc.addField("source-link", "http://non-existent-url.foo/index.html"); doc.addField("subject", "Subject: " + i); doc.addField("title", "Title: " + i); doc.addField("content", "This is the " + i + "(th|nd|rd) piece of content."); doc.addField("category", CATEGORIES[rand.nextInt(CATEGORIES.length)]); doc.addField("rating", i); System.out.println("Doc[" + i + "] is " + doc); docs.add(doc); } UpdateResponse response = server.add(docs); System.out.println("Response: " + response); 数据更新以后,如果要在索引库即刻看到更新的数据,需要提交更新: 在SolrJ中调用执行server.commit()来更新数据。 除了显式调用commit,同时Solr也支持自动提交数据。可以在solrconfig.xml配置文件中修改自动提交的条件: 10000 100000 - - 用户界面设计与实现 如果不需要自动提交数据,只是通过程序显式的提交,可以把相关的值都置成负数。 -1 -1 优化索引使用如下的Solr命令: 在SolrJ中调用执行server.optimize()来更新数据。 可以编写一个后台独立运行的程序来同步数据库和索引,把数据库中的数据增量放入索引库中。 public static void main(String[] args) throws Exception, InterruptedException { long sleepTime = 5000L; //5秒 for(;;){ boolean findNew = indexDb();//查看数据库是否有新数据要索引 if(findNew) { sleepTime = 5000L; } else { if(sleepTime < 500000L) { sleepTime = sleepTime*2; //没有新数据则延长检测新数据的间隔 } } System.out.println("sleep..."+sleepTime); Thread.sleep(sleepTime); } } 考虑indexDb()如何实现,也就是如何从待索引的表中找出要索引的新数据。可以根据表中的唯一id列查询出要索引的新数据。 StreamingUpdateSolrServer缓存所有的增加文档并把这些文档写到开放的HTTP连接。StreamingUpdateSolrServer内部使用HttpClient发送文档到Solr服务器。 - - 用户界面设计与实现 8.2.3 删除数据 可以通过查询的方式删除Solr的索引数据。例如根据查询删除数据: #curl http://192.168.10.30:8080/solr/update --data-binary "id:314685" -H "Content-type:text/xml" #curl http://localhost/gongkong/update --data-binary "id:http\:\/\/www.aljoin.com\/news\/2007-10\/20071010154644.htm" -H "Content-type:text/xml" 或者直接通过Id列删除: #curl http://192.168.10.30:8080/solr/update --data-binary "314685" -H "Content-type:text/xml" #curl http://localhost/gongkong/update --data-binary "http\:\/\/www.aljoin.com\/news\/2007-10\/20071010154644.htm" -H "Content-type:text/xml" 如果要删除所有标题或者内容中含有“答案”这个关键字的结果应该怎么写? #curl http://192.168.10.30:8080/solr/update --data-binary "title:答案 OR body:答案" -H "Content-type:text/xml" SolrJ删除数据的例子: String url = "http://localhost/solr/"; SolrServer server = new CommonsHttpSolrServer( url ); String q = "source:\"industrial acs\""; UpdateResponse res = server.deleteByQuery( q ); System.out.println(res.getStatus());//取得返回状态值 server.commit( true, true ); 如果要清空所有的数据,可以把Solr服务停止后直接删除Solr的索引路径,Solr起动后会重建索引路径。 8.2.4 Solr客户端与搜索界面 一般通过各种动态页面生成服务器返回搜索结果给查询用户。常用的动态网页开发工具有ASP.NET、JSP或PHP等。Solr提供对应的客户端来支持各种常用开发工具。Solr返回的数据格式通常是XML,返回JSON,Python,Ruby格式的数据也是支持的。Solr客户端是对HTTP请求的封装,同时也包括了对XML格式搜索结果的解析。已有的部分Solr客户端如表8-1所列: - - 用户界面设计与实现 前台语言 Solr客户端 网址 JSP SolrJ http://svn.apache.org/repos/asf/lucene/solr/trunk/src/solrj/ PHP solr-php-client http://code.google.com/p/solr-php-client/ ASP.NET SolrNet http://code.google.com/p/solrnet/ AJAX ajax-solr http://evolvingweb.github.com/ajax-solr/ Ruby on Rails acts_as_solr http://acts-as-solr.rubyforge.org/ 表8-1 Solr客户端列表 SolrJ是一个易于使用的客户端。因为SolrJ除了支持XML格式,还可以采用Java二进制(wt=javabin)方式返回搜索结果,所以性能相对其他客户端更好。下面是SolrJ一个简单的使用例子。 String url = "http://hot.lietu.com:8080/solr/"; SolrServer server = new CommonsHttpSolrServer( url ); SolrQuery query = new SolrQuery("NBA"); QueryResponse response = server.query( query ); //取得符合查询条件的总数 int numFound = response.getResults().getNumFound(); for(SolrDocument d : response.getResults()){ String id = (String)d.getFieldValue("id"); System.out.println("id:"+id); String title = (String)d.getFieldValue("title"); System.out.println("title:"+title); } 因为Solr内部已经有分页控制,所以实际显示的记录会比搜索到的结果少。分页程序需要计算总共有多少页,当前在第几页。下面增加对高亮显示的支持。 String url = "http://hot.lietu.com:8080/solr/"; SolrServer server = new CommonsHttpSolrServer( url ); SolrQuery query = new SolrQuery("NBA"); - - 用户界面设计与实现 HightlightingParams hp = query.getHighlightingParams(); hp.addField("body"); hp.setSimplePre(""); hp.setSimplePost(""); //限制高亮显示词的范围 hp.setHRequireFieldMatch(true); QueryResponse response = server.query( query ); System.out.println("found:"+response.getResults().getNumFound()); for(SolrDocument d : response.getResults()){ String id = (String)d.getFieldValue("id"); System.out.println("id:"+id); //取得高亮显示的第一段 String hl = response.getHighlighting().get(id).get( "body" ).get(0); System.out.println("hl:"+hl); } 下面是对“cat1id”这一列的分类统计例子: String url = "http://hot.lietu.com:8080/solr/"; SolrServer server = new CommonsHttpSolrServer( url ); SolrQuery query = new SolrQuery("NBA"); query.getFacetParams().addField("cat1id"); QueryResponse response = server.query( query ); System.out.println("found:"+response.getResults().getNumFound()); List facetCounts = response.getFacetField("cat1id").getValues(); for(Count c:facetCounts ){//打印分类统计结果 System.out.println(c); } VelocityResponseWriter可以返回从Velocity模版生成的内容。 - - 用户界面设计与实现 8.2.5 Solr索引库的查找 通过Solr的管理界面,可以直接分析索引库。还可以直接在浏览器输入url地址来查询索引库。通过各种参数来指定搜索执行的方式和返回结果的格式等。Solr中的参数分成基本参数和公共参数等类型。 Solr中所有请求都会用到的核心查询参数有: l qt – 查询类型 (request handler) ,例如 standard; l wt – 返回格式类型(response writer),例如XML或JSON。 公共的查询参数有: l q – 查询词; l sort – 排序方式; l start - 返回结果的开始行; l rows -本次需要返回结果的行数; l fl – 需要返回的列名称。 例如,执行一个最基本的关键词搜索,搜索“NBA”这个词: http://localhost:8080/solr/select/?q=NBA&version=2.2&start=0&rows=10&indent=on 这里返回最开始的前10行搜索结果。 执行分类统计搜索。假设cat列是分类列,首先我们可以看一下这个索引分了几类。 http://localhost/solr/select/?q=*%3A*&rows=0&facet.field=cat&rows=0&facet=true&&facet.limit=-1 部分返回结果如下: 395413 - - 用户界面设计与实现 151571 86733 175977 75374 8066 6053 10761 75885 8516 150630 14282 360621 139945 10565 265031 1900 … 如果要按url地址查找一个网站的数据,则需要把url地址中的字符“:”和“/”转义: url:http\:\/\/2packaging* 可以通过qt参数来调用不同的RequestHandler处理查询请求,下面这个查询调用StandardRequestHandler来处理。 http://localhost:8983/solr/select/?q=video&fl=name+score&qt=standard Solr的分析页面是不可缺少的实验方法和故障排除工具。可以用这个工具分析不同的语句,来验证是否是期望的结果。如果查询结果和你所想要的结果不同,也可以利用这个故障排除工具找出原因。在Solr的管理页面顶部有一个[ANALYSIS]的超级链接。 - - 用户界面设计与实现 图8-4 分析英文的Web界面 在图8-4这个页面顶部的第一个选项表示字段的类型,这一项是必选的。既可以直接输入字段类型,也可以按字段名称来分析。但是这个工具是用来分析文本型字段类型的,而不是用来分析布尔类型,日期类型和数字类型的。这个工具能同时分析索引和(或)查询文本。如果同时做查询和索引分析并且想查看分析时索引部分的匹配情况,可以高亮显示匹配结果。 输入”Quoting,Wi-Fi” and stopword后,分析的输出结果将显示如图8-5所示: 图8-5 分析英文的结果输出 输出结果中最重要的行是叫“term text”的第二行。term是实际存储和查询的原子单位。因此,查询的分析结果必须和索引分析阶段的结果包含相同的term才能够匹配。请注意,位置3上有两个词条。在同一位置的多个词条可能是由于同义词扩展产生,但是在这里,是由WordDelimeterFilterFactory引入了WiFi这个词。Quoting在词干化和小写化之后变成quot。因为and在停用词表里所以被StopFilter省略。 使用StopFilter的副作用是,因为有些查询中的词全部在停用词表中,所以可能完全搜索不到了。如果不想直接去掉停用词,可以用CommonGramsFilter来改进短语查询。CommonGramsFilter对布尔查询没有意义。布尔查询仅仅通过CommonGramsFilter而不发生改变。 - - 用户界面设计与实现 有两个相关的类:CommonGramsFilter和CommonGramsQueryFilter。在索引时使用CommonGramsFilter,在查询时使用CommonGramsQueryFilter。CommonGramsFilter输出 CommonGrams和Unigrams,这样就可以使用布尔查询代替短语查询。 如下是一个使用CommonGramsFilter的schema.xml配置文件例子: 有没有办法把最满足条件的文档排在最前面,如果查找很多个关键词时,如何把都满足的或者满足得最多的排在最前面? 可以制定如下规则:如果有三个条件,那么这三个条件都必须满足,如果条件超过三个,只要满足75%的条件。按照这个思路,首先重新配置solrconfig.xml,修改dismax的缺省参数配置,否则有些列名可能无效。 explicit 0.01 body^0.5 title^1.2 - - 用户界面设计与实现 body^0.2 title^1.5 title,body 75% 100 *:* text features name 0 name regex 然后在浏览器输入: http://localhost/solr/select/?q=test&rows=0&qt=dismax&mm=75% 这里设置了查询满足条件“mm=75%”并且指定Solr的DisMaxRequestHandler类来处理查询请求。 8.2.6 索引分发 为了支持更多的搜索访问量或者避免单点失败,使索引服务有更高的可用性,需要分发索引到多个服务器上来同时提供服务。利用rsync压入和弹出索引快照不会对服务器性能有大的影响。下面介绍如果有一个已经存在的Lucene索引,如何把这个索引从主节点复制到从节点。 在每个Linux服务器上创建路径: /usr/local/solr/ - - 用户界面设计与实现 这个路径下包含一些重要的可执行脚本: 主节点需要的脚本如下: · rsyncd-enable · rsyncd-disable · rsyncd-start · rsyncd-stop · snapshooter · snapcleaner 从节点需要的脚本如下: · snappuller-enable · snappuller-disable · snappuller · snapinstaller · snapcleaner 然后在Solr文件夹下面创建一个conf目录,conf目录下放一个配置文件:scripts.conf。 主节点里面的scripts.conf的配置文件类似这样: solr_hostname=localhost solr_port=8983 rsyncd_port=18983 data_dir= webapp_name=solr master_host= master_data_dir= master_status_dir= 注意,如果index的目录路径为:/usr/local/lucene/search/index/,则配置文件里data_dir路径应该为:/usr/local/lucene/search/。 从节点里面的scripts.conf的配置文件类似这样: user= solr_hostname=<主节点ip地址> - - 用户界面设计与实现 solr_port=8993 rsyncd_port=18993 data_dir=<从节点的lucene索引路径> webapp_name=solr master_host=<主节点ip地址> master_data_dir=<主节点的lucene索引路径> master_status_dir=<主节点的solr路径>/logs/clients/ 例如这里master_status_dir可能是:/usr/local/solr/logs/clients/ 通过如下的步骤即可开始复制索引: 1. 在主节点里,运行solr/bin目录下面的Linux命令: #rsyncd-enable #rsyncd-start 执行这两个命令后在solr/logs目录下将会创建一个pid文件和一个log文件。 2. 为了做一个索引的快照,只需要在主节点运行: #snapshooter 现在可以在crontab里进行配置snapshooter命令在一天的某几个时间运行。当新创建一个快照时,索引必需是完整的,也就意味着如果正在写入索引时,不能运行snapshooter命令。 如果运行snapshooter时,增加-c参数,只有当最后一个快照改变时,才创建一个新的snapshooter。为了防止磁盘被快照塞满,可以经常运行snapcleaner命令。运行snapshooter –N 2将会移除所有旧的快照,只保留两个最新的快照。 3. 在从节点,需要执行如下的脚本拉回并且安装快照: #snappuller-enable #snappuller -P 18983 #snapinstaller 注意snappuller后面的端口号是在主节点配置的rsyncd_port。同时在从节点应该调度执行snapcleaner。 4. 现在在从节点调度执行snappuller和snapinstaller的问题就剩下到主节点里做身份认证了。为了解决这个问题,可以添加ssh-keys到主节点的认证文件中。   在从节点执行: - - 用户界面设计与实现 #ssh-keygen -t dsa 执行ssh-keygen命令后,认证文件生成到home目录下的.ssh目录。 用scp命令把从节点上的id_dsa.pub文件拷贝到主节点: #scp id_dsa.pub usrname@masternode:./id_dsa.pub 在主节点的.ssh目录执行: #touch authorized_keys #chmod 600 authorized_keys #cat ../id_dsa.pub >> authorized_keys 8.2.7 Solr搜索优化 如果要在做索引时把不同的列设置不同的权重: value1 value2 value3 发送数据的写法: XML.writeXML(xmlContent, "field", value1, "name", " myBoostedField","boost","7.0"); 也可以在搜索的时候动态加权。对于标准的请求处理器,对标题列加权的例子:q=title:superman^2 subject:superman。 使用dismax请求处理器,可以在参数qf中对指定的列申明加权,例如:q=superman&qf=title^2 subject。 使用函数式查询实现日期和相关度混合排序: queryWord += " AND_val_:\"linear(recip(rord(timestamp),1,10000,10000),10000000,0)\""; 在索引中出现“头疼 药”和“头疼药”这样的内容,其分词都是“头疼”和“药”,前者有搜索结果,后者无搜索结果。这样的原因是Solr的查询词解析类使用的是短语匹配的方式。“头疼药”只按照“短语匹配”搜索是不恰当的,应该是先短语匹配,后按照分词进行垂直搜索。为了实现这样的效果,修改SolrQueryParser类。 protected Query getFieldQuery(String field, String queryText) throws ParseException { //如果碰到“-”,则把查询当成内部函数处理。 if (field.equals("_val_")) { return QueryParsing.parseFunction(queryText, schema); - - 用户界面设计与实现 } //缺省执行普通的字段查询 TokenStream source = this.getAnalyzer().tokenStream(field, new StringReader(queryText)); ArrayList v = new ArrayList(10); Token t; while (true) { try { t = source.next(); } catch (IOException e) { t = null; } if (t == null) break; v.add(t); } try { source.close(); } catch (IOException e) { // 省略 } if (v.size() == 0) return null; else if (v.size() == 1) return new TermQuery(new Term(field, ((Token)v.get(0)).termText())); else { PhraseQuery q = new PhraseQuery(); BooleanQuery b = new BooleanQuery(); q.setBoost(2048.0f); b.setBoost(0.001f); for (int i = 0; i < v.size(); i++) { Token token = v.get(i); q.add(new Term(field, token.termText())); TermQuery tmp = new TermQuery(new Term(field, token.termText())); tmp.setBoost(0.01f); - - 用户界面设计与实现 b.add(tmp, BooleanClause.Occur.MUST); } BooleanQuery bQuery = new BooleanQuery(); // 用OR条件合并两个查询 bQuery.add(q,BooleanClause.Occur.SHOULD); bQuery.add(b,BooleanClause.Occur.SHOULD); return bQuery; } } 如果需要限制这里的BooleanQuery bQuery的查询范围还可以: if(!stopwords.contains(token.termText())){ b.add(tmp, BooleanClause.Occur.MUST);//OR连接 System.out.println("add tmp:"+tmp); } 在通常的搜索中用户可以按内容相关度排序,或者按照日期逆序排序。 queryWord;createtime desc 还可以按sort参数排序: inStock desc, price asc SolrJ中按时间列排序的例子: query.addSortField("timestamp", ORDER.desc); 经常通过一个选项来切换这两种排序方式。为了更加简化搜索界面,可以综合内容相关度排序和日期排序。基本的方法是设置时间加权,在考虑到搜索词相关性的同时考虑到搜索结果的时间。例如:索引库中有三个字段:标题、内容、日期。然后组合搜索“内容+标题”,然后按时间排序。这样有个问题:比如我现在搜索“手机”,结果中如果不按日期排序,那结果的相关性很好,但是如果按日期排序,有些商情的内容里面会留些联系人的手机号这种字样, 但是其实这条商情并不是卖手机的,有可能是卖衣服的。但是这条商情的权重还是会排到卖手机的上面。Solr通过函数查询来实现时间加权排序。 l OrdFieldSource 实现了ord(myfield)函数。 l ReverseOrdFieldSource实现了rord(myfield)函数。 l LinearFloatFunction 实现了数值列的linear(myfield,1,2) 函数。 l MaxFloatFunction 实现了数值列或常量的max(linear(myfield,1,2),100) 函数。 l ReciprocalFloatFunction实现了数值列的recip(myfield,1,2,3) 函数。 - - 用户界面设计与实现 实际搜索”NBA”这个词的时候,使用时间加权的例子: +_val_:"linear\(recip\(rord\(timestamp\),1,10000,10000\),10000000,0\)" NBA 其基本原理是用索引中“timestamp”列的值来影响排序结果。 在优化索引阶段,Solr需要大约2倍索引大小的临时空间。因此需要大约400 GB的硬盘来容纳 200 GB的索引。 8.3 从FAST Search移植到Solr 微软在2008年收购FAST搜索公司。FAST搜索作为一个索引服务运行。因为FAST搜索的Linux版本已经停止开发,所以有必要移植到Solr。为了移植到Solr,需要修改提供数据端和查询端接口。 FAST有丰富的特征集合(语言,导航,连接器等),并不是所有的特征都在Lucene/Solr中已经实现,或者实现的方式不一样。不同于Solr的Field为中心的分析,FAST搜索的文档处理器在索引文档前转换文档。 例如下面的特征在Solr中没有很好的实现: l 编码归一化,语言识别。 l 文本抽取(HTML、PDF、MS Office、等) 。 l 分词,原型化(lemmatization),实体提取。 两者的术语对照如表8-1: Lucene/Solr FAST Replica Search Row Shard Column Facet Navigator Spellcheck Did you mean Update processor Document processor Request Handler Query Transformer(QT) Response Writer Result Processor(RP)/TWM - - 用户界面设计与实现 Lucene/Solr FAST Schema Index profile Index segment Index partition Multi core Multi cluster 用同样的方式处理的文档 Collection 表8-1 术语对照表 移植的步骤如下: 1. 评估下当前的特征和体系结构:保留所有的特征还是要增加新的特征? 2. 安装Solr并且迭代式开发:编写solrconfig.xml和schema.xml,导出并索引一些真实的数据,测试各种查询。 3. 设计覆盖所有移植领域的说明:模式、内容、装载数据并分析;前端界面,查询和API;管理和操作性的功能。 4. 实现移植到Solr。 从FAST搜索的索引配置到Solr的schema.xml移植的例子。FAST列的例子: Solr中的等价声明: Solr缺少成熟的处理管线。可以使用https://issues.apache.org/jira/browse/SOLR-1725中的更新处理器或者OpenPipeline(http://www.openpipeline.org/)来模拟。 需要特别注意的是,FAST搜索更好的支持多种自然语言,大量的使用了文档处理,包括实体提取。 - - 用户界面设计与实现 8.4 Solr扩展与定制 Solr的实现很灵活,可以定制输入输出或者增加内部功能。org.apache.solr.common.params.CommonParams类中定义了参数的名字。例如取得查询参数可以通过下面这行代码: String q = params.get( CommonParams.Q ); 8.4.1 Solr中字词混合索引 在Lucene的介绍中已经提到了实现字词混合索引的方法。在此基础上增加FilterFactory。 public class SingleFilterFactory extends BaseTokenFilterFactory { public SingleFilter create(TokenStream input) { return new SingleFilter(input); } } 在schema.xml中定义text列类型如下: 修改org.apache.solr.search 包中的SolrQueryParser类: //缺省执行普通的字段查询 TokenStream source = this.getAnalyzer().tokenStream(field, new StringReader(queryText)); ArrayList v = new ArrayList(10); Token t; while (true) { try { - - 用户界面设计与实现 t = source.next(); } catch (IOException e) { t = null; } if (t == null) break; v.add(t); } try { source.close(); } catch (IOException e) { //忽略 } if (v.size() == 0) return null; else if (v.size() == 1) return new TermQuery(new Term(field, ((Token)v.get(0)).termText())); else { PhraseQuery q = new PhraseQuery(); q.setBoost(2048.0f); ArrayLists=new ArrayList(v.size()); for (int i = 0; i < v.size(); i++) { Token token = v.get(i); if(token.getPositionIncrement()>0) { q.add(new Term(field, token.termText())); } if(token.termText().length()==1) { SpanTermQuery tmp = new TermQuery(new Term(field, token.termText())); s.add(tmp); } } BooleanQuery bQuery = new BooleanQuery(); - - 用户界面设计与实现 // 用OR条件合并两个查询 bQuery.add(q,BooleanClause.Occur.SHOULD); if(s.size()>0) { SpanNearQuery nearQuery = new SpanNearQuery(s.toArray(new SpanQuery[s.size()]),s.size(),true); nearQuery.setBoost(0.001f); bQuery.add(nearQuery,BooleanClause.Occur.SHOULD); } return bQuery; } 如果要向词表中增加词,则旧的按词切分会不准确。解决方法是:先查找出包含相关词的文档,然后删除文档,最后增加文档。 8.4.2 相关检索 经常需要实现相似检索的功能。例如输入一篇文章,返回相关的5篇文章。Solr中包括了一个MoreLikeThis的处理模块。在solrconfig.xml中包括MoreLikeThisHandler的定义: title,abstract 1 mlt.fl中包括提取关键词的缺省列。 搜索的时候输入类似: http://www.lietu.com:8080/solr/select/?q=%E6%B1%BD%E8%BD%A6&version=2.2&start=0&rows=1&mlt=true&mlt.mindf=1&mlt.mintf=1&mlt.fl=title,abstract 返回的结果后面包括了相似匹配的结果: 本文通过分析世界汽车工业的发展趋势,总结出21世纪世界汽车工业发展的八大趋势,主要表现在汽车产业组织、汽车生产方式、汽车产品及造型和汽车可持续发展战略上。 - - 用户界面设计与实现 武汉理工大学 Eight Developmont Trends of The World Automobile Industry in The 21st Century 5141078 杨瑞海,韩雄辉 21世纪世界汽车工业发展的八大趋势 杨瑞海,韩雄辉 这个缺省的相关检索仍然有待改进,因为MoreLikeThis查询的准确性依赖于提取关键词的准确性,为了提取关键词,首先要做的工作是去掉StopWord。MoreLikeThis类缺省使用的是英文的StopWord。下面我们修改MoreLikeThisHandler让它从外部读取StopWord.txt。 manu,cat 1 stopwords.txt MoreLikeThisHandler类的实现代码修改如下: private static Set stopWords = null; public void init(NamedList args) { super.init(args); SolrParams p = SolrParams.toSolrParams(args); String stopWordFile = p.get("stopWordFile"); if(stopWordFile == null) { stopWords = StopFilter.makeStopSet(StandardAnalyzer.STOP_WORDS); return; } - - 用户界面设计与实现 List wlist; try { wlist = Config.getLines(stopWordFile); } catch (IOException e) { throw new SolrException( SolrException.ErrorCode.NOT_FOUND, "MoreLikeThis requires a stop word list " ); } stopWords = StopFilter.makeStopSet((String[])wlist.toArray(new String[0]), true); } … mlt.setStopWords(stopWords); … 8.4.3 搜索结果去重 折叠对于一个给定列的相同或相似值的搜索结果叫做collapsing。例如对同一站点搜索结果的折叠,经常也会在这个搜索结果上加上“…中还有几条相关信息”。如图8-4所示: 图8-4 搜索结果去重效果图 这需要我们给Solr增加折叠的功能。首先我们假定折叠的列是未分词的。 https://issues.apache.org/jira/browse/SOLR-236 正是关于这个问题的解决方法。先取得这个版本: # svn export -r592129 http://svn.apache.org/repos/asf/lucene/solr/ - - 用户界面设计与实现 # patch -u -p0 < field-collapsing-extended-592129.patch 搜索测试: http://localhost:8080/select/?q=words_t%3AApple&version=2.2&start=0&rows=10&indent=on&collapse.field=t_s& collapse.threshold=1 返回的结果是: 0 0 0 1 on words_t:Apple 2.2 10 t_s 3 1 0 1 movie 2007-12-21T15:22:42.211Z Apple Orange 4 3 0 3 - - 用户界面设计与实现 book 2007-12-22T02:01:53.328Z Apple Orange t_s 1 1 HashDocSet(1) Time(ms): 0/0/0/0 这样的返回结果表示:对于列“t_s”,还有一个同名列“t_s”的值是movie。 主要参数介绍: l collapse.facet = before|after 参数控制faceting是在collapsing前或后发生。 l collapse.threshold参数控制在多少重复后开始折叠结果。 l collapse.maxdocs参数控制折叠执行时最多考虑的文档数量。当结果集很大的时候,增加此参数能缩短执行时间。 l collapse.info.doc和collapse.info.count参数控制collapse_counts返回结果内容。 实际应用场景设想,一个solr索引库包含很多新闻故事,这些故事来源于许多报纸或有限电视。 一条新闻故事可能来源于多个不同的报纸,例如《人民日报》或一些地方小报等。每个报纸对同一个新闻起上不同的标题,并截成不同的长度。 需要检测并把这些重复的故事分组在一起显示。假设每个故事都由一个Hash整数代表(也就是语义指纹),例如取故事的几个关键词作为“similarity_hash”,把这个值索引并存储起来作为检测重复故事的依据。 我们可以基于这个“similarity_hash”值来折叠搜索结果,这样同一个新闻故事的多次出现就折叠到了一起。 - - 用户界面设计与实现 而且,用户可能更愿意读到更加权威的版本,需要把这个结果优先显示到搜索结果中,当然也会有个重复新闻的计数和连接。权威性的取值可能是1)表示有限电视新闻网,2)表示 国家级报纸,3) 表示地区报纸,4) 表示地方小报。可以把权威性的取值索引和存储成一个整数权值——authority。 然后,可以显示给用户: "人咬狗" **日报, 连接可见其它77个重复新闻 通过折叠"similarity_hash"列实现这个功能,选择基于另外一列"authority"的值返回显示的新闻中的一条。 这样需要进一步修改这个折叠列的实现,增加一个参数: collapse.authority=[field] //索引列,用来控制折叠后的组中返回哪一个值 以前CollapseFilter只对一列排序,然后在排序后的结果中发现重复,实现折叠功能。现在改为实现多个列排序,把collapse.authority也加到排序列中。我们修改它的主要实现src/java/org/apache/solr/search/CollapseFilter.java文件: if (collapseType == CollapseType.NORMAL) { if(sort!=null) { SortField[] ofields = sort.getSort(); SortField[] nfields = new SortField[ofields.length+1]; for(int k=0;k - - 用户界面设计与实现 搜索结果需要返回的格式规定如表8-2所示: 变量名称 变量命名 说明 检索结果基本信息 baseinfo ers_word、ers_id、ers_s、ers_tc、ers_nc的父结点 检索词 ers_word 字符型字符串 最长长度120字节 检索ID ers_id 数字型字符串 最长长度20字节 检索状态 ers_s 字符型字符串 检索耗时 ers_tc 数字型字符串 检索结果数量 ers_nc 数字型字符串 检索结果内容 r ers_n、ers_pn 父结点 检索结果顺序码 ers_n 数字型字符串 检索结果对应所在电子书中页码 ers_pn 数字型字符串,非负 表8-2 返回结果格式 例如下面这个搜索结果: 软件开发 懂Protel等软件,熟悉电子线路 一般办公室庶务,文件整理,进销存软件操作 计算机软件相关专业本科以上,英语4级以上;有视频会议系统、流媒体软件、网络通讯、图像处理、数据库等相关软件开发经验。 - - 用户界面设计与实现 在服务器端定义一个Search类(Servlet)来处理查询请求。 Public class Search { public void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { //1、接受用户查询请求参数: String ers_word = request.getParameter("ers_word"); if(ers_word==null||ers_word.length()<=0){ response.sendError( 400, "检索词不能为空" ); return ; } final SolrCore core = SolrCore.getSolrCore(); SolrServletRequest solrReq = new SolrServletRequest(core, request); //2、将参数传递到solr内部: SolrParams spold = solrReq.getParams(); NamedList nl = spold.toNamedList(); nl.add("q", ers_word); nl.add("start", 0); nl.add("rows", 20); Map map = spold.toMultiMap(nl); SolrParams sp = new ServletSolrParams(map); solrReq.setParams(sp); //经过以上处理solr已经基本完成查询配置。 SolrQueryResponse solrRsp = new SolrQueryResponse(); try { SolrRequestHandler handler = core.getRequestHandler(solrReq.getQueryType()); if (handler==null) { log.warn("Unknown Request Handler '" + solrReq.getQueryType() +"' :" + solrReq); throw new SolrException(SolrException.ErrorCode.BAD_REQUEST,"Unknown Request Handler '" + - - 用户界面设计与实现 solrReq.getQueryType() + "'", true); } //3、Solr执行查询 core.execute(handler, solrReq, solrRsp ); if (solrRsp.getException() == null) { //4、调用自定义输出类进行输出: MyXMLWriter responseWriter = new MyXMLWriter(); response.setContentType(responseWriter.getContentType(solrReq, solrRsp)); PrintWriter out = response.getWriter(); responseWriter.rewrite(out, solrReq, solrRsp); } else { Exception e = solrRsp.getException(); int rc=500; if (e instanceof SolrException) { rc=((SolrException)e).code(); } sendErr(rc, SolrException.toStr(e), request, response); } } catch (SolrException e) { if (!e.logged) SolrException.log(log,e); sendErr(e.code(), SolrException.toStr(e), request, response); } catch (Throwable e) { SolrException.log(log,e); sendErr(500, SolrException.toStr(e), request, response); } finally { // This releases the IndexReader associated with the request solrReq.close(); } } } 自定义MyXMLWriter和MyWriter类。MyXMLWriter继承XMLResponseWriter,而MyWriter继承XMLWriter。在Servlet里调用MyXMLWriter输出搜索结果。 public class MyXMLWriter extends XMLResponseWriter { - - 用户界面设计与实现 public void rewrite(Writer writer, SolrQueryRequest req, SolrQueryResponse rsp) throws IOException { //MyXMLWriter调用MyWriter进行输出: MyWriter.rewriter(writer,req,rsp); } } public class MyWriter extends XMLWriter { public static float CURRENT_VERSION=2.2f; private static final char[] XML_START="\n".toCharArray(); private static final char[] XML_START_NOSCHEMA=("\n").toCharArray(); public MyWriter(Writer writer, IndexSchema schema, SolrQueryRequest req,String version) { super(writer,schema,req,version); } public static void rewriter(Writer writer, SolrQueryRequest req, SolrQueryResponse rsp) throws IOException{ String ver = req.getParams().get(CommonParams.VERSION); MyWriter xw = new MyWriter(writer, req.getSchema(), req, ver); xw.defaultFieldList = rsp.getReturnFields(); String ers_word = req.getParam("ers_word"); //获取查询结果集。 NamedList lst = rsp.getValues(); StringBuilder sb = new StringBuilder(""); //添加XML头信息。 sb.append(XML_START).append(XML_START_NOSCHEMA); //添加XML结果状态信息 NamedList response = rsp.getResponseHeader(); if(response!=null){ String ers_s = (String)response.get("ers_s"); - - 用户界面设计与实现 String ers_tc = (String)response.get("ers_tc"); String ers_nc = (String)response.get("ers_nc"); XML.writeXML(sb, "beseinfo", "", "ers_word",ers_word,,"ers_s",ers_s,"ers_tc",ers_tc,"ers_nc",ers_nc); } //添加XML详细结果信息。 Object result = lst.get("result"); if(result!=null){ DocList docs = (DocList)result; SolrIndexSearcher searcher = req.getSearcher(); DocIterator iterator = docs.iterator(); int ss = docs.size(); for (int i=0; i\n"); String temp = sb.toString(); //输出数据 writer.write(temp); } private static Set defaultFieldList; } 8.4.5 分布式搜索 当一个索引的大小超过一个机器的处理能力的时候,就需要用到分布式索引了。Solr直接用HTTP来实现分布式搜索。 分布式搜索要达到如下目标:客户端应用,例如SolrJ对分布式搜索是完全不可知的。分布式搜索的处理和结果合并都在请求 - - 用户界面设计与实现 handler 内部处理了。在结果合并以后,保持响应返回的结果格式不变。 分布式搜索的具体的实现方法是:一个新的叫做MultiSearchRequestHandler 的RequestHandler执行对多个子搜索的分布式搜索 (子搜索服务叫做"shard")。handleRequestBody方法被分成查询构建和执行两个方法。为了增加分布式搜索功能,所有的搜索请求handler都扩展这个MultiSearchRequestHandler。标准的StandardRequestHandler和DisMaxRequestHandler改成 扩展这个类。 如果"shards"出现在请求参数中,分布式搜索就开始起作用了,否则搜索的是本地索引库。例如,shards=local,host1:port1,host2:port2会搜索本地索引和两个远程索引。从三个shard 返回的结果合并后返回给客户端。 可以通过下面的URL地址访问多个Solr实例: http://localhost/select/?q=%E5%8C%BB%E7%94%9F&version=2.2&start=1000&rows=20&indent=on&shards=local,localhost:8080 分布式搜索系统整体结构如图8-4。其中,需要考虑如何在多个shard中分布文档?也就是分布文档算法。一个简单的分布文档方法是根据文档编号分布到不同的shard:hash(id) % numShards。如果重建索引很容易或者shard数量固定不变,这样是可以的。在shard数量变化的情况下,可考虑采用一致性散列(Consistent Hashing)。设想删除或者增加一个shard,不会造成文档到shard映射的剧烈改变,这就是一致性散列要达到的效果。一致性散列的结果不只是一个数,而是一个完整的探查序列的情形。想象有一个年级的同学分在几个班,把其中一个班解散,这个班的学生分到其他的班,而其他班的学生仍旧在原来的班。 Indexers … Shard[0] Shard[n] … 输入文档 用户 应用 分布算法 Searchers … Shard[0] … Shard[n] … 输入查询,合并返回结果 协调层 - - 用户界面设计与实现 图8-4 分布式搜索系统整体结构图 索引分布到多台机器后,提高了单点失败的可能性,也就是一台搜索服务器无法访问后,导致整个搜索结果都不可用了。可以利用虚拟IP地址(VIP)来避免单点失败。VIP是一个不连接到一个指定的计算机或者计算机上的网卡的IP地址。送到VIP地址的输入包被重定向到物理网络接口。分布式搜索通过Http网络协议实现,因此可以查询shard的VIP。通过VIP来实现负载均衡和容错的结构如图8-5。 从搜索服务器1.1 从搜索服务器1.2 从搜索服务器2.1 从搜索服务器2.2 主搜索服务器1.0 主搜索服务器2.0 复制 复制 http://****?shards=VIP1,VIP2 VIP1 VIP2 图8-5分布式搜索与索引复制结构图 为了集群配置和调度,Solr包含并且使用Zookeeper作为存储器。把Zookeeper看成一个包含所有的Solr服务器的分布式文件系统。 8.4.6 分布式索引 使用SOLR-1301可以实现用Hadoop做索引。因为索引很多文档往往比较耗时,这个方法用于索引到N个主服务器也需要花费很多时间的情况。 有数TB索引时,就会觉得索引速度很慢了。采用每个Hadoop节点对应一个Shard,并行的方式更改索引列可能要花费几分钟到几小时,而不是几天。 实现SolrDocumentConverter类,把对象从固定格式转换成SolrInputDocument,然后可以索引进Solr。SolrRecordWriter实例化一个构建Solr索引的RecordWriter。Hadoop中的RecordWriter类实现把任务的输出写到文件系统。SolrRecordWriter调用了BatchWriter来构建索引。BatchWriter类按批增加文档到一个嵌入的SolrServer,这样完成写索引。 - - 用户界面设计与实现 SolrMapper把输入的值写入到索引库。 public static class SolrMapper extends Mapper{ public void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String line = value.toString(); StringTokenizer lineStringTokenizer = new StringTokenizer(line, "\t"); int fieldId = 0; SolrInputDocument rawOccurrenceRecordDocument = new SolrInputDocument(); while(lineStringTokenizer.hasMoreTokens()){ String token = lineStringTokenizer.nextToken(); rawOccurrenceRecordDocument.setField(recordFileds[fieldId], token); fieldId+=1; } try { getServerInstance().add(rawOccurrenceRecordDocument); } catch (SolrServerException e) { log.equals(e); } } } Job job = new Job(getConf()); job.setJarByClass(CSVIndexer.class); int shards = N; boolean compressOutput = false; String solrHome = ""; Path out = new Path(args[0]); job.setMapperClass(CSVMapper.class); job.setReducerClass(CSVReducer.class); job.setOutputFormatClass(SolrOutputFormat.class); SolrOutputFormat.setupSolrHomeCache(new File(solrHome), job.getConfiguration()); if (shards > 0) { job.setNumReduceTasks(shards); } - - 用户界面设计与实现 job.setOutputKeyClass(Text.class); job.setOutputValueClass(MapWritable.class); SolrDocumentConverter.setSolrDocumentConverter(CSVDocumentConverter.class, job.getConfiguration()); FileOutputFormat.setOutputPath(job, out); SolrOutputFormat.setOutputZipFormat(compressOutput, job.getConfiguration()); return job.waitForCompletion(true) ? 0 : -1; HDFS不支持足够多的POSIX来直接把Lucene索引直接写到HDFS,所以在每个节点的本地存储空间创建索引。关闭索引后然后拷贝到HDFS。 Nutch使用的确是Lucene索引,不过将索引放在HDFS上面是为了利用Hadoop平台的计算性能对索引进行合并等一些操作。在Hadoop平台上进行这些操作比单机处理强很多。处理完成之后,可以将索引下载到本地进行访问,并不是提供搜索服务的时候也是在HDFS上面的。 使用MapFile是为了利用Hadoop平台处理数据,最终生成索引。MapFile并不是用来索引存储,而是用来存储原始数据,比如网页快照。 Hadoop实现了一个分布式文件系统(Hadoop Distributed File System),简称HDFS。HDFS有着高容错性的特点,即使一些数据节点失败了,集群仍然能工作。可以自动检测到数据节点失败。自动采取行动来重新平衡存储在失败的数据节点上的数据块到集群中的其他节点。并且,如果可能的话,重新执行失败节点上的任务。最后,Hadoop集群的用户甚至不会注意到一个数据节点的失败。所以可以使用Hadoop集群来备份索引。 8.4.7 SolrJ查询分析器 为了支持象AND(与)、OR(或)、NOT(非)这样的高级查询语法。Lucene使用JavaCC生成的QueryParser类实现用户查询串的解析。SolrJ自身没有这样的实现,下面实现一个和Lucene查询语法兼容的查询语法解析器。 查询分析一般用两步实现,词法分析和语法分析。词法分析阶段根据用户输入返回单词符号序列,而语法分析阶段则根据单词符号序列返回需要的查询串。 词法分析的功能是从左到右扫描用户输入查询串,从而识别出标识符、保留字、整数、浮点数、算符、界符等单词符号,把识别结果返回到语法分析器,以供语法分析器使用。这一部分的输入是用户查询串,输出是单词符号串的识别结果。例如,对如下的输入片断: title:car site:http://www.sina.com 词法分析的输出可能是: TREM title - - 用户界面设计与实现 COLON : TREM car TREM site COLON : TREM http://www.sina.com 词法分析可以采用JFlex这样的工具生成,也可以手工编写。因为查询词法比较简单,所以这里采用手工实现一个词法分析。语法分析采用YACC的Java版本BYACC/J(http://byaccj.sourceforge.net/)。BYACC/J根据YACC源文件生成Java源代码。YACC推导的返回类型Query定义如下: public interface Query { public String getQueryType(); //取得查询类型,对应Solr的Request Handler public String getQuery(); //取得查询串 } 语义值存储在一个叫做ParserVal的类中,因此修改ParserVal中的属性obj的类型定义。 public class ParserVal{ … /** * 联合体对象的值 */ public Query obj; … 定义Token的类型有如下几种: %token AND OR NOT PLUS MINUS LPAREN RPAREN COLON TREM SKIP RANGEIN_START RANGEIN_TO RANGEIN_END 下面是词法分析器的实现: public class Yylex { private Parser yyparser; //解析器 private String buffer; //查询串缓存 private int tokPos = 0; //Token的当前位置 private int tokLen = 0; //Token的长度 /** * 词法分析器的构造函数 * * @param r 输入查询串 - - 用户界面设计与实现 * @param yyparser 解析器对象 */ public Yylex(String r, Parser yyparser) { buffer = r; this.yyparser = yyparser; } /** * 遍历扫描直到匹配正则表达式 * 如果结束,返回0 * * @返回下一个 token */ public int yylex() { tokPos += tokLen; if (tokPos >= buffer.length()) { //输入串解析结束 return 0; } char ch = buffer.charAt(tokPos); switch (ch) { case '+': tokLen = 1; return Parser.PLUS; case '-': tokLen = 1; return Parser.MINUS; case '(': case '(': tokLen = 1; return Parser.LPAREN; case ')': case ')': tokLen = 1; return Parser.RPAREN; case '[': - - 用户界面设计与实现 tokLen = 1; return Parser.RANGEIN_START; case ']': tokLen = 1; return Parser.RANGEIN_END; case ':': case ':': tokLen = 1; return Parser.COLON; case '|': if (tokPos + 1 < buffer.length() && buffer.charAt(tokPos + 1) == '|') { tokLen = 2; return Parser.OR; } tokLen = 1; return Parser.TREM; case '&': if (tokPos + 1 < buffer.length() && buffer.charAt(tokPos + 1) == '&') { tokLen = 2; return Parser.AND; } tokLen = 1; return Parser.TREM; case ' ': case '\t': case ' ': tokLen = 1; return yylex(); case '"': tokLen = 1; while (tokPos + tokLen < buffer.length()) { char chTerm = buffer.charAt(tokPos + tokLen); if (chTerm != '"') { tokLen++; } else { - - 用户界面设计与实现 tokLen++; break; } } yyparser.yylval=new ParserVal(buffer.substring(tokPos, tokPos+tokLen)); return Parser.TREM; default: tokLen = 1; while (tokPos + tokLen < buffer.length()) { char chTerm = buffer.charAt(tokPos + tokLen); if (chTerm != ' ' && chTerm != '\t' && chTerm != ' ' && chTerm != '(' && chTerm != ')' && chTerm != ')' && chTerm != '(' && chTerm != ':' && chTerm != ']') { if( chTerm == ':' ) { if("http".equals(buffer.substring(tokPos, tokPos+tokLen))) { tokLen++; } else { break; } } else { tokLen++; } } else { break; } } String cur = buffer.substring(tokPos, tokPos+tokLen); if (cur.equals("AND")) { return Parser.AND; } - - 用户界面设计与实现 if (cur.equals("OR")) { return Parser.OR; } if (cur.equals("TO")) { return Parser.RANGEIN_TO; } yyparser.yylval = new ParserVal(cur); return Parser.TREM; } } } 可以测试一下这个词法分析程序,看是否能返回正确的Token类型: public static void main(String[] args) { String i = "title:car link:http://www.sina.com"; //输入字符串 Parser yyparser = new Parser(); Yylex lexer = new Yylex(i, yyparser); int type = 1; while (type!=0){ type = lexer.yylex(); //得到Token类型 String result = String.valueOf(type); if (yyparser!=null && yyparser.yylval!=null ){ result+= " "+ yyparser.yylval.sval; //得到Token值 } System.out.println(result); } } YACC源文件由三部分组成: 第一部分是DECLARATIONS区域,在这里定义token和过程等。 第二部分是ACTIONS区域,在这里定义文法和行为。 第三部分是CODE区域,在这里定义用户方法。 三部分由一个'%%'行分隔开。形式是: DECLARATIONS - - 用户界面设计与实现 %% ACTIONS %% CODE 生成Solr查询串的YACC语法文件queryparser.y的内容如下: %start querystr %token AND OR NOT PLUS MINUS LPAREN RPAREN COLON TREM SKIP RANGEIN_START RANGEIN_TO RANGEIN_END %left OR %left AND %left NOT %left PLUS %left MINUS %% querystr : query { $$ = $1; } ; query : /*empty */ | query clause { if ($1 == null) { $$ = $2; } else { BooleanQuery bq = new BooleanQuery(); bq.Add($1.obj, BooleanClause.Occur.SHOULD); if ($2.obj instanceof BooleanQuery) { BooleanQuery clause = (BooleanQuery)$2.obj; if (clause.plus) { bq.Add($2.obj, BooleanClause.Occur.MUST); } else if (clause.minus ) { - - 用户界面设计与实现 bq.Add($2.obj, BooleanClause.Occur.MUST_NOT); } else { bq.Add($2.obj, BooleanClause.Occur.SHOULD); } } else { bq.Add($2.obj, BooleanClause.Occur.SHOULD); } $$ = new ParserVal(bq); } } ; clause : clause OR clause { BooleanQuery bq = new BooleanQuery(); bq.Add($1.obj, BooleanClause.Occur.SHOULD); bq.Add($3.obj, BooleanClause.Occur.SHOULD); $$ = new ParserVal( bq ); } | clause AND clause { BooleanQuery bq = new BooleanQuery(); bq.Add($1.obj, BooleanClause.Occur.MUST); bq.Add($3.obj, BooleanClause.Occur.MUST); $$ = new ParserVal( bq ); } | LPAREN clause RPAREN { $$ = new ParserVal( $2.obj ); } | PLUS clause { BooleanQuery bq = new BooleanQuery(); bq.Add($2.obj, BooleanClause.Occur.SHOULD); bq.plus = true; $$ = new ParserVal( bq ); - - 用户界面设计与实现 } | MINUS clause { BooleanQuery bq = new BooleanQuery(); bq.Add($2.obj, BooleanClause.Occur.SHOULD); bq.minus = true; $$ = new ParserVal( bq ); } | NOT clause { BooleanQuery bq = new BooleanQuery(); bq.Add($2.obj, BooleanClause.Occur.SHOULD); bq.minus = true; $$ = new ParserVal( bq ); } | TREM { String termStr = $1.sval; BooleanQuery bq = new BooleanQuery(); for (int i = 0; i < defaultSearchFields.length; ++i) { bq.Add(new TermQuery(defaultSearchFields[i], termStr), BooleanClause.Occur.SHOULD); } $$ = new ParserVal(bq); } | TREM COLON TREM { String fieldStr = $1.sval; String termStr = $3.sval; $$ = new ParserVal( new TermQuery(fieldStr, termStr) ); } | TREM COLON RANGEIN_START TREM RANGEIN_TO TREM RANGEIN_END { String fieldStr = $1.sval; String fromStr = $4.sval; String toStr = $6.sval; $$ = new ParserVal( new RangeQuery(fieldStr, fromStr,toStr) ); } ; %% - - 用户界面设计与实现 private Yylex lexer; public String[] defaultSearchFields = {"title","body"}; //缺省搜索列 private int yylex () { int yyl_return = lexer.yylex(); return yyl_return; } public void yyerror (String error) { System.err.println ("Error: " + error); } public Parser(String i) { lexer = new Yylex(i, this); } public static void main(String args[]) { //测试方法 String input = "title:中国 OR 北京"; Parser parser = new Parser(input); parser.yyparse(); System.out.println(parser.val_peek(0).obj.ToString()); } 用BYACC生成出Java源代码: $yacc -J queryparser.y 8.4.8 扩展SolrJ SolrJ通过请求类和响应类提供了方便的接口用来扩展。例如实现从URL地址: http://localhost/admin/stats.jsp#core 读取索引状态如图8-6。 图8-6 索引状态 - - 用户界面设计与实现 首先定义请求类: public CoreRequest() { super( METHOD.GET, "/admin/stats.jsp#core" ); } 然后定义响应类: public CoreResponse(NamedList res) { super(res); indexInfo = res; } 以及返回XML格式内容的解析类CoreResponseParser: protected NamedList readNamedList( XMLStreamReader parser ) { NamedList nl = new NamedList(); int status; while( parser.hasNext()) { status = parser.next(); if (status==XMLStreamConstants.START_ELEMENT) { String n = parser.getLocalName(); if("stat".equals(n)) { //取得属性名 n = parser.getAttributeValue(0); parser.next(); //取得属性值 String v = parser.getText().trim(); nl.add(n, v); } } } return nl; } 8.4.9 扩展Solr Solr本身是REST风格的,它通过Servlet响应用户请求。以MoreLikeThis handler为例,在solrconfig.xml中包含: - - 用户界面设计与实现 manu,cat 1 MoreLikeThisHandler对应的请求方法: http://localhost:8983/solr/mlt?q=id:UTF8TEST&mlt.fl=manu,cat&mlt.mindf=1&mlt.mintf=1 编写Solr的handler的方法需要经过以下几个步骤的操作: 1. 将Solr源文件解压,在Eclipse中新建工程,将源文件以及相关的jar文件导入。 2. 在org.apache.solr.handler包下(一般在此包下进行扩展)新建Java类。 3. 新建的类需要继承RequestHandlerBase类,并且实现其中的handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp)方法。同时需要覆盖getVersion()、getDescription()、getSourceId()、getSource()、getDocs()等方法。 4. handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp)方法中,req表示传入的参数对象,rsp表示经过处理后得到的需要显示的对象。 5. 根据需要编写业务处理逻辑。 6. 在Solr中,常量一般在org.apache.solr.common.params包下的接口CommonParams中定义。在本项目中,需要在CommonParams中添加新的常量,如: public static final String URI = "uri";//URI的值表示访问的参数 7. 以上工作实现后,对Solr重新打包。然后加入到Web项目目录\WEB-INF\lib中。 8. 打开Solr文件夹(对应resin文件夹下的solr文件夹),打开conf文件夹下的solrconfig.xml文件,在……标签中,添加如下内容: 其中name属性决定了访问的路径,class属性决定了处理类。 9. 打开IE浏览器,输入“http://localhost:8081/index/urlinfo?url=?wt=?”,其中“localhost:8081/index/”表示该WEB应用程序的访问地址,“urlinfo”对应第8条中的标签中的name属性的值。url对应传入的查询网页地址(对应第6条中添加的常量的值(uri))。wt表示要输出的格式,值一般为:xml和json,表示输出的格式为XML格式或者JSON格式。 - - 用户界面设计与实现 另外一个应用场景:前台用PHP实现的系统需要知道网页的类别。采用PHP调用Solr服务器中提供的网页分类服务。 1. 在合适的包下编写所需要的Java类,本例中类名为URItoWordsHandler,该类继承RequestHandlerBase类。其中的方法handleRequestBody实现如下: public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throws Exception { // 获取传递的参数集合,并且进行相应的业务处理 SolrParams params = req.getParams(); // 从参数集合中得到想要的参数 String uri = params.get(CommonParams.URI); if (uri == null || uri.trim().length() < 1) { rsp.add("type", "其它"); rsp.add("weburl", ""); return; } // 进行业务处理,在这里是对网页分类 InputStream is = this.getClass().getResourceAsStream("/spider.properties"); try { properties.load(is); is.close(); } catch (IOException e1) { e1.printStackTrace(); } String modelDir = properties.getProperty("modelDir"); //分类模型所在路径 Classifier theClassifier = new Classifier(modelDir+"/model.prj"); //文本分类器 String catName=null; if(uri.startsWith("http")) { //对网页分类 ContentExtractor ce = new ContentExtractor(); String body = ce.processURL(uri,null).toString(); //提取正文 catName = theClassifier.getCategoryName(body); //根据正文分类 if (catName==null){ catName="其它"; } - - 用户界面设计与实现 } else{ //对本地文件分类 FilterFile file=new FilterFile(); String body=file.filter(uri); //提取正文 catName = theClassifier.getCategoryName(body); //根据正文分类 if (catName==null){ catName="其它"; } } /** * 将业务处理结果添加到rsp,并且输出 */ rsp.add("type", catName); rsp.add("weburl", uri); } 2. 在solrconfig.xml中标签下添加相应的配置信息: 参数 3. 启动服务器,在浏览器地址栏输入:“http://localhost:8081/index/urlinfo?url=d&wt=xml”。其中,urlinfo对应b项中提到的访问路径,url表示需要传递的参数,wt表示返回的结果格式。wt=xml时的显示结果为XML格式。wt=json时,指定返回结果格式是JSON: 要对网页http://tech.163.com/digi/09/0328/13/55GBJMQT00161MAH.html分类,可以在浏览器地址栏输入: http://localhost:8081/index/urlinfo?uri=http://tech.163.com/digi/09/0328/13/55GBJMQT00161MAH.html&wt=xml 即可实现对该网址内容的分类: 0 - - 用户界面设计与实现 9688 新闻 http://tech.163.com/digi/09/0328/13/55GBJMQT00161MAH.html 要对本地文件进行分类,例如:D:\zhexue\zhexue\08325\1.txt,可以在浏览器地址栏输入: http://localhost:8081/index/urlinfo?uri=D:\zhexue\zhexue\08325\1.txt&wt=xml 返回结果是: 0 8062 新闻 D:\zhexue\zhexue\08325\1.txt 8.4.10 日文搜索 sen项目实现日文分词,集成到solr的配置如下: - - 用户界面设计与实现 id content 8.4.11 查询Web图 为了支持在Solr中以“link:”语法查找链接网页,写一个LinkToThisHandler的Solr插件调用第二章已经实现的WebGraph。 public final static String PREFIX = "lt."; public final static String URL_FIELD = PREFIX + "fl"; private static final String INDEX_DIR = PREFIX + "dbDir"; private WebGraph webGraph = null; @Override public void init(NamedList args) { super.init(args); SolrParams p = SolrParams.toSolrParams(args); String dir = p.get(INDEX_DIR); try { //读入WebGraph webGraph = new WebGraph(dir); } catch (DatabaseException e) { throw new RuntimeException("Cannot open WebGraph database", e); } } @Override public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throws Exception { - - 用户界面设计与实现 SolrParams params = req.getParams(); SolrIndexSearcher searcher = req.getSearcher(); String q = params.get( CommonParams.Q ); SolrParams required = params.required(); //取得URL列的名称 String field = required.get(LinkToThisHandler.URL_FIELD) ; // 需要返回的字段 String fl = params.get(CommonParams.FL); int flags = 0; if (fl != null) { flags |= SolrPluginUtils.setReturnFields(fl, rsp); } int start = params.getInt( CommonParams.START, 0 ); int rows = params.getInt( CommonParams.ROWS, 10 ); DocList docList = null; if( q != null ) { // 找到一个基础的匹配 Query query = QueryParsing.parseQuery(q, params.get(CommonParams.DF), params, req.getSchema()); DocList match = searcher.getDocList(query, null, null, 0, 1, flags ); // only get the first one... // DocIterator是一个迭代器,但这里只处理第一个匹配。 DocIterator iterator = match.iterator(); if( iterator.hasNext() ) { // 在结果中的每一个文档里都做这样一个请求处理 int id = iterator.nextDoc(); IndexReader reader = searcher.getReader(); Document doc = reader.document(id); String toURL = doc.getField(field).stringValue(); - - 用户界面设计与实现 String[] fromURLs = webGraph.inLinks(toURL); ArrayList docIds = new ArrayList(fromURLs.length); for(int i = 0;i=1) { int docId = hits.id(0); docIds.add(docId); } } int[] ids = new int[docIds.size()]; for(int i=0;i url - - 用户界面设计与实现 webgraph 然后我们就可以在浏览器中测试效果了: http://localhost/solr/lt/?q=url%3Ahttp%5C%3A%5C%2F%5C%2Fwww.baotron.com%5C%2Findex.asp&version=2.2&start=100&rows=10&indent=on Solr内部调用LinkToThisHandler,查询哪些网页指向了 “http://www.baotron.com/Findex.asp”。 8.5 Solr的.NET客户端 SolrNet (http://code.google.com/p/solrnet/)是Solr最流行的.NET客户端。客户端的基本原理是:发送HTTP请求,返回XML格式的数据,最后得到的是搜索结果对象。.NET中的实现方法是:使用WebClient请求数据,然后使用System.Xml解析结果,最后使用一个对象封装器把搜索结果封装成对象。 SolrNet使用SOLID原则构建而成。这里的S代表单一责任原则:当需要修改某个类的时候原因有且只有一个。换句话说就是让一个类只做一种类型责任,当这个类需要承当其他类型的责任的时候,就需要分解这个类。这里的O代表开放封闭原则:软件实体应该是可扩展,而不可修改的。也就是说,对扩展是开放的,而对修改是封闭的。这个原则是诸多面向对象编程原则中最抽象、最难理解的一个。L表示里氏替换原则:当一个子类的实例应该能够替换任何其超类的实例时,它们之间才具有is-A关系。I表示依赖倒置原则:高层模块不应该依赖于低层模块,二者都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象。D表示接口分离原则:不能强迫用户去依赖那些他们不使用的接口,换句话说,使用多个专门的接口比使用单一的总接口总要好。 假设要对博客文章实现全文搜索。在Solr中的schema.xml定义如下这些列: - - 用户界面设计与实现 text id 在项目中增加对SolrNet.dll和Microsoft.Practices.ServiceLocation.dll的引用。 首先,映射Solr文档到Article类。 class Article { [SolrUniqueKey("id")] public int ID { get; set; } [SolrField("title")] public string Title { get; set; } [SolrField("content")] public string Content { get; set; } [SolrField("tag")] public List Tags { get; set; } } 它只是一个简单的POCO(Plain Old CLR Object)类。SolrField映射属性到一个Solr列,而 SolrUniqueKey映射一个属性到一个Solr唯一标识列。 定义好模型后,现在可以连接到Solr并且保存一些文章。下面的代码定位Solr实例(这里运行在本地的8080端口),创建一些文档并提交到索引。 // 发现服务 Startup.Init
("http://localhost:8080/solr"); ISolrOperations
solr = ServiceLocator.Current.GetInstance>(); // 形成一些文章 solr.Add(new Article(){ ID = 1, Title = "my laptop", Content = "my laptop is a portable power station", Tags = new List() { "laptop", "computer", - - 用户界面设计与实现 "device" } }); solr.Add(new Article(){ ID = 2, Title = "my iphone", Content = "my iphone consumes power", Tags = new List() { "phone", "apple", "device" } }); solr.Add(new Article(){ ID = 3, Title = "your blackberry", Content = "your blackberry has an alt key", Tags = new List() { "phone", "rim", "device" } }); // 提交到索引 solr.Commit(); 如下的例子执行一个全文搜索"power"和标签搜索"phone": // 全文搜索"power" Console.WriteLine("POWER ARTICLES:"); ISolrQueryResults
powerArticles = solr.Query(new SolrQuery("power")); foreach (Article article in powerArticles) { Console.WriteLine(string.Format("{0}: {1}", article.ID, article.Title)); } - - 用户界面设计与实现 Console.WriteLine(); // 标签搜索"phone" Console.WriteLine("PHONE TAGGED ARTICLES:"); ISolrQueryResults
phoneTaggedArticles = solr.Query(new SolrQuery("tag:phone")); foreach (Article article in phoneTaggedArticles){ Console.WriteLine(string.Format("{0}: {1}", article.ID, article.Title)); } SolrNet支持分类统计搜索。通过QueryOptions对象指定分类统计列。如下的例子显示按标签分类统计匹配"device"标签的例子: Console.WriteLine("DEVICE TAGGED ARTICLES:"); ISolrQueryResults
articles = solr.Query(new SolrQuery("tag:device"), new QueryOptions() { Facet = new FacetParameters { // 设置分类统计列 Queries = new[] { new SolrFacetFieldQuery("tag") } } }); foreach (Article article in articles){ Console.WriteLine(string.Format("{0}: {1}", article.ID, article.Title)); } Console.WriteLine("\nTAG COUNTS:"); foreach (var facet in articles.FacetFields["tag"]){ Console.WriteLine("{0}: {1}", facet.Key, facet.Value); } 可以通过QueryOptions指定排序方式。 QueryOptions options = new QueryOptions(); //按"Popularity"列排序 options.AddOrder(new SolrNet.SortOrder("Popularity", Order.DESC)); options.Rows = intPageSize; //返回结果行数 - - 用户界面设计与实现 options.Start = 0; //开始位置 可以使用QueryOptions的ExtraParams属性来增加参数到Solr查询字符串。例如: var results = solr.Query("myquery", new QueryOptions { ExtraParams = new Dictionary { {"bf", "recip(rord(myfield),1,2,3)^1.5"} } }); 8.6 Solr的PHP客户端 PHP索引数据有两种方式:用PHP模拟curl 的方式做索引和直接发送POST的客户端,后一种方法更成熟一些,solr-php-client(http://code.google.com/p/solr-php-client/)就是使用直接发送POST请求实现。 solr-php-client中有3个最主要的PHP类: l Apache_Solr_Service代表一个Solr服务器。Apache_Solr_Service::search()的前3个参数分别对应q、start和rows,这是查询中三个最常用的参数。 l Apache_Solr_Document封装一个Solr文档。可以直接设置文档列中的值,例如 $document->title = 'Something'; l Apache_Solr_Response 封装一个Solr响应。 如下是一个简单的查询例子: //连接位于端口号8983的solr服务 $solr = new Apache_Solr_Service('localhost', '8983', '/solr'); $query = "NBA";//查询词 $offset = 0; $limit = 10; //提供给查询方法的参数包括要返回文档的开始位置和要返回的最多文档数。 $response = $solr->search( $query, $offset, $limit ); if ( $response->getHttpStatus() == 200 ) { if ( $response->response->numFound > 0 ) { echo "$query
"; //遍历结果文档 foreach ( $response->response->docs as $doc ) { echo "$doc->partno $doc->name
"; - - 用户界面设计与实现 } echo '
'; } } else { echo $response->getHttpStatusMessage(); } 首先看下创建Solr服务对象这行代码: $solr = new Apache_Solr_Service( 'localhost', '8983', '/solr' ); 其中“/solr”这个参数是可以变的,可以对应不同的索引库。如果只要搜索一个栏目,只用一个索引库就够了,如果要搜索不同的栏目,而且每个栏目需要显示不一样的内容才用多个索引库。 为了搜索一些零件,增加零件描述文档到Solr的例子: ping() ) { echo 'Solr service not responding.'; exit; } //创建两个文档表示两个零件,实际上,文档更可能来源于数据库查询。 $parts = array( 'spark_plug' => array( //第一个零件 'partno' => 1, //零件编号 'name' => 'Spark plug', //零件名称 'model' => array( 'Boxster', '924' ), //零件型号 'year' => array( 1999, 2000 ), //年 'price' => 25.00, //价格 'inStock' => true, //是否在仓库中 ), 'windshield' => array( //第二个零件 'partno' => 2, 'name' => 'Windshield', - - 用户界面设计与实现 'model' => '911', 'year' => array( 1999, 2000 ), 'price' => 15.00, 'inStock' => false, ) ); $documents = array(); foreach ( $parts as $item => $fields ) { $part = new Apache_Solr_Document(); foreach ( $fields as $key => $value ) { if ( is_array( $value ) ) { foreach ( $value as $datum ) { $part->setMultiValue( $key, $datum ); } } else { $part->$key = $value; } } $documents[] = $part; } //把文档加载到索引 try { $solr->addDocuments( $documents ); $solr->commit(); $solr->optimize(); } catch ( Exception $e ) { echo $e->getMessage(); } ?> - - 用户界面设计与实现 8.7 本章小结 本章介绍了企业级的搜索服务器Solr,从它的基本用法到对Solr服务器和Solr客户端的扩展。通过仔细的调整性能,可以使用Solr实现搜索若干个TB的文档内容。 和Lucene相比,Solr有更多的缓存支持,并且支持主从复制和分片两种分布式部署方式。2009年年底发布的Solr 1.4集成了在搜索结果中聚类的功能。聚类功能本身使用另外一个开源项目Carrot2(http://project.carrot2.org/)完成。 Solr计划开发中的功能包括通过ZooKeeper自动管理大的搜索服务器集群。 除了Solr,还有http://www.elasticsearch.org/可以实现搜索服务器。 - - 用户界面设计与实现 第9章 使用Hadoop实现分布式计算 因为计算往往离不开数据,所以Hadoop除了包括MapReduce分布式计算实现,还包括分布式存储的实现。分布式存储的实现叫做Hadoop File System,简称HDFS。 6类线、6类模块、千兆交换机,不过因为有很多物理因素影响,实际使用中没法达到千兆,但绝对比百兆要快很多很多。千兆网卡(二手20多块)和4路千兆交换机(二手100多块)组成内网,不必用太好的设备,普通或二手的就行了。 Hadoop在分布式存储和分布式计算方面都使用主从架构。主服务器叫做NameNode,从服务器叫做DataNode。一个HDFS集群是由一个NameNode和一定数目的DataNode组成。以服务器/客户端方式使用HDFS集群,在这里,使用Hadoop集群的机器叫做客户端。 NameNode负责保管和管理所有的HDFS元数据,客户端直接在DataNode上读写文件数据,并不是由客户端把数据提交给NameNode后由它再转发。 在MapReduce看来,主服务器叫做JobTracker,从服务器叫做TaskTracker。JobTracker只有一个,而TaskTracker可以是很多。JobTracker负责调度构成一个作业的所有任务,这些任务分布在不同的TaskTracker上,JobTracker监控它们的执行,重新执行已经失败的任务。而TaskTracker仅负责执行由JobTracker指派的任务。 一般用性能好的机器做NameNode和JobTracker。性能一般的机器做DataNode和TaskTracker。 通常,MapReduce框架和分布式文件系统是运行在一组相同的节点上的,也就是说,计算节点和存储节点通常在一起。这种配置允许框架在那些已经存好数据的节点上高效地调度任务,这可以高效地利用整个集群的网络带宽。 在Hadoop中把一次计算任务称为一个作业。作业由客户端提交给JobTracker。为了方便开发,建议在Linux环境采用Hadoop Eclipse插件开发分布式计算程序。例如,在一台Linux机器上使用Eclipse Plugin for Hadoop开发分布式计算程序,把这台电脑称为客户端。 通常作业的输入和输出都会被存储在文件系统中。框架需要对key和value的类进行序列化操作,因此,这些类需要实现 Writable接口。另外,为了方便框架执行排序操作,key类必须实现 WritableComparable接口。 一个Map-Reduce 作业的输入和输出类型如下所示: (input) -> map -> -> combine -> -> reduce -> (output) 假设集群在内部网络运行,所以对安全性考虑较少。 - - 用户界面设计与实现 下载hadoop-0.20.2.tar.gz其中的contrib\eclipse-plugin路径包含了插件hadoop-0.20.2-eclipse-plugin.jar。 为了运行WordCount的例子,需要在Eclipse中增加对hadoop-0.20.2-core.jar和commons-cli-1.2.jar的引用。 调用Context.write()方法来输出键/值对。 public class WordCount extends Configured implements Tool { public static class Map extends Mapper { private final static IntWritable one = new IntWritable(1); private Text word = new Text(); public void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String line = value.toString(); StringTokenizer tokenizer = new StringTokenizer(line); while (tokenizer.hasMoreTokens()) { word.set(tokenizer.nextToken()); context.write(word, one); } } } public static class Reduce extends Reducer { public void reduce(Text key, Iterable values, Context context) throws IOException, InterruptedException { int sum = 0; for (IntWritable val : values) { sum += val.get(); } context.write(key, new IntWritable(sum)); } } - - 用户界面设计与实现 public int run(String [] args) throws Exception { Job job = new Job(getConf()); //定义任务 job.setJarByClass(WordCount.class); //定义要执行的类 job.setJobName("wordcount"); //定义作业名 //定义Reduce输出键值的数据类型,需要与前述的Map和Reduce类相匹配 job.setOutputKeyClass(Text.class); job.setOutputValueClass(IntWritable.class); //定义Map类名称,Combiner类名称和Reducer类名称 //必要时还要定义Map的输入类型,例如Map输入是某个Reduce的输出 job.setMapperClass(Map.class); job.setCombinerClass(Reduce.class); job.setReducerClass(Reduce.class); //定义输入输出文件的类型 job.setInputFormatClass(TextInputFormat.class); job.setOutputFormatClass(TextOutputFormat.class); //取得输入路径 FileInputFormat.setInputPaths(job, new Path(args[0])); //取得输出路径 FileOutputFormat.setOutputPath(job, new Path(args[1])); boolean success = job.waitForCompletion(true); return success ? 0 : 1; } public static void main(String[] args) throws Exception { int ret = ToolRunner.run(new WordCount(), args); System.exit(ret); } } - - 用户界面设计与实现 第10章 地理信息系统案例分析 电子地图是车载导航和互联网上的重要应用。地图上有些用户关注的兴趣点,例如一个餐馆就是一个兴趣点。兴趣点也叫做POI(Point of Interest),每个POI包含名称、地址、类别、经度、纬度等信息。 数据采集是地图数据更新和专业的位置信息获取的重要方法,需要把现实中的专业地理位置信息准确地标注在地图上。采集用于地图的地理位置信息,需要保证:地图数据的准确性和更新的及时性。需要借助人工采集数据来取得POI信息,获取数据成本高,所以需要考虑局部更新的问题。 从新闻中发现更新信息的流程是:首先抓取新闻,然后提取其中的标题和正文等信息,再从中提取POI信息,最后排重后输出。 因为自然语言处理相关项目存在高风险,所以软件费用结算方式建议采用一次性预交的产品费加按月结算开发费的方式。“假如森林里有一棵树倒下了,但没有被人听到,那么它算是发出了声音吗?”同样地,人们可以说,“假如软件没有被使用,那么它算是被开发出来了么?”如果不让用户在早期就参与到成本控制上来,项目风险会比较高。 自然语言处理项目的开发周期往往很长,例如北京大学计算语言学研究所加工半年的人民日报语料库花了1年多年时间。为了节约成本,降低人员流动性,采用如下几点来实现异地分布式开发: 1、最好能找一个分布式团队成员在任何地方都可以访问得到的源代码容器,有些网站提供SVN或Git版本管理服务。 2、需求管理可以使用Word文档,通过邮件群发到每个项目开发人员。因为Gmail能永久保存邮件,所以推荐使用它。在某个迭代周期中要能清晰的定义好需要完成的需求。 3、任务管理和工作项分配、以及项目完成情况的跟踪可以用JIRA(http://www.atlassian.com/software/jira/)完成。把需求分解成可执行的工作项,并将工作项和源代码的变更关联起来,对工作项进行跟踪,以确认整个项目的进展。 4、异地分布式开发时协同交流和沟通往往更加困难。文字聊天上,可以使用GTalk,因为它的聊天记录可以保留,语音聊天方面可以使用QQ,支持多方语音、视频,远程桌面共享方面,可以使用QQ的远程桌面。文档共享方面可以使用维基。为了方便数据传输,可以使用Excel或者Access数据库存数据。 5、在测试方面,需要强调单元测试和敏捷式开发。没有时间做单元测试时,可以做代码评审。 6、软件方法和流程上,采用增量式的开发方法。尽量完成每一个里程碑。并在每个里程碑 - - 用户界面设计与实现 后进行代码优化和重构。 10.1 新闻提取 需要从任意新闻网页中提取标题和正文等信息。这里采用信息提取的方法来实现。首先定义要提取的数据结构: public class NewsInfo { String title; //标题 String text; //正文 String author; //作者 String infoSource; //信息来源 String publishDate; //发布日期 } 定义标注网页中的文本的类型: public enum DocType { Start, //开始状态 End, //结束状态 PrefixAuthor, //作者前缀 SuffixAuthor, //作者后缀 PrefixTitle, //标题前缀 SuffixTitle, //标题后缀 Title, //标题 PrefixText, //正文前缀 SuffixText, //正文结尾 Text, //正文 PrefixInfoSource, //消息来源前缀 InfoSource, //消息来源 PrefixPublishDate, //发布时间前缀 PublishDate, //发布时间 Time, // 表示时分秒的时间 Date, // 表示年月日的时间 Author, //作者 Unknow, //未知 Other, // 其他 } 去掉网页标题中开始和结尾的噪音。例如:网页中的HTML原始标题是“日本地震已造成33人死亡_滚动新闻_新浪财经_新浪网 - - 用户界面设计与实现 ”,其中结尾部分是噪音。提取标题的实现如下: //取得净化后的标题 public String getClearTitle(Node node) { int startPos = 0; int endPos = 0; String title = ""; StringBuffer sb = new StringBuffer(); getTitle(sb, node); //取得网页中的原始标题 ArrayList ret = DocTagger.tag(sb.toString()); boolean findTitleStartFlag = false; boolean findTitleEndFlag = false; for (int i = 0; i < ret.size(); ++i) { //根据标注类型找有效的标题信息 if (ret.get(i).type.equals(DocType.PrefixTitle) && (findTitleStartFlag == false) && (findTitleEndFlag == false)) { findTitleStartFlag = true; /* 找到标题开头标志 */ startPos = i; continue; } else if (((ret.get(i).type.equals(DocType.SuffixTitle)) || (ret.get(i).type.equals(DocType.PublishDate))) && (findTitleEndFlag == false)) { findTitleEndFlag = true; /* 找到标题结尾标志 */ endPos = i; continue; } } /* 根据发现标题的标志进行处理 */ if (findTitleStartFlag == true) { if (findTitleEndFlag == true) { /* 从title的start标志 拷贝至 end标志 */ for (int j = startPos + 1; j < endPos; j++) { if (!ret.get(j).type.equals(DocType.Start) && !ret.get(j).type.equals(DocType.End) && !ret.get(j).type.equals(DocType.Link) - - 用户界面设计与实现 && !ret.get(j).type.equals(DocType.PrefixTitle) && !ret.get(j).type.equals(DocType.SuffixTitle)) { title += ret.get(j).termText; } } } else { /* 从title的start标志 拷贝至 结尾 */ for (int j = startPos + 1; j < ret.size(); j++) { if (!ret.get(j).type.equals(DocType.Start) && !ret.get(j).type.equals(DocType.End) && !ret.get(j).type.equals(DocType.Link) && !ret.get(j).type.equals(DocType.PrefixTitle) && !ret.get(j).type.equals(DocType.SuffixTitle)) { title += ret.get(j).termText; } } } } else { if (findTitleEndFlag == true) { for (int j = 0; j < endPos; j++) { if (!ret.get(j).type.equals(DocType.Start) && !ret.get(j).type.equals(DocType.End) && !ret.get(j).type.equals(DocType.Link) && !ret.get(j).type.equals(DocType.PrefixTitle) && !ret.get(j).type.equals(DocType.SuffixTitle)) { title += ret.get(j).termText; } } } else { title = sb.toString().trim(); } } return title; } 去掉网页正文中开始和结尾的噪音。提取正文: - - 用户界面设计与实现 static NewsInfo extractAll(Node documentNode) { NewsInfo needInfo = new NewsInfo(); String infoSource = ""; /* 信息来源 */ String publishDate = ""; /* 发布时间 */ String title = ""; /* 标题 */ String text = ""; /* 正文 */ String author = ""; /* 作者 */ int start = 0; //正文的开始位置 int end = 0; //正文的结束位置 boolean findTextStartFlag = false; /* 初始化找到正文开头标志 */ boolean findTextEndFlag = false; /* 初始化找到正文结尾标志 */ boolean findInfoSource = false; /* 初始化找到信息来源标志 */ boolean findPublishDate = false; /* 初始化找到发布日期标志 */ boolean findTextAuthor = false; /* 初始化找到作者标志 */ /* 获取标题 */ title = getClearTitle(documentNode); /* 取得DOM对象内的所有文本 */ String content = textExtractor(documentNode); /* 执行信息提取 采用规则方式提取小段文本*/ ArrayList ret = DocTagger.tag(content); for (int j = 0; j < ret.size(); j++) { DocToken token = ret.get(j); /* 信息来源 */ if (token.type.equals(DocType.InfoSource) && (findInfoSource == false)) { infoSource = token.termText; findInfoSource = true; /* 找到信息来源标志 */ continue; } /* 作者 */ - - 用户界面设计与实现 if (token.type.equals(DocType.Author) && (findTextAuthor == false)) { author = token.termText; findTextAuthor = true; /* 找到作者 */ if (author.length() >= 4) { author = ""; findTextAuthor = false; } continue; } /* 发布时间 */ if (token.type.equals(DocType.PublishDate) && (findPublishDate == false)) { publishDate = token.termText; findPublishDate = true; /* 找到发布日期标志 */ continue; } /* 正文前缀 */ if (token.type.equals(DocType.PrefixText) && (findTextStartFlag == false)) { start = j; findTextStartFlag = true; continue; } /* 正文后缀 */ if (token.type.equals(DocType.SuffixText) && (findTextEndFlag == false) && (findTextStartFlag == true)) { end = j; findTextEndFlag = true; continue; } } text = ""; - - 用户界面设计与实现 if ((findTextStartFlag == true) && (findTextEndFlag == true)) { for (int j = start + 1; j < end - 1; j++) { text += ret.get(j).termText; } } // 去掉头尾的一些不需要的字符 title = Entities.HTML40.unescape(title).trim(); /* 处理其中的转义字符 */ text = Entities.HTML40.unescape(text.toString()).trim(); needInfo.title = title; needInfo.text = text; needInfo.author = author; needInfo.infoSource = infoSource; needInfo.publishDate = publishDate; return needInfo; } 10.2 POI信息提取 在城市发展建设过程中,地图数据信息的变更不可避免,这就需要及时更新数据。监测新闻等网络媒体中涉及到地址变更的信息。通过爬虫抓取最新的和地图数据信息相关的变更信息。通过信息提取技术形成变化的地址信息列表。需要提取的POI相关信息有: l POI主体:例如深圳发展银行永康支行。 l 所属地区:杭州、深圳福田区等。需要用行政区域编码来确定区域。 l 时间:2009年04月28日等时间性的词。 l 变更事件:如开业,拆迁,完工,迁址,关门等动词。 从新闻中提取描述主体与事件等结构化信息。例如下面这篇新闻: “刘老根大舞台北京开业 郭德纲笑言无竞争会捧场 2009年04月28日 17:49 来源:武汉晚报 郭德纲的德云社就在“刘老根大舞台”前门剧场的附近,两者同为逗乐艺术,竞争和比较在所难免。赵本山认为,二人转和相声有不同的欣赏观众,大家可以在竞争中求发展,在发展中成为好朋友,相互借鉴和促进。” - - 用户界面设计与实现 提取出特征: l 区域:北京 l 主体:刘老根大舞台 l 事件:开业 从上面的例子可以看到,感兴趣的主体、事件、区域全部都在标题中一起出现了。对于这样的可以采用整体提取的方式,把这些信息提取到一个POIInfo类的实例。 public class POIInfo { public DocNode poi; //主体 public DocNode place; //区域 public DocNode matter; //事件 } 还有些信息比较分散,这时候需要根据整篇新闻全局考虑,提取出全局性的主体、区域和事件。 为了降低实现的复杂度,基于分而治之的思想,把信息提取分成5个阶段的管线: 1.中文分词; 2.词性标注; 3.名字发现; 4.句法分析,一般限于名词和动词短语识别,一个句法树的例子如图9-1; 5.语义解释,一般基于模式匹配。 - - 用户界面设计与实现 org org v org suffixOrg place 发展 银行 深圳 永康 支行 place suffixOrg 图9-1主体句法树 然而,管线结构容易导致错误累积。例如,一个标注中的错误可能导致句法分析失败,接着导致语义解释失败。这类似多米诺骨牌效益。 集成的模型可以限制错误传播,通过联合起来做所有的决策。因此,设计一个集成的模型,在这个模型里,标注、名字发现、解析和语义解释决策都有机会相互影响。 输入一篇新闻,返回的是一个DocNode组成的数组。 public class DocNode { public String termText; //词 public DocType type; //类型 public int start; //开始位置 public int end; //结束位置 public long cost; //概率 public ArrayList children = new ArrayList(); //孩子节点 } 在匹配模式的过程中生成树的代码: public static void replace(ArrayList key, int offset, ArrayList spans) { int j = 0; for (int i = offset; i < key.size(); ++i) { DocSpan span = spans.get(j); - - 用户界面设计与实现 DocNode token = key.get(i); StringBuilder newText = new StringBuilder(); int newStart = token.start; int newEnd = token.end; DocType newType = span.type; /* 组合新生成的节点 */ for (int k = 0; k < span.length; ++k) { token = key.get(i + k); newText.append(token.termText); newEnd = token.end; } /* 准备新生成的结点并将老结点挂接在新节点的孩子结点上 */ DocNode newToken = new DocNode(newStart, newEnd, newText.toString(), newType); /* 将老节点从队列中删除,并临时存储起来,作为将要替代的新节点的孩子结点 */ for (int k = 0; k < span.length; ++k) { /* 如果长度大于1或者类型不相同时增加孩子结点 */ if ((span.length > 1) || (!span.type.equals(token.type))) { newToken.children.add(key.get(i)); } key.remove(i); } key.add(i, newToken); j++; if (j >= spans.size()) { return; } } } 此外,依存句法可以直接发现词之间的关系,所以对于信息提取很有用。例如“上海华普大连金龙4S店即将于12月19日隆重开业”,这里的“即将于12月19日隆重”都是用来修饰“开业”的。依存语法认为词之间的关系是有方向的,通常是一个词支配另一个词,这种支配与被支配的关系就称作依存关系。 24个依存关系定义如下: - - 用户界面设计与实现 public enum DependencyRelation { ATT, //定中关系(attribute) QUN, //数量关系(quantity) ROOT, //核心 COO, //并列关系(coordinate) APP, //同位关系(appositive) LAD, //前附加关系(left adjunct) RAD, //后附加关系(right adjunct) VOB, //动宾关系(verb-object) POB, //介宾关系(preposition-object) SBV, //主谓关系(subject-verb) SIM, //比拟关系(similarity) VV, //连动结构(verb-verb) CNJ, //关联结构(conjunctive) MT, //语态结构(mood-tense) IS, //独立结构(independent structure) ADV, //状中结构(adverbial) CMP, //动补结构(complement) DE, //“的”字结构 DI, //“地”字结构 DEI, //“得”字结构 BA, //“把”字结构 BEI, //“被”字结构 IC, //独立分句(independent clause) DC; //依存分句(dependent clause) } 依存文法由一系列规则组成。规则类定义如下: public class Rule { DocType[] dependents; //被支配词性序列 int headId; //支配词性位置 DependencyRelation type; //依存关系类型 public Rule(DocType[] deps,int governor,DependencyRelation dr){ dependents = deps; headId = governor; type = dr; } - - 用户界面设计与实现 } MSTParser(http://www.seas.upenn.edu/~strctlrn/MSTParser)是一个Java实现的依存句法分析包。其中对训练实例的定义如下: public class DependencyInstance { public String[] terms; //词 public DocType[] postags; // 标注类型 public int[] heads; // 每个元素的头ID public String[] deprels; // 依赖关系,例如"SUBJ" } 训练数据中的每个句子用3到4行表示,一般的格式是: w1 w2 ... wn p1 p2 ... pn l1 l2 ... ln d1 d2 ... d2 这里, - w1 ... wn 是以空格分开的句子中的单词 - p1 ... pn 是每个单词的词性标注 - l1 ... ln 是依赖关系类型标注 - d1 ... dn 用整数表示每个单词的父亲的位置 例如,句子"武汉 取消 了 49 个 收费 项目"用这种格式表示是: 武汉 取消 了 49 个 收费 项目 ns vg u m q vn n SBV ROOT MT QUN ATT ATT VOB 2 0 2 5 8 8 2 这样可以识别出这个句子的主干是:武汉 取消 了 项目。 - - 用户界面设计与实现 10.2.1 提取主体 把主体看成是短语。传统的方法是首先进行分词和标注,然后再做短语识别。如果有多个可能的主体,则按照每个主体在文本中出现的次数,把出现次数多的作为主体。 但是,有些真正的主体出现次数却较少,这时候怎么办?规则匹配出来的时候可以设置不同的权重,然后加权算主体。 public class DocSpan { public int length; //覆盖的长度 public DocType type; //token类型 public int weight=0; //权重 public DocSpan(int l,DocType t) { length = l; type = t; } public DocSpan(int l,DocType t,int weight){ length = l; type = t; this.weight=weight; } public String toString(){ return type+":"+length+":"+weight; } } 写规则的时候可以增加识别出来的主体的权重,例如: lhs.add(new DocSpan(6, DocType.SmallAddress,100)); //最后这个100是权重,可以调高 新闻经常在标题中描述发生一个事件的主体,如酒店开业中的酒店名,道路开通中的道路名。“主体”和“事件”连在一起的,可以增加提取出来的权重。 虽然主体一般是一个,但是可以有多个。例如新闻内容是: “目前,佛山市有佛山皇冠假日(原佛山宾馆)、华夏新中源、南海名都、顺德哥顿、乐从财神酒店等5家挂牌五星...”。 - - 用户界面设计与实现 这里有5个主体:佛山皇冠假日、华夏新中源、南海名都、顺德哥顿、乐从财神酒店。把这些都做成并列主体,也就是说并列的主体可以用标点“、”连接。 有些主体中出现的地名需要简化成地名简称。例如:“武汉石家庄高速”简称“武石高速”。一般取地名的第一个字作为简称,但是有些则不是,例如北京简称“京”,天津简称“津”。Abbreviation.txt存放简称词典,每行一个简称,样例如下: 北京市:京 上海市:沪 天津市:津 重庆市:渝 “蚌淮高速”和“淮蚌高速”都是指同一个高速公路。解决方法是如果“高速”前的两个字都是简称,则按字符排序后输出成统一的格式“淮蚌高速”。高速路中出现的地名一般是前后次序无关的。 因此,把高速路名称标准化: String getStandName(DocNode roadName){ StringBuffer sb=new StringBuffer(); DocNode firstNode = roadName.children[0]; String abbreviation = abbreviationMap.get(firstNode.termText); if(abbreviation!=null){ sb.append(abbreviation); } else { sb.append(firstNode.termText); } DocNode secondNode = roadName.children[1]; abbreviation = abbreviationMap.get(secondNode.termText); if(abbreviation!=null){ sb.append(abbreviation); } else { sb.append(secondNode.termText); } return sb.toString(); } 10.2.2 提取地区 地名有国外地名和国内地名。如果不需要关注国外地名,则可以把国外地名可以作为停用词去掉。和地址相关的类型有:直辖市(Municipality)、省(Province)、市(City)、区( - - 用户界面设计与实现 County)、镇(Town)、街(Street)。下面的代码根据搜集到的地名数组返回一个最重要的地名。 //输入候选地区,返回地区和这个地区对应的行政区域编码 public static PlaceAndCode getPlace(ArrayList places) { LinkedHashMap address=new LinkedHashMap(); HashMap placeCode = new HashMap(); for(DocToken place:places){ String p = place.termText; Long pc1 = place.code; placeCode.put(p, pc1); //迭代出一个place对象 并用此对象与places集合中的所有对象进行比较 for(DocToken place1:places){ Long pc2 = place1.code; //比较它们的行政区域编码计算相似度 int i = codeDistance(pc1,pc2); if(i!=0){ Integer freq = address.get(p); if (freq!=null) { address.put(p, freq+i); } else { address.put(p, i ); } } } } /** * 遍历地址,取出出现频率最高的那个地址 */ int max=0; String bestPlace = ""; - - 用户界面设计与实现 for(Entry e : address.entrySet()) { String key=e.getKey(); Integer val = e.getValue(); if (val>max) { max=val; bestPlace=key; } } String place = SynonymReplace.replace(bestPlace); Long codeValue = placeCode.get(place); long code = 0; if(codeValue != null){ code = codeValue; } return new PlaceAndCode(place,code); } 10.2.3 指代消解 有的描述对象只在内容中出现了一次或少数几次,在内容中更多的地方以代词出现。这时候,需要用指代消解(Coreference Resolution)来准确的处理这样的描述对象。和主体相关的例子: 例如“昨天(20日)晚上10时,随着最后一段接缝处合龙段混凝土浇筑完毕,由上海建工基础公司承建的新河南路桥实现了提前10天合龙贯通。预计到今年8月,这座桥的部分车道将通车,以缓解河南路的交通压力。” 希望提取出“新河南路桥”,而这里,“这座桥”指代了“新河南路桥”很多代词如果不还原出来,就很难抽取到内容了。因为“新河南路桥”这个词只出现了一次,所以要通过指代消解来确定谈话主体。 指代消解对词性标注和语法分析有一定的依赖,判断是否消解不是根据这个词出现了几次,而要先找到指示词“这座桥”。 用选出的先行词(antecedent)替换指代词,即进行指代消解。 具体实现上首先找出指示词的候选先行词,然后计算候选的先行词和指示词的一致性。例如 - - 用户界面设计与实现 “新河南路桥”和“这座桥”词尾都是“桥”,所以一致性比较高。指代语过滤器用于判断是否应该将一些实体描述作为其先行语。最后把指示词替换成对应的先行词。 另外几个和地名相关的例子: “成都人民南路主车道完成施工 … 记者从市重大办了解到,自人民南路综合改造工程动工以来,我市在确保施工质量的前提下先后进行过多次改造进度提速:9月上旬,各施工单位基本完成东侧道路路面改造,开始倒边向道路西侧打围,进行西侧道路的半幅施工;仅用1个月时间就完成了西侧道路的改造,在本月15日之前形成主车道全线通车能力,并完成天府广场至二环路范围内的人行道铺装,为西博会的顺利召开提供交通保障。” 在这里“我市”指代“成都”。 “东莞阳光网 ... 莞深高速三期石碣段即东江大桥于上月28日通车以来,受到市民的广泛欢迎。特别是我市东部镇街的市民经此去广州以及白云机场,将比走广深高速节省半个小时。” 在这里“我市”指代“东莞”。 “我市金龙大桥长田隧道正式通车 该工程于2008年1月开工,建成通车后,将有效改善达州天然气能源化工基地连通主城区的交通面貌,对推动化工产业园区建设,拓展城市发展空间,构建城市交通内联外接骨架网络具有重大重义。” 在这里“我市”指代“达州”。 DocFactory的 resolutionPlace方法实现的值转换代码如下: //对输入tokens中的代词执行指代消解 public static void resolutionPlace(ArrayList tokens) { for (int i=0;i=0;--k) {//先往前找 DocToken predecessor = tokens.get(k); if (predecessor.type == DocType.Address) { //找到前驱词 tokens.set(i, predecessor); find = true; break; - - 用户界面设计与实现 } } if(!find){ for(int j = i+1;j(); rhs = new ArrayList(); rhs.add(DocType.QQPrefix); rhs.add(DocType.Num); rhs.add(DocType.Num); rhs.add(DocType.Num); rhs.add(DocType.Num); rhs.add(DocType.Num); rhs.add(DocType.Num); rhs.add(DocType.Num); rhs.add(DocType.Num); rhs.add(DocType.Num); lhs.add(new DocSpan(1, DocType.Other)); lhs.add(new DocSpan(9, DocType.qqinfo)); addProduct(rhs, lhs); 这是定义9位QQ信息的规则,其中NUM有9位,如果想定义抓取5位的QQ信息。则将NUM的数量定义为5个即可。 另外举一个提取网页信息中的召集人的例子。 先定义要提取的信息类型,代码如下: public enum DocType { convenor, // 活动召集人 CallerPrefix, // 召集人前缀 CallerSuffix, // 召集人后缀 - - 其他高级主题 Unknow, // 未知的 Other // 其他 } 然后定义召集人的特征、前缀信息、连接信息和结尾信息。这部分和定义QQ相关的提取特征相似。 最后定义提取召集人信息的规则,代码如下: lhs = new ArrayList(); rhs = new ArrayList(); rhs.add(DocType.CallerPrefix); rhs.add(DocType.GuillemetStart); rhs.add(DocType.Unknow); rhs.add(DocType.GuillemetStart); lhs.add(new DocSpan(2, DocType.Other)); lhs.add(new DocSpan(1, DocType.convenor)); lhs.add(new DocSpan(1, DocType.Other)); addProduct(rhs, lhs); 实际运行时,用户所要提取的信息是一次提取出来的,所以以上提到的提取QQ号码和召集人相关的类型是定义在同一个枚举类型中的。代码如下: public enum DocType { QQinfo, // 要提取的QQ号 Fixedlinetelephone, // 固定电话 Mobiletelephone, // 移动电话 Convenor, // 活动召集人 PeopleNumber, // 活动人数 EmailInfo, // email信息 GuillemetStart, // 开始符号 GuillemetEnd, // 结束符号 Link, // 链接符号 Num, // 数字 Start, // 虚拟类型,开始状态 End, // 虚拟类型,结束状态 Date, // 日期 QQPrefix, // QQ前缀 QQSuffix, // QQ后缀 MobilePhonePrefix, // 移动电话号码前缀 - - 其他高级主题 MobilePhoneSuffix, // 移动电话号码后缀 CallerPrefix, // 召集人前缀 CallerSuffix, // 召集人后缀 AttenderPrefix, // 参与人数前缀 AttenderSuffix, // 参与人数后缀 EmailPrefix, // Email前缀 EmailMiddle, // Email中间字段 EmailSuffix, // Email后缀 CityInfo, // 出发城市 CityPrefix, // 出发城市前缀 CitySuffix, // 出发城市后缀 DetailAddressInfo, // 详细地址 DetailAddressPrefix, // 详细地址前缀 DetailAddressSuffix, // 详细地址后缀 English, // 英文字母 Unknow, // 未知的 Other //其他 } 这个枚举类型里定义了所要提取的QQ、召集人、电话号码、城市、邮件和详细地址信息。只要分别定义相关对应的规则,系统在扫描一次文本后,会一次性地将上述要提取的信息提取出来。 11.3 分类 11.3.1 活动分类 需要把抓取过来的户外活动分类。把户外活动分成:运动、徒步、自驾、游泳、登山、滑雪、骑行、休闲、聚会、旅行、户外、公益一共12个类别。 因为对一个活动的描述往往很短,所以采用简单的基于关键词匹配的分类方法。根据标题分类的实现代码如下: String[] keywords = new String[] {"漫步", "夜游", "徒步", "步行", "拉练", "远足","远行","压路机"}; Rule r = new Rule(keywords, "徒步"); decisionList.add(r); keywords = new String[] { "拼车", "自驾", "驾驶", "远征", "驾车" ,"开车","驾","搭车"}; r = new Rule(keywords, "自驾"); - - 其他高级主题 decisionList.add(r); keywords = new String[] { "船潛", "潜水", "船潜", "船宿", "游泳", "潜泳", "游水","仰泳", "蝶泳", "花样游泳", "蛙泳" }; r = new Rule(keywords, "游泳"); decisionList.add(r); keywords = new String[] { "山","登", "登山", "爬山", "登高", "高峰", "夜爬", "攀登", "登顶" ,"爬","峰","主峰","雪山","山峰","上山","岭"}; r = new Rule(keywords, "登山"); decisionList.add(r); keywords = new String[] { "滑", "滑雪", "雪地", "滑冰", "溜冰" }; r = new Rule(keywords, "滑雪"); decisionList.add(r); keywords = new String[] { "骑行", "骑车", "单骑", "单车", "自行车", "摩托车" ,"骑士"}; r = new Rule(keywords, "骑行"); decisionList.add(r); 11.3.2 资讯分类 11.4 搜索 根据行业特点设计出不同的搜索栏目。前期设计出自助游活动搜索、商业旅游线路搜索、旅游新闻搜索、旅行攻略搜索四大功能。采用迭代式开发方法,首先实现活动搜索,然后再实现剩下的三个,以后再考虑增加酒店搜索等功能。设计把不同的信息存放在不同的索引库中。自助游活动搜索、商业旅游线路搜索、旅游新闻搜索、旅行攻略搜索各自使用独立的索引库。 在活动搜索结果页面集成当地的天气信息。活动搜索结果页面显示发起人的联系信息,例如QQ号码,用户可以根据链接直接加QQ。 在前期搜索访问量小,为了节省硬件成本,提高系统的运行效率,采用Lucene而没有采用Solr。 1798户外搜索引擎具有同义词检索功能,可以根据用户使用特点自定义大量同义词库,提高搜索全面性。如搜索“周一”,同样可以搜索到内容中有“星期一”的活动。如搜索“跑步”会有“夜跑”等信息。搜索爬山会返回夜爬等信息。 可以考虑增加一个手机可以浏览的WAP网站。数据来源一样,只是方便手机在较小的手机屏幕访问。 - - 其他高级主题 11.5 本章小结 本章介绍了一起走吧户外活动搜索(http://www.1798hw.com)。 这个项目最开始的时候,爬虫和搜索运行在同一台服务器,后来则分开成独立的爬虫服务器和搜索服务器。爬虫抓下来的数据形成索引后,把索引同步到搜索服务器。以后可以考虑采用Solr,把前台界面和后台提供搜索数据独立出来。 第12章 英文价格搜索 - - 本书中的章节和代码对照表 专业英语词汇列表 algorithm 算法 applications 应用程序,可执行程序 code 代码 mapping 映射 pattern 模式 crash 崩溃 node 节点 port 端口 crawler 爬虫 bubble冒泡 span 跨度,往往通过开始和结束位置来描述跨度 dispatcher 分发器,例如Struts中的分发器分发HTTP请求给对应的Action dependency 依赖 compile 编译 nutshell 简介、概述 download 下载 essential 必要的 source 来源,源代码 distribution 发布包 alternative 可选的 - - 本书中的章节和代码对照表 documentation 文档 verify 验证 entity 实体 implementation 实现 annotate 标注 keystroke 按键 hexadecimal 十六进制 implement 实现,编码 duplicate 复制 Interface 接口 cache 高速缓存 frequency 频率 time-consuming operation 耗时操作 online 网上的 symbolic 代号 terminal 终端机 transmit 传输 adapter 适配器 register 记录,登记簿 Bit 位 Byte 字节 reduced 简化的 - - 本书中的章节和代码对照表 recommend 建议 template 模板,样板 memory leak 内存泄露 - - 本书中的章节和代码对照表 参考资源 书籍 ERIK HATCHER 和 OTIS GOSPODNETIC. “Lucene in Action” Bing Liu. “Web Data Mining” Haralambos Marmanis and Dmitry Babenko. “Algorithms of the Intelligent Web” Toby Segaran. “Programming Collective Intelligence” W.Bruce Croft,Donald Metzler,Trevor Strohman.“Search Engines: Information Retrieval in Practice” 网址 网址 说明 http://lucene.apache.org/ Lucene主站点。 http://nlp.stanford.edu/IR-book/ 图书《Introduction to Information Retrieval》网络版 http://trec.nist.gov/ 是文本检索会议(TREC) 的网站。TREC为检索系统提供标准语料,标准方法,每年开一次评测会。 http://www.sigir.org/ 是美国计算机协会情报检索专业组(SIGIR)的网站。SIGIR是一个学术界与工业界年年举行交流的盛会。 http://www.nltk.org/book Natural Language Processing with Python - -

下载文档到电脑,查找使用更方便

文档的实际排版效果,会与网站的显示效果略有不同!!

需要 30 金币 [ 分享文档获得金币 ] 62 人已下载

下载文档