從 WordCount 到文檔的倒排索引詳解

概述

倒排索引源於實際應用中需要根據屬性的值來查找記錄。這種索引表中的每一項都包括一個屬性值和具有該屬性值的各記錄的地址。由於不是由記錄來確定屬性值,而是由屬性值來確定記錄的位置,因而稱爲倒排索引(inverted index)。帶有倒排索引的文件我們稱爲倒排索引文件,簡稱倒排文件(inverted file)。
——摘自《百度百科》


版權說明

著作權歸作者所有。
商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。
本文作者:Q-WHai
發表日期: 2016年6月13日
本文鏈接:http://blog.csdn.net/lemon_tree12138/article/details/51659657
來源:CSDN
更多內容:分類 >> 大數據之 Hadoop


前言

在很多大數據的應用場景中我們都有可能看到倒排索引的身影,我第一次接觸倒排索引是在學習 Lucene 全文檢索框架的時候。本文會從倒排索引開始說明,再補充講解倒排索引文檔及帶權重的倒排索引文檔。你是不是想說這些不都是同一個東西麼?顯然,他們不是同一個東西。


倒排索引

根據上面的概述所言,我相信你應該已經對倒排索引的原理有了一個初步的認識。如果你有一些編程的功底,那麼基於某種編程語言寫一個倒排索引的程序應該不難。只是如何將這個倒排索引程序翻譯成 Hadoop 中 的 MapReduce 程序呢?這是需要探討的問題。
如果你理解了前面的 WordCount 程序的話,尤其是 Mapper 和 Reducer 的過程,那麼倒排索引對你來說也就是一菜一碟了。

輸入的文件格式

要讓 MapReduce 程序正確的運行,我們首先要確保 MapReduce 的輸入是正確的。反映在文件中就是文件的格式需要提前被規定好。現在我們的文件輸入格式定義如下:

<key_name>:<value1> <value2> <value3> ... <value4>

在輸入的格式中,我們把 key 與 values 用英文冒號( : )分隔開了。而 value 與 value 之間則是使用空格分隔開。
假設這裏的 key 是一個代表文件的文件名稱,後面的 values 則是文件中的內容(單詞)。根據格式我們編寫了如下的幾組測試樣例:
file01

file01:hello world hello today

file02

file02:hi today funny day

file03

file03:face day face world

分佈式運行的過程

對於文件從本地上傳到 HDFS 的過程不是本文的討論範圍,如果你感興趣可以查閱相關資料,理解上也並不困難。
當倒排索引的 MR 程序運行時,其過程大致可以用下圖進行表示:

這裏寫圖片描述

  1. 首先我們從 HDFS 的 DataNode 節點上讀取數據(讀取時是隨機讀取每個數據備份中的一個)。比如,file01 和 file02 是在 datanode0 上讀取的,而 file03 是在 datanode1 上讀取的;
  2. 在讀取文件時,是以行爲單位進行讀取,每一行都將以行號作爲 key,這一行的本文作爲 value 讀取,並將此 key:value 傳入 Mapper;
  3. 經過 Mapper.map() 函數的處理,會形成以本文內容爲 key,文本的第一個 word 爲 value 的局部倒排索引序列。就像上圖中的 3 號位置所示;
  4. 將第 3 步形成的局部倒排索引進行 Reducer.reduce() 函數的整合形成最終結果。

MapReduce 程序編寫

這裏的代碼邏輯與代碼結構與之前的 WordCount 程序有很多相似的地方。而與 WordCount 程序不同的是,InvertedIndex 在 Mapper 中需要對文件中的內容進行區別對待,因爲文件的最開始是文件名的 key;在 Reducer 中累加的不是單詞的個數,而是 value 字符串的適當疊加。具體代碼如下:

import java.io.IOException;
import java.util.StringTokenizer;

import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.Reducer;

public class InvertedCoreMR {

    public static class CoreMapper extends Mapper<Object, Text, Text, Text> {
        @Override
        protected void map(Object key, Text value, Mapper<Object, Text, Text, Text>.Context context)
                throws IOException, InterruptedException {
            String[] splits = value.toString().split(":");
            StringTokenizer tokenizer = new StringTokenizer(splits[1]);
            while (tokenizer.hasMoreTokens()) {
                context.write(new Text(tokenizer.nextToken()), new Text(splits[0]));
            }
        }
    }

    public static class CoreReducer extends Reducer<Text, Text, Text, Text> {
        @Override
        protected void reduce(Text key, Iterable<Text> values, Reducer<Text, Text, Text, Text>.Context context)
                throws IOException, InterruptedException {
            if (null == values) {
                return;
            }

            StringBuffer filesBuffer = new StringBuffer();
            boolean firstFlag = true;
            for (Text invertedFile : values) {
                filesBuffer.append((firstFlag ? "" : ", ") + invertedFile.toString());
                firstFlag = false;
            }

            context.write(key, new Text(filesBuffer.toString()));
        }
    }
}

