按:本文浅谈信息检索是什么,为什么,怎么做等问题,主要内容是Manning等人著的《信息检索导论》前八张的读书笔记
问曰:信息检索的定义是什么?
答曰:
根据《信息检索导论》(Manning, Raghavan & Schütze, 2008)第一章:
Information retrieval (IR) is finding material (usually documents) of an unstructured nature (usually text) that satisfies an information need from within large collections (usually stored on computers).
翻译过来的大白话,“信息检索”是在一大堆非结构化的信息里面(通常是文本),找到符合需求的信息。
问曰: 为什么会产生信息检索?
或问曰: 信息检索这门技术用在什么地方?
或问曰: 信息检索的起源与演变过程是怎样的?
答曰:
为什么需要信息检索?最本源的原因就是,信息太多,找不过来,需要有相应的技术和工具辅助。信息越多,我们的需求越细致,就越要使用高级的技术和工具。
信息检索技术起源于图书馆书籍管理。这不难理解,在计算机和互联网出现以前,书籍就是信息的基本载体,图书馆就是信息的集中地。要在一本书籍中找到某个信息,比如“某个朝代发生的某件事情”,可以人工把书本翻个遍,找出来这个信息,这并不是难难事。但要在整个图书馆找到符合“某个朝代发生的某件事情”这个需求的信息,显然是不可能一本书一本书地去翻。因此,对书籍进行分类,就是必须的。分类以后,我们可以到“历史”这个类别里找到这个朝代的书籍,再翻书查找。“分类”就是最简单的信息管理手段,对信息分类,然后根据需求到特定的类别里去找找信息,就是最简单的信息检索方法。
进入互联网时代,计算机里的文档成为了信息的主要载体,互联网成为了新时代的图书馆(互联网本质上就是一个分布式文档系统)。自然而然地,分类方法在一开始也被应用到了互联网文档(主要是HTML文档)的检索上。而Yahoo!的成功案例也证明了分类方法在互联网初期也是非常有用的。但随着互联网内容的爆炸性增长,分类方法也逐渐失效了。没办法,即便设置几百个类别,每个类别内的内容也太多了。所以Yahoo!逐渐没落,而Google逐渐兴起。毕竟Google代表的全文搜索方法无论在有效性还是快捷性都比分类方法有优势。
至此,信息检索的发展主要脉络就是从图书馆书籍管理开始产生分类方法,然后在互联网时代分类方法不再适用,由此催生了全文检索方法,更准确地说,是建立索引,用索引来做检索的方法。
- 什么是索引?
索引,顾名思义,就是搜索的指引。任何能够指引我们尽快搜索到我们所求之物的东西,都是索引。若我们把每一本书的书名、作者等信息记录在案,找书时只要提供书名、作者等部分或全部信息,就能找到一本书。这种情况下,书名、作者信息的记录表就是索引。Google的全文检索把每一篇文档的每一个词记录在案,形成了一个全面而庞大的索引,所以能比分类法更准确的找到信息。顺带一提,这种角度看,分类法其实是索引法的特殊形式,因为类别也是一种索引。如此一来,用全文每个词做索引,和根据书本要义用类别做索引,孰优孰劣,就不难分别了。
问曰: 什么是信息检索?
- 或问曰: 信息检索的目标是什么? 实现目标的基本流程如何?
- 问曰: 信息检索的基本任务是什么? 其基本手段又是什么?
答曰:
信息检索的目标,或者说基本的任务,就是从一大堆信息中找到我们需要的某部分信息。进一步,我们缩小范围,使之更加具体:信息检索的目标是在一大堆文档等非结构化信息中根据我们的需求挑选出我们需要的部分文档。这其实就体现在Manning等人对信息检索的定义中。
那么,进行信息检索的基本流程有哪些?或者说为了达成信息检索的任务,我们要做哪些子任务?首先我们总得先对我们眼前的一大堆数据(在这里特指文档集),有一个清晰的认识。起码要知道这个文档集的规模如何,文档由哪些语言写成,如果是电子文档,还要检查一下编码之类的,这样我们就可以大概知道需要用什么方案。然后就可以建立索引,无论是类别,还是全文词项,又或是其他的辅助指引工具,都是索引。最后还要实现检索机制,比如制定一些图书馆借阅规定,或者开发一套计算机系统。
问曰: 目前最典型的信息检索方案是怎样的?
答曰(这一部分假定读者掌握线性代数的基础知识):
目前最常用最典型的信息检索任务,恐怕就是对网联网上的文档做检索了。而最典型的信息检索方案,便是web搜索引擎。那么web搜索引擎的工作原理又是怎样的呢?
Google和百度等大公司都会有web采集器,不断地、动态地从网上获取web文档(基本就是HTML文档)。采集回来的文档就是信息集,要在这么一大堆文档里(通常是百亿级规模)找出用户需要的文档,就需要索引了。
不过搜索引擎用到的索引到底长什么样?
我们要根据关键词把文档找出来,也就是说要针对文档中出现的每一个词给问你当建立索引。所以全文检索用到的索引就是一个以词为行,文档为列的“词-文档表格”,确切的说是“词-文档矩阵”。
上图就是一个“词-文档矩阵”,图中的行就是文档中出现的词,列就是文档的名字(都是莎士比亚的作品)。图中某行某列中的0代表该行的词不曾出现在该列的文档中,反之。因此“Antony”这个词出现在了“Julius Caesar”这篇文档中,而不曾出现在“Hamlet”中。 从这个矩阵可以清晰看出哪些词存在于哪些文档中,这个就是全文搜索会用到的索引,到信息检索里的术语称为“倒排索引”。
要知道,互联网上的文档数量是百亿级的,而其中包含的词可能也要数十万甚至上百万(瞎猜的),总之比任何一步词典收录的词都要多。这么大的矩阵,实在是太过占用空间了,哪怕是今天,计算机的内存都是宝贵资源啊。考虑到大规模文档集和大规模词表形成的“词-文档矩阵”中会有很多0存在,我们就不难想到采用稀疏表示的方式来存储这个矩阵。而上图的索引也会变成下面的样子。
图中左边是词项,右边则是文档(编号)列表。“Brutus”对应的列表里有1、2、4等号码,意味着文档1、文档2、文档4里包含着“Brutus”这个词。
那么这个索引是怎么构建出来的呢?
在建索引前肯定要做一些预处理。常见的预处理可能会有识别文档编码(UTF-8?GBK?ASKII?),选用正确的解码方式。然后就是分词。英文等拉丁语系的文字词与词之间有间隔,所以还不算难,但也要注意不能把“United Kingdom”这样的专有名词切开。而对于汉语等东亚的语言文字,就需要特殊的分词手段(比如条件随机场、马尔科夫链等)。分完词后还没完,一般还会把一些的都好、句号之类的符号去掉。最后可能还会考虑一下要不要把所有词换成小写,甚至进行词根还原(把词的动词、名词等各种形式统一映射到词根)等词项归一化,使得用户的查询能够匹配到更多可能相关结果。
经过了编码识别、分词、大小写转换、词项归一化等一系列预处理,我们把一个文档转换成词项流(可以看成由词组成的数组),因此文档集也就转换成了词项数组的集合。接下来我们就可以建立索引了。我们可以扫描一遍得到的词项流,得到一个“词-文档ID”流,然后把词项相同的元组合并,就得到了下图右面的倒排索引
Talk is cheap, show me your code!
—— Torvalds · Linus
下面是一段很简单的python代码,为一个简单的文档集构建倒排索引
"""
inverted_index.py
Build a basic (naive) inverted index for a simple (naive) documents set
Please Run this script using Python3.x
Tested under Python3.6, Win7 and Python3.5 ubuntu16.04
Author: Richy Zhu
Email: rickyzhu@foxmail.com
"""
import json
import re
from pprint import pprint
def clear_symbols(text):
"""remove symbols like commas, semi-commas
"""
simbols = re.compile("[\s+\.\!\/_,$%^*()+\"\']+|[+——!,。?、~@#¥%……&*():]+")
if type(text) is str:
processed_text = re.sub(simbols, ' ', text)
return processed_text
elif type(text) is list:
return [re.sub(simbols, ' ', item) for item in text]
else:
raise TypeError("This function only accept str or list as argument")
def lowercase(text):
"""turn all the characters to be lowercase
"""
if type(text) is str:
return text.lower()
elif type(text) is list:
return [item.lower() for item in text]
else:
raise TypeError("This function only accept str or list as argument")
def tokenize(docs):
token_stream = []
for doc in docs:
token_stream.append(doc.split())
return token_stream
def preprocess(docs):
"""clear symbols, lowercase, tokenize, get clean tokenized docs
"""
normalized_docs = lowercase(clear_symbols(docs))
tokenized_docs = tokenize(normalized_docs)
return tokenized_docs
def get_token_stream(tokenized_docs, docs_dict):
"""get (term-doc_id) stream
"""
token_stream = []
for doc_id in docs_dict:
for term in tokenized_docs[doc_id]:
token_stream.append((term, doc_id))
return token_stream
def build_indices(tokenized_docs, docs_dict):
"""main function -- build invertex index
assume that the documents set is small enough to be loaded into Memory
"""
token_stream = get_token_stream(tokenized_docs, docs_dict)
# pprint(token_stream)
indices = {}
for pair in token_stream:
if pair[0] in indices:
if pair[1] not in indices[pair[0]]:
indices[pair[0]].append(pair[1])
else:
indices[pair[0]] = [pair[1]]
return indices
if __name__ == "__main__":
docs = [
"hello world",
"hello python",
"I love C, Java, Python, Typescript, and PHP",
"use python to build inverted indices",
"you and me are in one world"
]
docs_dict = {
0: "docs[0]",
1: "docs[1]",
2: "docs[3]",
3: "docs[4]",
4: "docs[5]"
}
tokenized_docs = preprocess(docs)
# pprint(tokenized_docs)
indices = build_indices(tokenized_docs, docs_dict)
pprint(indices)
运行文件可以得到如下结果:
$ python3 inverted_index.py
{'and': [4],
'are': [4],
'build': [3],
'hello': [0, 1],
'i': [2],
'in': [4],
'indices': [3],
'inverted': [3],
'love': [2],
'me': [4],
'one': [4],
'python': [1, 2, 3],
'to': [3],
'use': [3],
'world': [0, 4],
'you': [4]}
当然,上面的索引构建程序有一个假设:文档规模不大,整个文档集索引的构建过程都可以在内存里完成。如果文档集过大,就要将其分割成较小的块,对每块做构建索引的操作,然后把每一个块操作的结果(这一个块的索引文件)合并起来得到整个文档集的索引。典型的算法有BSBI算法、SPIMI算法等。
现在我们已经有一个索引了,但我们怎么去使用呢?
有了索引,就可以很方便地找出我们需要的文档,最典型的的应用是布尔查询。布尔查询使用布尔运算符对查询 关键词进行连接,比如:
“信息 AND 检索 AND 导论”, 这个查询就是查找文中既包含“信息”,又包含“检索”,还包含“导论”这三个关键词的文档。
“(python OR Java) NOT Ruby”,这个查询就是查找文中包含“python”或包含“Java”,但不包含“Ruby”的文档。
对于AND操作,只需在索引中找到每个关键词对应的的文档ID列表,然后对文档ID列表求交集,也就是找出所有列表中共有的文档ID,那么这些找出来的文档就是用户想要的文档。OR的NOT操作不再赘述。
直到现在,很多图书馆的检索系统都还支持布尔查询的操作(大多放在“高级查询”功能里面)。
但是下一个问题来了,布尔查询使用到布尔操作符,而且其理论基础是布尔代数。虽说他们已经足够简单,但还是要求用户学习一点知识。而且布尔查询的查询表达式可以相当复杂。而且查找出来的文档虽然都与用户的查询相关,但是并不能按照相似程度来排序,只能根据发表日期等指标来排序。
那么有没有更加简单有效的方法让用户输入自然语言查询(而非布尔表达式等有一定规则的查询),得到根据相关程度排序的文档结果呢?
答案是向量空间模型。
我们的目标是把所有文档和用户查询一起转化成向量(词袋模型、if-idf权重),然后使用线性代数的方法来求得用户查询向量与所有文档向量之间的相似度(余弦相似度),进而得到用户查询与所有文档之间的相关程度。
要把向量空间模型应用于信息检索,要关注三个重要概念:词袋模型、TF-IDF、余弦相似度。
关于向量空间模型的文章整个互联网满大街都是,随便百度一下都能找到许多好的入门文章。在此我摘引一篇文章的部分来介绍词袋模型,资料来自bag-of-words模型入门:
Bag-of-words模型是信息检索领域常用的文档表示方法。
在信息检索中,BOW模型假定对于一个文档,忽略它的单词顺序和语法、句法等要素,将其仅仅看作是若干个词汇的集合,文档中每个单词的出现都是独立的,不依赖于其它单词是否出现。(是不关顺序的)
也就是说,文档中任意一个位置出现的任何单词,都不受该文档语意影响而独立选择的。那么到底是什么意思呢?那么给出具体的例子说明:
例子
Wikipedia[1]上给出了如下例子:
John likes to watch movies. Mary likes too.
John also likes to watch football games.根据上述两句话中出现的单词, 我们能构建出一个字典 (dictionary):
{ "John": 1, "likes": 2, "to": 3, "watch": 4, "movies": 5, "also": 6, "football": 7, "games": 8, "Mary": 9, "too": 10 }
该字典中包含10个单词, 每个单词有唯一索引,注意它们的顺序和出现在句子中的顺序没有关联. 根据这个字典, 我们能将上述两句话重新表达为下述两个向量:
[1, 2, 1, 1, 1, 0, 0, 0, 1, 1] [1, 1, 1, 1, 0, 1, 1, 1, 0, 0]
这两个向量共包含10个元素, 其中第i个元素表示字典中第i个单词在句子中出现的次数. 因此BoW模型可认为是一种统计直方图 (histogram). 在文本检索和处理应用中, 可以通过该模型很方便的计算词频.
但是从上面我们也能够看出,在构造文档向量的过程中可以看到,我们并没有表达单词在原来句子中出现的次序(这也是bag of words的一个缺点,但是听师兄说,很多情况简单的用bow特征产生的结果就比较好了)
根据上面介绍到的词袋模型,我们可以把一个文档转换成一个向量,向量的长度是词典的大小,也就是文档集里词项的数量,而向量中的每个元素都是都是对应词项的词频。同理,用户的查询也能转换为一个向量,比如在上述的情境下,如果用户查询是“football games”,那么对应的向量就是[0,0,0,0,0,0,1,1,0,0]
。现在,我们就可以使用线性代数的方法来求得两个向量的相似度,从而得到用户查询和所有文档对应的相似度。典型的方法就是求得条用户查询向量与文档向量之间的余弦夹角,夹角越小,相似度越大。具体的求法如下:
假定A和B是两个n维向量,A是 [A1, A2, ..., An] ,B是 [B1, B2, ..., Bn] ,则A与B的夹角θ的余弦等于:
这种方法虽然用到了形式化数学的方法,但是本质上的思想很简单:包含用户查询中关键词的文档才与用户的查询相关。一篇文档包含的关键词越多,关键词出现的频率越高,这篇文档与用户查询的相关度就越高。
但是现在问题又来了,有一些词比如“a”,“the”, “of”,或者“啊”, “的”, “了”,它们基本上在每一篇文档都出现,而且在每一篇文档中的出现频率都很高,用户查询中也惊颤会包含这些词。
显然这些词是没什么价值的,不能帮我们找出真正与用户查询相关的文档的。那我们要怎么做来消除这些词的影响呢?
一个简单粗暴的方法是维护一个停用词表,也就是把一些没有价值的词,也就是在绝大多数文档都出现的词记录成一张表,在建索引和解析用户查询时忽略这些词。
一个更有技术含量的方法是,在向量化文档和用户查询时,给每个词赋予权重,使得重要的词权重高,“a”,“啊”之类的词权重低。那么文档向量和用户查询向量里的元素就不再是词频这样简单的指标,而是词在文档中的权重这样的指标。这种权重指标中最常用的方法就是TF-IDF。其核心思想是,在绝大多数文档的中都出现的词重要性最低,只在少数文档中出现的词重要性较高,这些词的词频越高,重要性越高。因此,IF-iDF的计算方法如下:
一些符号: t--某个词项, d--某篇文档, n--文档集包含的文档总数
第一步,计算词频。**
tf = 词项t在文章d中的出现次数 / 文章d的总词项数
第二步,计算逆文档频率。
idf = lg(文档集规模n / 包含词项t的文档数 + 1)
第三步,计算TF-IDF。
tf-idf = tf * idf
Again, talk is cheap。下面是一段简单的python代码,演示使用VSM来计算用户查询和文档相似度。
"""
vsm.py
Simple implementation of Vector Space Model
Note: Depend on Numpy, please install it ahead (`pip install numpy`)
Please Run this script using Python3.x
Tested under Python3.6, Win7 and Python3.5 ubuntu16.04
Author: Richy Zhu
Email: rickyzhu@foxmail.com
"""
from math import log10
from pprint import pprint
import numpy as np
def _tf(tokenized_doc):
"""calculate term frequency for each term in each document"""
term_tf = {}
for term in tokenized_doc:
if term not in term_tf:
term_tf[term]=1.0
else:
term_tf[term]+=1.0
# pprint(term_tf)
return term_tf
def _idf(indices, docs_num):
"""calculate inverse document frequency for every term"""
term_df = {}
for term in indices:
# 一个term的df就是倒排索引中这个term的倒排记录表(对应文档列表)的长度
term_df.setdefault(term, len(indices[term]))
term_idf = term_df
for term in term_df:
term_idf[term] = log10(docs_num /term_df[term])
# pprint(term_idf)
return term_idf
def tfidf(tokenized_docs, indices):
"""calcalate tfidf for each term in each document"""
term_idf = _idf(indices, len(tokenized_docs))
term_tfidf={}
doc_id=0
for tokenized_doc in tokenized_docs:
term_tfidf[doc_id] = {}
term_tf = _tf(tokenized_doc)
doc_len=len(tokenized_doc)
for term in tokenized_doc:
tfidf = term_tf[term]/doc_len * term_idf[term]
term_tfidf[doc_id][term] =tfidf
doc_id+=1
# pprint(term_tfidf)
return term_tfidf
def build_terms_dictionary(tokenized_docs):
"""assign an ID for each term in the vocabulary"""
vocabulary = set()
for doc in tokenized_docs:
for term in doc:
vocabulary.add(term)
vocabulary = list(vocabulary)
dictionary = {}
for i in range(len(vocabulary)):
dictionary.setdefault(i, vocabulary[i])
return dictionary
def vectorize_docs(docs_dict, terms_dict, tf_idf):
""" transform documents to vectors
using bag-of-words model and if-idf
"""
docs_vectors = np.zeros([len(docs_dict), len(terms_dict)])
for doc_id in docs_dict:
for term_id in terms_dict:
if terms_dict[term_id] in tf_idf[doc_id]:
docs_vectors[doc_id][term_id] = tf_idf[doc_id][terms_dict[term_id]]
return docs_vectors
def vectorize_query(tokenized_query, terms_dict):
""" transform user query to vectors
using bag-of-words model and vector normalization
"""
query_vector = np.zeros(len(terms_dict))
for term_id in terms_dict:
if terms_dict[term_id] in tokenized_query:
query_vector[term_id] += 1
return query_vector / np.linalg.norm(query_vector)
def cos_similarity(vector1, vector2):
"""compute cosine similarity of two vectors"""
return np.dot(vector1,vector2)/(np.linalg.norm(vector1)*(np.linalg.norm(vector2)))
def compute_simmilarity(docs_vectors, query_vector, docs_dict):
"""compute all similarites between user query and all documents"""
similarities = {}
for doc_id in docs_dict:
similarities[doc_id] = cos_similarity(docs_vectors[doc_id], query_vector)
return similarities
if __name__ == '__main__':
tokenized_docs = [
['hello', 'world'],
['hello', 'python'],
['i', 'love', 'c', 'java', 'python', 'typescript', 'and', 'php'],
['use', 'python', 'to', 'build', 'inverted', 'indices'],
['you', 'and', 'me', 'are', 'in', 'one', 'world']
]
tokenized_query = ["python", "indices"]
docs_dict = {
0: "docs[0]",
1: "docs[1]",
2: "docs[2]",
3: "docs[3]",
4: "docs[4]"
}
indices = {'and': [2, 4], 'are': [4], 'build': [3], 'c': [2], 'hello': [0, 1], 'i': [2],
'in': [4], 'indices': [3], 'inverted': [3], 'java': [2], 'love': [2], 'me': [4],
'one': [4], 'php': [2], 'python': [1, 2, 3], 'to': [3], 'typescript': [2], 'use'
: [3], 'world': [0, 4], 'you': [4]}
tf_idf = tfidf(tokenized_docs, indices)
terms_dict = build_terms_dictionary(tokenized_docs);
docs_vectors = vectorize_docs(docs_dict, terms_dict, tf_idf)
query_vector = vectorize_query(tokenized_query, terms_dict)
# pprint(docs_vectors)
pprint(compute_simmilarity(docs_vectors, query_vector, docs_dict))
运行以上脚本可以得到下面的结果,字典做点是文档id,右边是对应的用户查询的相似度。可见文档3与用户查询最为相关。
$ python3 vsm.py
{0: 0.0,
1: 0.34431538823149532,
2: 0.088542411007409116,
3: 0.41246212572975449,
4: 0.0}
关于向量空间模型的资料,有很多更加详细,更加通俗易懂的文章,比如吴军博士《数学之美》的第11章: 确定网页和查询的相关性:TF-IDF,Manning等人《信息检索导论》的第6章: Scoring, term weighting, and the vector space model。
小结
所以向量空间模型的要点是把文档转换成向量,把用户的查询也转换成向量,然后求查询向量和所有文档向量的余弦夹角等相似度指标,根据相似度来排序。
但是对大规模文档集,不可能把用户查询跟所有文档向量的相似度都计算一遍,这样太耗费时间了。我们可以牺牲一点点搜索质量,来获取时间性能上的大幅提高。首先我们可以结合倒排索引,先根据用户查询的全部或部分关键词,在索引中找出相关文档列表,然后只对搜索出来的文档进行相似度计算。如果这一步后找出来的文档依然太多,我们可以更进一步,先根据每个词在每一篇文档中TF-IDF值对文档进行排序,排序高的放在这个词对应的文档列表前面。这样,我们可以每次只选出关键词对应的文档列表的前K个文档做相似度计算,便完全可以控制时间成本和搜索质量之间的平衡了。
至此,一个基于倒排索引和向量空间模型的全文搜索引擎的核心工作机制就完备了。
信息检索当下的应用和未来可能的发展方向有哪些?
答曰:
前文详细介绍了一种主要使用倒排索引和向量空间模型的信息检索方案,主要用于检索计算机里的文档。早期的Google等互联网搜索引擎主要采用的都是这种方案。甚至现在,这种方案可能也还是Google的主要框架。这种搜索引擎也称为“ad hoc search engine”, “ad hoc”在拉丁文中是“特殊的、临时的、针对特定目的”的意思。也就是说我们常用的搜索引擎针对得到用户信息需求都是“特殊的、临时的、针对特定目的”。相应的,也有长期的,稳定的信息需求,比如一个长期关注信息检索技术的人,就想要每天阅读一些信息检索主题的文章。更加常见的长期、稳定的信息需求,可能是“在一大堆邮件中找出垃圾邮件(然后丢掉)”。针对这些信息需求,我们需要文档集进行分类等操作,找出文档集里“信息检索技术”主题的文章,或者对邮件集操作,找出“垃圾邮件”类别的邮件(然后丢掉)。典型的方案就是贝叶斯文本分类、支持向量机文本分类,K临近文本聚类之类额机器学习习方法。Manning等人的《信息检索导论》后面好几章都是介绍这些机器学习方法在文本分类和聚类中的应用。
当下,搜索引擎是信息检索的主要工具和研究分支。但是让我们重新检视信息检索的定义:
Information retrieval (IR) is finding material (usually documents) of an unstructured nature (usually text) that satisfies an information need from within large collections (usually stored on computers).
信息检索的本质,应该是“给用户找到他想要的信息”。那么如果用户提出一个问题,然后我们不是返回一堆文档,而是直接告诉他问题的答案,岂不是更好?是的,人们把自动问答系统看作是下一代的搜索引擎,而Google、百度、搜狗等人工智能和信息检索巨头也正在大力开发自动问答系统。
让我们更激进一点,如果信息检索的本质是“给用户找到他想要的信息”,那么更加高端的信息检索形式可能是在用户提出问题之前就给他提供他想要的信息,比如用户刚想换一部手机,就把所有手机相关的商品信息呈现给用户;或者说用户刚想了解一下娱乐圈某明星最近发生的一件事,系统就他推送相关新闻给这位用户。是的,推荐系统可能才是信息检索最高级的形态,这可能也是很多人看好今日头条的缘故吧。
相关链接:
一些参考代码:https://coding.net/u/qige96/p/IR-exe
本文直接或简介地使用了以下著作的内容:
本作品首发于简书平台,采用知识共享署名 4.0 国际许可协议进行许可。