用JavaScript实现对PDF的全文索引

内容摘要
我曾今在一个售卖法律和财务数据库访问方案(他们称之为“智能信息”)的公司工作。大多数法庭记录都是通过PACER以PDF形式提供的,一个站点被特地开发出来
文章正文

我曾今在一个售卖法律和财务数据库访问方案(他们称之为“智能信息”)的公司工作。大多数法庭记录都是通过PACER以PDF形式提供的,一个站点被特地开发出来用于发布法庭记录。基于这个数据集的一个意义重大的数据库产品需要建立一条处理管道,它能够从超过两亿分份PDF文档中提取文本并对其进行索引,展示美国超过20年的诉讼记录。这些处理过程将花费数月的机器时间,使得软件工作组在构建它们时的面临很大的压力。在这一处理过程中的早期有个一步骤是从电子文档化的PDF中提取出内容,其在稍后的将会被送入一个NLP处理阶段——显示关键字,标注部分词类,识别实体,而然后发出报告。

Mozilla实验室最近已经收到了许多为一个项目做出的尝试,这一项目的野心令人印象深刻:在一个浏览器中仅仅使用Javascript来对PDF进行渲染。PDF文档的结构令人难以置信的复杂,因此要祝pdf.js工作组的兄弟们好运了!在另外一条不同的尝试道路上,Oliver Nightingale使用Javascript实现了一个的Javascript全文索引装置——将这两个项目结合起来,就可以在web浏览器中完全再现PDF处理管道。

站在一名新手的角度来看,全文索引能用户可以搜索非结构化的文档,也可以依据由词频决定的相关度分值来对结果文档进行排名。索引装置会计算每一个份文档中每一个词出现的次数,并且对文本进行最轻微的修改,以移除内容中跟搜索无关的一些文本语法特性。例如,它可能会提取出“-ing”,将元音部分变更为一般的表示形式。如果一个词语频繁出现在整个文档集中,索引装置会自动将其识别为不那么重要的关键词,而它对排名结果的影响将会被最小化。这同Google PageRank背后的基本概念是不同的,后者是基于一个引征图来提升文档排名的。

大多数数据库软件都提供了对全文索引的支持,但如果是大规模安装的话,通常会使用功能更加强大的工具来进行处理。开源产品中主要是Solr/Lucene,Solr是围绕Lucene库封装的一个web应用。它们都是用Java编写的。

构造一个Javascript全文索引装置使得搜索在诸如Phonegap引用,终端用户机或者加密存储的用户数据这些之前很难实现搜索功能的地方成为可能。有一整个领域只研究加密的搜索指数,而在客户机上对数据进行索引和加密看上去像是围绕这个天生具有挑战性的问题想出的一个好办法。

为了测试这个处理管道,我们首先来看看如何从PDF中提取文本,这些文本将在稍后被插入到一个全文索引中。pdf.js的代码是很有启发性的,其中Mozilla的开发者们使用了一些并不常用的浏览器特性,举个例子,Web工作者,会要你设置后台的处理线程。

pdf.js 的 API大量使用约定来持有代码中未完成操作的引用。你会使用回调来对它们进行操作:

var pdf = PDFJS.getDocument('http://www.pacer.gov/documents/pacermanual.pdf');
 
var pdf = PDFJS.getDocument('pacermanual.pdf');
pdf.then(function(pdf) {
 // this code is called once the PDF is ready
});

这样的API看起还不怎么成熟——理想情况下你应该能够写出 promise.then(f(x)).then(g(x)).then(h(x)) 等等代码,但现在那还是不可用的。

约定模式在渲染PDF方面起了很大的作用,因为它为并行的渲染处理留下了空间。对于只是从一份PDF中提取出文本感觉上好像有大量的工作要做——你必须相信你的回调会按照秩序运行并且跟踪到哪个是在最后。

下面的示例代码演示了提取PDF内容,并在浏览器中控制台日志中输出:

‘use strict’;
var pdf = PDFJS.getDocument('http://www.pacer.gov/documents/pacermanual.pdf');
 
var pdf = PDFJS.getDocument('pacermanual.pdf');
pdf.then(function(pdf) {
 var maxPages = pdf.pdfInfo.numPages;
 for (var j = 1; j <= maxPages; j++) {
    var page = pdf.getPage(j);
 
    // the callback function - we create one per page
    var processPageText = function processPageText(pageIndex) {
      return function(pageData, content) {
        return function(text) {
          // bidiTexts has a property identifying whether this
          // text is left-to-right or right-to-left
          for (var i = 0; i < text.bidiTexts.length; i++) {
            str += text.bidiTexts[i].str;
          }
 
          if (pageData.pageInfo.pageIndex ===
              maxPages - 1) {
            // later this will insert into an index
            console.log(str);
          }
        }
      }
    }(j);
 
    var processPage = function processPage(pageData) {
      var content = pageData.getTextContent();
 
      content.then(processPageText(pageData, content));
    }
 
    page.then(processPage);
 }
});

