设计思路
(1)使用Lucene3.6.0版本,由于之前尝试使用IK分词器,加载到Lucene中,让Lucene自动分词,然后建立索引,但是IK分词器一直报错,所以我选择自己使用HanLP分词之后,在使用Lucene建立倒排索引。
(2)使用建立好的倒排索引,快速获取所有文档的TF-IDF值
词频(term frequency)TF
单个文章的词频,词在文档中出现的词频 词在文档中出现的频度是多少? 频度越高,权重 越高 。 5 次提到同一词的字段比只提到 1 次的更相关。
逆向文档频率(inverse document frequency)IDF
词在这篇文档中出现过次数/词在所有文章出现的次数 词在集合所有文档里出现的频率是多少?频次越高,权重 越低 。 常用词如 and 或 the 对相关度贡献很少,因为它们在多数文档中都会出现,一些不常见词如 elastic 或 hippopotamus 可以帮助我们快速缩小范围找到感兴趣的文档。
(3)以TF-IDF值作为向量,计算两个文本(两个向量)之间的余弦相似度
(4)对每一个文本,计算出与它最相似的的top10的文本,将其存在Redis中, 可以通过articleId取出与此博客文章相似的其他博客文章的id,然后返回。
说明:由于我们的博客是定时抽取的,所以这一步的分词,建立索引,然后计算所有文本的其他相似文本,将其提前存入Redis中,都在抽取之后进行,系统运行过程中的基于相似度的推荐都是直接根据articleId从Redis中读取数据,所以系统的响应还是很快的。
具体实现
(1)maven的有关Lucene的依赖
<!-- https://mvnrepository.com/artifact/org.apache.lucene/lucene-core -->
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
<version>3.6.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.lucene/lucene-queryparser -->
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-queryparser</artifactId>
<version>3.6.0</version>
</dependency>
(2)在每一次定时抽取完成以后,读取数据库内容,对每一个文本进行分词后,使用Lucene创建索引,提前物化,便于计算文本相似度
/**
* 将文本分词后,用" "隔开
*
* @param sentence
* @return
*/
public String cutWord(String sentence) {
List<com.hankcs.hanlp.seg.common.Term> termList = HanLP.segment(sentence);
List<String> wordList = new ArrayList<>();
//是否应当将这个term纳入计算,词性属于名词、动词、副词、形容词
for (com.hankcs.hanlp.seg.common.Term term : termList) {
if (shouldInclude(term)) {
wordList.add(term.word);
}
}
String result = String.join(" ", wordList);
return result;
}
/**
* 读取数据库内容,并且创建索引 提前物化
*
* @throws IOException
*/
public void writeFile() throws IOException {
//删除文件夹下所有文件
File file = new File(INDEX_DIR);
deleteFile(file);
//获取存储器的数据存储位置目录
Directory directory = getDirectory();
//创建一个索引创建的类
IndexWriterConfig config = new IndexWriterConfig(Version.LUCENE_36, new WhitespaceAnalyzer(Version.LUCENE_36));
//创建写入数据的流
IndexWriter iwriter = new IndexWriter(directory, config);
//从第几页开始 默认从0开始
int curPage = 0;
//每次写入两条
int size = 20;
//数据库中的文章总条数
articleNumbers = articleService.selectArticleCount();
int totalPage = articleNumbers / size;
while (curPage <= totalPage) {
List<Article> articles = articleService.selectArticleList(size, curPage * size);
for (Article article : articles) {
//将article对象转换为Document元素,
Document doc = getDocumentByArticle(article);
//写入磁盘
iwriter.addDocument(doc);
}
curPage++;
}
//关闭写入流
iwriter.close();
}
/**
* 因为Lucene是根据Document存储的,所以要将Article对象转换为Document元素,
*
* @param article
* @return
*/
public Document getDocumentByArticle(Article article) {
//创建一个存储数据的Document
Document doc = new Document();
// 文章id
// 不分词、索引、存储
Field articleId = new Field("id", new StringReader(String.valueOf(article.getId())), TermVector.YES);
doc.add(articleId);
//创建该类的属性, 分词、索引、存储 TextField
String verbalContent = cutWord(article.getVerbalContent());
Field content = new Field("verbalContent", new StringReader(verbalContent), Field.TermVector.YES);
doc.add(content);
return doc;
}
(3)由于我们要将每一篇文章存入redis,这里使用redis的set结构进行存储,key为文章id,value为与key相似的文章id集合,有关set存取方法的实现
/**
* 给指定set添加元素 sadd
* @param key
* @param value
*/
public void setAdd(String key, String value) {
redisTemplate.opsForSet().add(key, value);
}
/**
* 删除指定set中的值 不需要指定位置 srem
*
* @param key
* @param value
*/
public void setremove(String key, String value) {
redisTemplate.opsForSet().remove(key, value);
}
/**
* 判断是否包含 sismember
*
* @param key
* @param value
*/
public void setContains(String key, String value) {
redisTemplate.opsForSet().isMember(key, value);
}
/**
* 获取集合中所有的值 smembers
*
* @param key
* @return
*/
public Set<String> setGetValues(String key) {
return redisTemplate.opsForSet().members(key);
}
(5)在建立完索引之后,遍历文章列表,使用Lucene建立好的倒排索引,获得文章的TF-IDF值,然后使用TF-IDF计算文章的余弦相似度,然后将每篇文章最相似的其他文章的id存入redis的set中。
/**
* 获取所有文档的tf-idf值
*
* @return
* @throws IOException
* @throws ParseException
*/
public HashMap<String, Map<String, Float>> getAllTFIDF() throws IOException, ParseException {
HashMap<String, Map<String, Float>> scoreMap = new HashMap<String, Map<String, Float>>();
Directory directory = getDirectory();
IndexReader re = IndexReader.open(directory);
articleNumbers = articleService.selectArticleCount();
for (int k = 0; k < articleNumbers; k++) {
//每一个文档的tf-idf
Map<String, Float> wordMap = new HashMap<String, Float>();
//获取当前文档的内容
TermFreqVector[] a = re.getTermFreqVectors(k);
String articleId = a[0].getTerms()[0];
// TermFreqVector articleId = re.getTermFreqVector(k,"id");
TermFreqVector articleContent = re.getTermFreqVector(k, "verbalContent");
if (articleContent == null) {
continue;
}
int[] freq = articleContent.getTermFrequencies();
String[] terms = articleContent.getTerms();
int noOfTerms = terms.length;
DefaultSimilarity simi = new DefaultSimilarity();
for (int i = 0; i < noOfTerms; i++) {
int noOfDocsContainTerm = re.docFreq(new Term("verbalContent", terms[i]));
float tf = simi.tf(freq[i]);
float idf = simi.idf(noOfDocsContainTerm, articleNumbers);
wordMap.put(terms[i], (tf * idf));
}
scoreMap.put(articleId, wordMap);
}
return scoreMap;
}
/**
* 获取查找文本的tf-idf
*
* @param termList
* @return
* @throws IOException
*/
public Map<String, Float> getSearchTextTfIdf(List<String> termList) throws IOException {
//统计每一个词,在文档中的数目
Map<String, Integer> termFreqMap = new HashMap<>();
for (String term : termList) {
if (termFreqMap.get(term) == null) {
termFreqMap.put(term, 1);
continue;
}
termFreqMap.put(term, termFreqMap.get(term) + 1);
}
Map<String, Float> scoreMap = new HashMap<String, Float>();
Directory directory = getDirectory();
IndexReader re = IndexReader.open(directory);
DefaultSimilarity simi = new DefaultSimilarity();
articleNumbers = articleService.selectArticleCount();
for (String term : termList) {
int noOfDocsContainTerm = re.docFreq(new Term("verbalContent", term));
float tf = simi.tf(termFreqMap.get(term));
float idf = simi.idf(noOfDocsContainTerm, articleNumbers);
scoreMap.put(term, (tf * idf));
}
return scoreMap;
}
解释:这里在获取文章TF-IDF的时候,使用了Lucene的IndexReader类来对索引进行读取。而这个IndexReader类在新版本Lucene中已经被取消了,所以我只能选用老版本的Lucene3.6来实现我想要的功能
/**
* 每次抽取完成,并且建立索引之后,都计算每篇文章10个相似的文章,存入redis,方便取用
*/
public void init() throws IOException, org.apache.lucene.queryParser.standard.parser.ParseException {
//从第几页开始 默认从0开始
int curPage=0;
//每次写入两条
int size=20;
//数据库中的文章总条数
int articleNumbers = articleDao.selectArticleCount();
int totalPage = articleNumbers/size;
while (curPage<=totalPage)
{
List<Article> articles = articleDao.selectArticleList(size,curPage*size);
//计算文章的相似文章 存入redis
for(Article article :articles)
{
int articleId = article.getId();
List<Integer> similarityArticleList = luceneUtil.searchSimilarity(articleId,SIZE);
//加入redis的set中
for (int i :similarityArticleList)
{
System.out.println(i);
if(i != articleId)
{
redisUtils.setAdd(SET_KEY+String.valueOf(articleId) , String.valueOf(i));
}
}
}
curPage++;
}
}
(6)在进行相似文章推荐时,只需要调用响应的接口,然后从redis中获取数据即可
/**
* 寻找与此文章相似的size个文章 通过文本的相似度进行推荐
* @param articleId 文章id
* @param size 推荐的相似文章个数
* @return 返回相似文章的id列表
* @throws IOException
*/
public List<Integer> searchSimilarity(int articleId,int size) throws IOException, ParseException
{
Article article = articleService.getArticleById(articleId);
String searchText = article.getVerbalContent();
//去除标点符号,特殊字符
String content = searchText.replaceAll("[\\p{P}+~$`^=|<>~`$^+=|<>¥×]", "");
content = content.replaceAll("\\t|\\r|\\n","");
content = content.replaceAll(" ","");
// JiebaSegmenter segmenter = new JiebaSegmenter();
List<String> strings =cutWord(content);
Map<String, Float> searchTextTfIdfMap = luceneManager.getSearchTextTfIdf(strings);
HashMap<String, Map<String, Float>> allTfIdfMap = luceneManager.getAllTFIDF();
//利用余弦相似度求出与所有文档的相似值
Map<String, Double> docSimMap = cosineSimilarity(searchTextTfIdfMap, allTfIdfMap);
//找出最相似的size个
List<Map.Entry<String, Double>> list = new ArrayList<Map.Entry<String, Double>>(docSimMap.entrySet());
Collections.sort(list, new Comparator<Map.Entry<String, Double>>() {
@Override
public int compare(Map.Entry<String, Double> o1, Map.Entry<String, Double> o2) {
if (o2.getValue() > o1.getValue()) { return 1; } else if (o2.getValue() < o1.getValue()) {
return -1; } else {
return 0; }
}
});
int index = 0;
List<Integer> result = new ArrayList<>();
for (Map.Entry<String, Double> t : list) {
result.add(Integer.valueOf(t.getKey()));
if (++index > size-1) {
break;
}
}
return result;
}
本地索引的结果
基于文本相似度的推荐,完成!