InvertedIndex 的客戶端程序與 WordCount 的客戶端基本上是一樣的。

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import org.apache.hadoop.util.Tool;
import org.apache.hadoop.util.ToolRunner;
import org.demo.index.inverted.core.InvertedCoreMR;

public class InvertedClient  extends Configuration implements Tool {

    public static void main(String[] args) {
        InvertedClient client = new InvertedClient();

        try {
            ToolRunner.run(client, args);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @Override
    public Configuration getConf() {
        return this;
    }

    @Override
    public void setConf(Configuration arg0) {
    }

    @Override
    public int run(String[] args) throws Exception {
        Job job = new Job(getConf(), "File Inverted Index");
        job.setJarByClass(InvertedCoreMR.class);

        job.setMapperClass(InvertedCoreMR.CoreMapper.class);
        job.setReducerClass(InvertedCoreMR.CoreReducer.class);

        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(Text.class);

        FileInputFormat.addInputPath(job, new Path(args[0]));
        FileOutputFormat.setOutputPath(job, new Path(args[1]));

        return job.waitForCompletion(true) ? 0 : 1;
    }
}

結果展示

day     file02, file03
face    file03, file03
funny   file02
hello   file01, file01
hi      file02
today   file02, file01
world   file01, file03

優化

在上面的結果展示中可以看到 face 和 hello 這兩個單詞分別出現在了兩個相同的文件中。這不是一個好的用戶體驗,所以需要優化。優化的邏輯就是過濾。如果你有一些項目經驗的話,你可能會已經想到要 BloomFilter 或是字典樹之類的。這兩種過濾方案的確是很好的,只是這裏我就不使用 BloomFilter 和字典樹了,感覺有一些“大材小用”了。直接使用 Java 提供的 HashSet 會更好一些。我一直比較提倡的做法就是,不要在一個小功能上使用大工具或是大框架,這樣會讓你的程序顯得臃腫肥大,且沒有什麼實用價值。
可能你會說這裏不應該直接使用過濾,應該進行詞頻的統計。是的,的確是要做詞頻統計,你先別急,咱們一步步來,後面會作介紹的。現在只對重複的文件名進行過濾就 ok 了。

import java.util.HashSet;
import java.util.Set;
... ( 此處省略 N 行 ) ...

public class InvertedCoreMR {

    public static class CoreMapper extends Mapper<Object, Text, Text, Text> {
        @Override
        protected void map(Object key, Text value, Mapper<Object, Text, Text, Text>.Context context)
                throws IOException, InterruptedException {
            ... ( 此處省略 N 行 ) ...
            Set<String> filterSet = new HashSet<>();
            while (tokenizer.hasMoreTokens()) {
                String label = tokenizer.nextToken();
                if (filterSet.contains(label)) {
                    continue;
                }

                filterSet.add(label);
                ... ( 此處省略 N 行 ) ...
            }
        }
    }

    public static class CoreReducer extends Reducer<Text, Text, Text, Text> {
        @Override
        protected void reduce(Text key, Iterable<Text> values, Reducer<Text, Text, Text, Text>.Context context)
                throws IOException, InterruptedException {
            ... ( 此處省略 N 行 ) ...
            Set<String> filterSet = new HashSet<>();
            for (Text invertedFile : values) {
                if (filterSet.contains(invertedFile.toString())) {
                    continue;
                }

                filterSet.add(invertedFile.toString());
                ... ( 此處省略 N 行 ) ...
            }

            context.write(key, new Text(filesBuffer.toString()));
        }
    }
}

修改之後,結果就要好用得多了。

day     file03, file02
face    file03
funny   file02
hello   file01
hi      file02
today   file02, file01
world   file01, file03

倒排索引文檔

在上面的倒排索引中,我們是人爲給文件添加 key,也就是文件名來達到單詞映射文件名的目的。可是,如果我們的輸入文件中並不符合我們 MapReduce 程序的格式要求,那麼之前的做法就與我們的願望相悖了。我們要讓文檔檢索的時候更具一般性,那麼就不能限定文件名,而是應該讓程序去動態獲取。
所以,這裏我們首先介紹一下在 MapReduce 程序中如何動態獲取文件名,這一點是關鍵。
動態獲取文件名需要用到的兩個類分別是:InputSplit, FileSplit
它們所在的包名分別爲:

import org.apache.hadoop.mapreduce.InputSplit;
import org.apache.hadoop.mapreduce.lib.input.FileSplit;

我們可以在 Mapper 中通過如下兩句代碼獲取文件名:

InputSplit inputSplit = context.getInputSplit();
String fileName = ((FileSplit)inputSplit).getPath().toString();

於是,我們修改了 CoreMapper.map() 方法:

public static class CoreMapper extends Mapper<Object, Text, Text, Text> {
    @Override
    protected void map(Object key, Text value, Mapper<Object, Text, Text, Text>.Context context)
            throws IOException, InterruptedException {

        InputSplit inputSplit = context.getInputSplit();
        String fileName = ((FileSplit)inputSplit).getPath().toString();

        StringTokenizer tokenizer = new StringTokenizer(value.toString());
        Set<String> filterSet = new HashSet<>();
        while (tokenizer.hasMoreTokens()) {
            String label = tokenizer.nextToken();
            if (filterSet.contains(label)) {
                continue;
            }

            filterSet.add(label);
            context.write(new Text(label), new Text(fileName));
        }
    }
}

運行 Hadoop 程序,然後,我們就可以得到如下的結果:

day     file:/home/hadoop/temp/inverted/file03, file:/home/hadoop/temp/inverted/file02
face    file:/home/hadoop/temp/inverted/file03
funny   file:/home/hadoop/temp/inverted/file02
hello   file:/home/hadoop/temp/inverted/file01
hi      file:/home/hadoop/temp/inverted/file02
today   file:/home/hadoop/temp/inverted/file02, file:/home/hadoop/temp/inverted/file01
world   file:/home/hadoop/temp/inverted/file01, file:/home/hadoop/temp/inverted/file03

這樣我們就完成了更加一般性的倒排索引文檔方案,程序的運行結果也展示了我們想要達到的效果。


帶權重的倒排索引文檔

上面對文檔中的重複數據是採用 HashSet 過濾,而在實際應用中我們不能這樣一概而論。比如,文檔 Doc1 中單詞總數爲 100 個,單詞 word1 的個數爲 20 個,比率爲 20% ;而文檔 Doc2 中的單詞總數爲 1000 個,單詞 word1 的個數爲 30 個,比率爲 3%。如果我們不進行詞頻統計,那麼這兩個文檔中 word1 的重要性是一樣的。這顯然與實際情況相悖。
所以這裏我們還需要對每個單詞進行詞頻統計。
我們修改了 Mapper.map() 方法,具體代碼如下:

public static class CoreMapper extends Mapper<Object, Text, Text, Text> {
    @Override
    protected void map(Object key, Text value, Mapper<Object, Text, Text, Text>.Context context)
            throws IOException, InterruptedException {

        InputSplit inputSplit = context.getInputSplit();
        String fileName = ((FileSplit)inputSplit).getPath().toString();

        StringTokenizer tokenizer = new StringTokenizer(value.toString());
        Map<String, Integer> freqMap = new HashMap<String, Integer>();
        while (tokenizer.hasMoreTokens()) {
            String label = tokenizer.nextToken();

            if (freqMap.containsKey(label)) {
                freqMap.put(label, freqMap.get(label) + 1);
            } else {
                freqMap.put(label, 1);
            }


            context.write(new Text(label), new Text(fileName + "," + freqMap.get(label)));
        }
    }
}

上面代碼的邏輯就是,如果發現一個單詞已經存在就將其記數器 +1. 如果單詞不存在,就新建記數器。記數器的選擇是 HashMap,這很好用,而且方便輕巧。

在 Reducer.reduce() 方法中需要修改的地方不多,因爲要展示詞頻,所以首先要獲取詞頻,並把詞頻在結果中顯示出來即可。具體代碼如下:

public static class CoreReducer extends Reducer<Text, Text, Text, Text> {
    @Override
    protected void reduce(Text key, Iterable<Text> values, Reducer<Text, Text, Text, Text>.Context context)
            throws IOException, InterruptedException {
        if (null == values) {
            return;
        }

        StringBuffer filesBuffer = new StringBuffer();
        boolean firstFlag = true;
        Set<String> filterSet = new HashSet<String>();
        for (Text invertedFile : values) {
            String fileName = invertedFile.toString().split(",")[0];
            int wordFreq = Integer.parseInt(invertedFile.toString().split(",")[1]);

            if (filterSet.contains(fileName)) {
                continue;
            }

            filterSet.add(fileName);
            filesBuffer.append((firstFlag ? "[" : ", [") + fileName + " : " + wordFreq + "]");
            firstFlag = false;
        }

        context.write(key, new Text(filesBuffer.toString()));
    }
}

修改後的結果展示

day     [file:/home/hadoop/temp/inverted/file02 : 1], [file:/home/hadoop/temp/inverted/file03 : 1]
face    [file:/home/hadoop/temp/inverted/file03 : 2]
funny   [file:/home/hadoop/temp/inverted/file02 : 1]
hello   [file:/home/hadoop/temp/inverted/file01 : 2]
hi      [file:/home/hadoop/temp/inverted/file02 : 1]
today   [file:/home/hadoop/temp/inverted/file02 : 1], [file:/home/hadoop/temp/inverted/file01 : 1]
world   [file:/home/hadoop/temp/inverted/file01 : 1], [file:/home/hadoop/temp/inverted/file03 : 1]

上面就是整個倒排索引的全部內容了,如果你任何疑問,歡迎留言。一起討論,一起進步。後面,我將更新 N 個 MapReduce 共同執行和 TF-IDF 的 MapReduce 實現(網絡上的那些 MR 版的 TF-IDF 文章有點看不下去了。。。),敬請期待。


GitHub

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章