这并不会识别页眉和图片.如何识别这些内容需要使用渲染代码,需要非常理解PDF命令(PDF可能使用流渲染命令,类似于RTF)

Lunr

创建一个Lunr函数直接添加字段-所有的API都使用JSON类型,以下是一个简单的AIP示例

doc1 = {
    id: 1,
    title: 'Foo',
    body: 'Foo foo foo!'
  };
 
doc2 = {
    id: 2,
    title: 'Bar',
    body: 'Bar bar bar!'
  }
 
doc3 = {
    id: 3,
    title: 'gary',
    body: 'Foo Bar bar bar!'
  }
 
index = lunr(function () {
    this.field('title', {boost: 10})
    this.field('body')
    this.ref('id')
  })
 
// Add documents to the index
index.add(doc1)
index.add(doc2)
index.add(doc3)

搜索也很方便,一个简单的方法可以查询索引,因为它只是一个JS对象:

// Run a search
index.search(“foo”)
 
// Inspect the actual index to see which docs match a term
index2.tokenStore.root.f.o.o.docs

当我第一次接触全文索引,我对他所谓的"文档"有所迷惑-它包括了一个PDF或者一个办公文档以及任何一个数据库,很可能包括大堆的文本.

如果你不得不时刻构建索引,全文索引将会是愚蠢的,而Lunr则使索引自身的序列化和反序列化变得真正简单起来:

var serializedIndex = JSON.stringify(index1.toJSON())
var deserializedIndex = JSON.parse(serializedIndex)
var index2 = lunr.Index.load(deserializedIndex)

Index.toJSON也会返回一个“bean”风格的对象(而不是一个string)。我从来没有见过像这样的API,但是我喜欢这个创意——它给了你一个干净的Javascript对象,只带有需要被序列化的数据。

下面是索引的属性:

  • corpusTokens – 已经排好序的token列表
  • documentStore – 每一份文档的列表 – 系
  • fields – 用来描述每一份文档的域 (类似于数据库中列)
  • pipeline – 用来处理token的管道对象
  • tokenStore – 每一份文档中关键词出现的位置和频率

这种索引最棒的一个特性是作业可以并行完成,然后作为一个map-reduce作业被整合。上述对象只有三个条目需要被整合,因为“域”和“管道”是静态的。下面就展示了再现步骤的实现(注意jQuery被引入了):

(function reduce(a, b) {
  var j1 = a.toJSON();
  var j2 = b.toJSON();
 
  // The "unique" function does uniqueness by sorting,
  // which we need here.
  var corpusTokens =
      $.unique(
          $.merge(
              $.merge([], j1.corpusTokens),
                           j2.corpusTokens));
 
  // It's important to create new arrays and
  // objects throughout, or else you modify
  // the source indexes, which is disastrous.
  var documentStore =
     {store: $.extend({},
                      j1.documentStore.store,
                      j2.documentStore.store),
      length: j1.documentStore.length + j2.documentStore.length};
 
  var jt1 = j1.tokenStore;
  var jt2 = j2.tokenStore;
 
  // The 'true' here triggers a deep copy
  var tokenStore = {
    root: $.extend(true, {}, jt1.root, jt2.root),
    length: jt1.length + jt2.length
  };
 
  return {version: j1.version,
          fields: $.merge([], j1.fields),
          ref: j1.ref,
          documentStore: documentStore,
          tokenStore: tokenStore,
          corpusTokens: corpusTokens,
          pipeline: $.merge([], j1.pipeline)};
})(index1, index2)

通过创建三个索引我测试了这段代码:index1,index2和index3。index1是{doc1},index2是{doc2,doc3},而index3则是{doc1,doc2,doc3}。为了测试这段代码,你需要简单的改变:

JSON.stringify(index3.toJSON())
 
JSON.stringify(combine(index1, index2))

可能性

总的来说这项技术很浪费网络I/O,使得这看起来很傻。从另外一方面来看,ebay和fiberr上待售清单上充斥着“网络交通流量”的叫卖,通常来自背后弹出式广告,僵尸网络,隐藏的iframe等等。你能很容易的发现像“3美元20000次点击”的列表,小批量的。因为没有多少商业价值它通常是很便宜的,此外还得犯下各种形式的欺诈行为。

你需要一个便宜点的VM负载作为一个代理的带宽,以及公共可用的数据——你不能将其作为一项针对浏览器跨域请求保护的搜刮技术。你也需要使用一种独特的方式来生成单独的文档ID,也许要使用到原生的URL。

如果一种交通资源在现代浏览器上面运行,某些人就可能会将其作为一种潜在的廉价且拥有无限制处理能力的资源来加以利用,即使是出于整合索引的目的,尽管必须针对系统的自然不稳定性做出规定。

代码注释

作者:喵哥笔记

IDC笔记

学的不仅是技术,更是梦想!