普通视图

发现新文章,点击刷新页面。
昨天以前Xiaobin's Blog

如何让大语言模型输出JSON格式

作者 xbl
2024年8月9日 21:50

提示词明确要求JSON

这是最直接的方法,直接在提示词中提要求,类似这样:

请描述这张图片,输出用JSON格式

就可以让LLM输出JSON格式

当然,这种方法不是100%让结果是JSON,有时候结果会是Markdown,有时候干脆就不是结构化的文本。

提示词中给出JSON样例

给出JSON样例的好处,是可以让LLM在生成的JSON中使用指定的key name。

例如,提示这么写:

描述这张图片,输出为JSON格式,例如{“desc”: “somebody is dancing”, “character_count”: 3}

产生的结果真的就包含desc和character_count两个key。

这是一个业内公认的方法,但是,在实操过程中,我发现对llama 3.2 vision使用这招产生非JSON输出的概率反而更大了,可能因为『描述图片内容』这个任务不容易让LLM上道输出指定的JSON。

指定结果开头字符为{

前面的方法,有可能结果虽然包含JSON,但是在JSON之前还要加一段废话,为了强制LLM只输出JSON,还有一招,就是在提示词中要求LLM输出以{开头(当然JSON也可以是以[开头),这样更大概率输出的就只有JSON。

上面这些都是在提示词上做文章,除此之外,在模型参数上也可以做一些trick。

调整模型参数

对于OpenAI的API,可以通过调整 logit_bias 来操纵输出token的概率,比如下面的配置,可以增加 {} 字符的输出概率,减少 ''' 的输出概率,从而增大输出为JSON的概率。

1
2
3
4
5
6
7
logit_bias: {
"90": 10, // token ID for "{"
"92": 10, // token ID for "}"
"19317": -10, // token ID for "'''"
"19317": -10, // token ID for "'''"
"74694": -10 // token ID for "```"
}

llama 3.2没有对等的logits_bias参数,但是我试了一下调整 temperature 和 top_p 参数,降低temperature和top_p的值,可以让模型少点『创意思维』,老老实实规规矩矩输出,似乎(我只敢说似乎)能够让模型更大概率遵守提示词以JSON格式输出。

重试

修改Joplin主题样式

作者 xbl
2024年7月21日 22:16

Joplin主题

直接在设置中的“插件”选项中搜索 “macos theme”安装,然后重启后打开macos theme 这个插件,打开该插件对应的设置页面,将样式调成_light_模式,同时Joplin本身的外观设置也需要保持_亮色_模式。再次重启Joplin,发现全局主题已经改变。此外,该插件主题也同时支持暗色主题和自定义主题颜色。

Markdown渲染样式

将源码复制至Joplin配置中的_userstyle.css_中即可,该配置文件可以从_工具_——>_选项_——>_外观_——>_显示高级选项_——>_适用于已渲染Markdown的自定义样式表_中找到。Mac 下路径为

~/.config/joplin-desktop/userstyle.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165

pre, code {
font-size: 14px;
font-family: Roboto, 'Courier New', Consolas, Inconsolata, Courier, monospace;
margin: auto 5px;
}

code {
white-space: pre-wrap;
border-radius: 2px;
display: inline;
}

pre {
font-size: 15px;
line-height: 1.4em;
display: block; !important;
}

pre code {
white-space: pre;
overflow: auto;
border-radius: 3px;
padding: 1px 1px;
display: block !important;
}

strong, b{
color: #BF360C;
}

em, i {
color: #009688;
}

hr {
border: 1px solid #BF360C;
margin: 1.5em auto;
}

p {
margin: 1.5em 5px !important;
}

table, pre, dl, blockquote, q, ul, ol {
margin: 10px 5px;
}

ul, ol {
padding-left: 15px;
}

li {
margin: 10px;
}

li p {
margin: 10px 0 !important;
}

ul ul, ul ol, ol ul, ol ol {
margin: 0;
padding-left: 10px;
}

ul {
list-style-type: circle;
}

dl {
padding: 0;
}

dl dt {
font-size: 1em;
font-weight: bold;
font-style: italic;
}

dl dd {
margin: 0 0 10px;
padding: 0 10px;
}

blockquote, q {
border-left: 2px solid #009688;
padding: 0 10px;
color: #777;
quotes: none;
margin-left: 1em;
}

blockquote::before, blockquote::after, q::before, q::after {
content: none;
}

h1, h2, h3, h4, h5, h6 {
margin: 20px 0 10px;
padding: 0;
font-style: bold !important;
color: #009688 !important;
text-align: center !important;
margin: 1.5em 5px !important;
padding: 0.5em 1em !important;
}

h1 {
font-size: 24px !important;
}

h2 {
font-size: 20px !important;
}

h3 {
font-size: 18px;
}

h4 {
font-size: 16px;
}


table {
padding: 0;
border-collapse: collapse;
border-spacing: 0;
font-size: 1em;
font: inherit;
border: 0;
margin: 0 auto;
}

tbody {
margin: 0;
padding: 0;
border: 0;
}

table tr {
border: 0;
border-top: 1px solid #CCC;
background-color: white;
margin: 0;
padding: 0;
}

table tr:nth-child(2n) {
background-color: #F8F8F8;
}

table tr th, table tr td {
font-size: 16px;
border: 1px solid #CCC;
margin: 0;
padding: 5px 10px;
}

table tr th {
font-weight: bold;
color: #eee;
border: 1px solid #009688;
background-color: #009688;
}

复制完成后保存,重启Joplin即可。

问题及解决方法

当使用_macos theme_的插件主题之后,插件中的主题样式会覆盖部分的_自定义CSS样式_,可以通过在_自定义CSS样式_后添上!important强制使用自定义的样式。
对上文中的 CSS 进行更改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168


pre, code {
font-size: 14px !important;
font-family: Roboto, 'Courier New', Consolas, Inconsolata, Courier, monospace !important;
margin: auto 5px !important;
}

code {
white-space: pre-wrap !important;
border-radius: 2px !important;
display: inline !important;
}

pre {
font-size: 15px !important;
line-height: 1.4em !important;
display: block; !important;
}

pre code {
white-space: pre !important;
overflow: auto !important;
border-radius: 3px !important;
padding: 1px 1px !important;
display: block !important;
}

strong, b{
color: #BF360C !important;
}

em, i {
color: #009688 !important;
}

hr {
border: 1px solid #BF360C !important;
margin: 1.5em auto !important;
}

p {
margin: 1.5em 5px !important;
}

table, pre, dl, blockquote, q, ul, ol {
margin: 10px 5px !important;
}

ul, ol {
padding-left: 15px !important;
}

li {
margin: 10px !important;
}

li p {
margin: 10px 0 !important;
}

ul ul, ul ol, ol ul, ol ol {
margin: 0 !important;
padding-left: 10px !important;
}

ul {
list-style-type: circle !important;
}

dl {
padding: 0 !important;
}

dl dt {
font-size: 1em !important;
font-weight: bold !important;
font-style: italic !important;
}

dl dd {
margin: 0 0 10px !important;
padding: 0 10px !important;
}

blockquote, q {
border-left: 2px solid #009688 !important;
padding: 0 10px !important;
color: #777 !important;
quotes: none !important;
margin-left: 1em !important;
}

blockquote::before, blockquote::after, q::before, q::after {
content: none !important;
}

h1, h2, h3, h4, h5, h6 {
margin: 20px 0 10px !important;
padding: 0 !important;
font-style: bold !important;
color: #009688 !important;
text-align: center !important;
margin: 1.5em 5px !important;
padding: 0.5em 1em !important;
}

h1 {
font-size: 24px !important;
border-bottom: 1px solid #ddd !important;
}

h2 {
font-size: 20px !important;
border-bottom: 1px solid #eee !important;
}

h3 {
font-size: 18px !important;
}

h4 {
font-size: 16px !important;
}


table {
padding: 0 !important;
border-collapse: collapse !important;
border-spacing: 0 !important;
font-size: 1em !important;
font: inherit !important;
border: 0 !important;
margin: 0 auto !important;
}

tbody {
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
}

table tr {
border: 0 !important;
border-top: 1px solid #CCC !important;
background-color: white !important;
margin: 0 !important;
padding: 0 !important;
}

table tr:nth-child(2n) {
background-color: #F8F8F8 !important;
}

table tr th, table tr td {
font-size: 16px !important;
border: 1px solid #CCC !important;
margin: 0 !important;
padding: 5px 10px !important;
}

table tr th {
font-weight: bold !important;
color: #eee !important;
border: 1px solid #009688 !important;
background-color: #009688 !important;
}

更改后发现自定义的CSS样式不再会被插件主题样式覆盖。

新一代包管理器 PNPM

作者 xbl
2024年6月16日 21:59

依赖管理的始末

npm2

使用早期的 npm1/2 安装依赖,node_modules 文件夹会以递归的形式呈现,严格按照 package.json 结构以及次级依赖的 package.json 结构将依赖安装到它们各自的 node_modules 中,直到次级依赖不再依赖其它模块。

就像下面这样,tea-app 依赖 tea-component 作为次级依赖,tea-component 会安装到 tea-component 的 node_modules 里面:

1
2
3
4
5
6
7
8
node_modules
└─ tea-app
├─ index.js
├─ package.json
└─ node_modules
└─ tea-component
├─ index.js
└─ package.json

假设项目的中的两个依赖同时依赖了相同的次级依赖,那么它们二者的次级依赖将会被重复安装:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
node_modules
├─ tea-app
│ ├─ index.js
│ ├─ package.json
│ └─ node_modules
│ └─ tea-component
│ ├─ index.js
│ └─ package.json
└─ tea-chart
├─ index.js
├─ package.json
└─ node_modules
└─ tea-component
├─ index.js
└─ package.json

这只是简单的例子,那如果 tea-component 还依赖别的包,别的包又依赖另外的包…… 在真实的开发场景中其问题还会更加恶劣:

  1. 依赖层级太深,会导致文件路径过长
  2. 重复的包被安装,导致 node_modules 文件体积巨大,占用过多的磁盘空间

npm3

npm3/yarn 开始,相比 npm1/2 项目依赖管理的方式有了很大的改变,不再是以往的“嵌套式”而是采用了“扁平化”方式去管理项目依赖。

这里继续拿上面的例子,tea-app 和 tea-chart 都依赖了 tea-component,依赖安装后呈现的是下面的这种扁平化目录:

1
2
3
4
5
6
7
8
9
10
node_modules
├─ tea-component
│ ├─ index.js
│ └─ package.json
├─ tea-app
│ ├─ index.js
│ └─ package.json
└─ tea-chart
├─ index.js
└─ package.json

扁平化的目录的确解决了上一小节暴露的一些问题,同时也暴露了新的问题:

  • Phantom dependencies

称为幽灵依赖,指的是在项目内引用未在 package.json 中定义的包。这个问题在 npm3 展现,因为早期的树形结构导致了依赖冗余和路径过深的问题,npm3 之后采用扁平化的结构,一些第三方包的次级依赖提升到了与第三方包同级。

一旦出现幽灵依赖的问题,可能会导致意想不到的错误,所以一定要正视:

  1. 不兼容的版本(例如某一个 api 进行了重大更新)

  2. 有可能会丢失依赖(某依赖不再依赖呈现在我们项目中的幽灵依赖)

    1
    2
    // tea-component 就属于是幽灵依赖,因为它是属于 tea-app、tea-chart 的次级依赖。
    import { Button } from 'tea-component';
  3. NPM doppelgangers

称为分身依赖依赖的同名包都会被重复安装。

在实际开发中也会出现这样的情景,假设 tea-app、tea-form 依赖 tea-component@2.0.0,tea-chart 依赖 tea-component@3.0.0,这时候会造成依赖冲突,解决冲突的方式会将对应的冲突包放到对应依赖目录的 node_mudules 中,类似下面结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
node_modules
├─ tea-component@3.0.0
│ ├─ index.js
│ └─ package.json
├─ tea-app
│ ├─ index.js
│ ├─ package.json
│ └─ node_modules
│ └─ tea-component@2.0.0
│ ├─ index.js
│ └─ package.json
├─ tea-form
│ ├─ index.js
│ ├─ package.json
│ └─ node_modules
│ └─ tea-component@2.0.0
│ ├─ index.js
│ └─ package.json
└─ tea-chart
├─ index.js
└─ package.json

这时候会发现一个问题,tea-app、tea-form 的 node_modules 下都有重复且版本相同的 tea-component@2.0.0,这个问题就是我们正在所说的“分身依赖”的问题。这个问题就会导致 tea-app 中的 ConfigProvider 组件和 tea-form 的不是一个实例,无法生效。

常见的问题:

  1. 项目打包会将这些“重身”的依赖都进行打包,增加产物体积
  2. 无法共享库实例,引用的得到的是两个独立的实例
  3. 重复 TypeScript 类型,可能会造成类型冲突

结论

  1. 扁平化的 node_modules 结构允许访问没有在 package.json 中声明的依赖。
  2. 安装效率低,大量依赖被重复安装,磁盘空间占用高。
  3. 多个项目之间已经安装过的的包不能共享,每次都是重新安装。

PNPM

Fast, disk space efficient package manager (速度快、节省磁盘空间的软件包管理器)

当使用 npm 或 Yarn 时,如果你有 100 个项目,并且所有项目都有一个相同的依赖包,那么, 你在硬盘上就需要保存 100 份该相同依赖包的副本。然而,如果是使用 pnpm,依赖包将被 存放在一个统一的位置,因此:

如果你对同一依赖包需要使用不同的版本,则仅有 版本之间不同的文件会被存储起来。例如,如果某个依赖包包含 100 个文件,其发布了一个新 版本,并且新版本中只有一个文件有修改,则pnpm update只需要添加一个 新文件到存储中,而不会因为一个文件的修改而保存依赖包的 所有文件。

所有文件都保存在硬盘上的统一的位置。当安装软件包时, 其包含的所有文件都会硬链接自此位置,而不会占用 额外的硬盘空间。这让你可以在项目之间方便地共享相同版本的 依赖包。

最终结果就是以项目和依赖包的比例来看,你节省了大量的硬盘空间, 并且安装速度也大大提高了!

依赖安装

使用 pnpm 安装,pnpm 会将依赖存储在位于 .pnpm-store 目录下。只要你在同一机器下,下次安装依赖的时候 pnpm 会先检查 store 目录,如果有你需要的依赖则会通过一个硬链接到你的项目中去,而不是重新安装依赖。

依赖管理原理

pnpm 会将依赖存储在 store 目录下,通过符号链接的方式仅将项目的直接依赖项添加到 node_modules 的根目录下。

当使用 npm 或 yarn 安装依赖包时,所有软件包都将被提升到 node_modules 的 根目录下。其结果是,源码可以访问 本不属于当前项目所设定的依赖包。

PNPM 机制

如果 store 目录里面拥有即将需要下载的依赖,下载将会跳过,会向对应项目 node_modules 中去建立硬链接,并非去重新安装它。这里就表明为什么 pnpm 性能这么突出了,最大程度节省了时间消耗和磁盘空间。

基于软链接的 node_modules

pnpm 输出的 node_modules 与 npm/yarn 有很大的出入,并非是先者那样的“扁平化目录”而是“非扁平化目录”。

创建两个目录并分别运行 npm add express,pnpm add express。

这是使用 npm 安装 node_modules 的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.bin
accepts
array-flatten
body-parser
bytes
content-disposition
cookie-signature
cookie
debug
depd
destroy
ee-first
encodeurl
escape-html
etag
express

这个则是 pnpm 安装 node_modules 的结构:

1
2
3
.pnpm
.modules.yaml
express

打开 .pnpm 目录会发现这些依赖都被“扁平化”了,每个包都携带着自己的版本号。pnpm 这样设计的目的我理解其实是为了解决“分身依赖”的问题。

假设我们有这么一个情景,项目中依赖了 tea-app@1.0.0tea-chart@1.0.0tea-component@2.0.0。tea-chart 和 tea-app 依赖了 tea-component@1.0.0 那它引用关系是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
node_modules
├─tea-app -> ./.pnpm/tea-app@1.0.0/node_modules/tea-app
├─tea-chart -> ./.pnpm/tea-chart@1.0.0/node_modules/tea-chart
├─tea-component -> ./.pnpm/tea-component@2.0.0/node_modules/tea-component
└─.pnpm
├─ tea-app@1.0.0
│ └─ node_modules
│ ├─ tea-component -> ../tea-component@1.0.0/node_modules/tea-component
│ └─ tea-app -> <store>/tea-app
├─ tea-chart@1.0.0
│ └─ node_modules
│ ├─ tea-component -> ../tea-component@1.0.0/node_modules/tea-component
│ └─ tea-chart -> <store>/tea-chart
├─ tea-component@1.0.0
│ └─ node_modules
│ └─ tea-component -> <store>/tea-component@1.0.0
└─ tea-component@2.0.0
└─ node_modules
└─ tea-component -> <store>/tea-component@2.0.0

为什么需要通过软链接的方式去引用实际的依赖?

这样设计的目的是解决“幽灵依赖”的问题,只有声明过的依赖才会以软链接的形式出现在 node_modules 目录中。在实际项目中引用的是软链接,软链接指向的是 .pnpm 的真实依赖,所以在日常开发中不会引用到未在 package.json 声明的包。

PNPM 锁文件

pnpm 产出的是一个 pnpm-lock.yaml 格式的锁文件。

支持通过 pnpm import 从另一个包管理器的锁文件生成一个。支持的源文件:

  • package-lock.json
  • npm-shrinkwrap.json
  • yarn.lock

总结

npm2 是通过嵌套的方式管理 node_modules 的,会有同样的依赖复制多次的问题。

npm3+ 和 yarn 是通过铺平的扁平化的方式来管理 node_modules,解决了嵌套方式的部分问题,但是引入了幽灵依赖的问题,并且同名的包只会提升一个版本的,其余的版本依然会复制多次。

pnpm 则是用了另一种方式,不再是复制了,而是都从全局 store 硬连接到 node_modules/.pnpm,然后之间通过软链接来组织依赖关系。

这样不但节省磁盘空间,也没有幽灵依赖问题,安装速度还快,从机制上来说完胜 npm 和 yarn。

React useEffect() Hook

作者 xbl
2024年5月18日 22:16

Side Effect 是什么

函数式编程将那些跟数据计算无关的操作,都称为 side effect 。如果函数内部直接包含产生 side effect 的操作,就不再是纯函数了. 纯函数内部只有通过间接的手段(即通过其他函数调用),才能包含 side effect

Hook 的作用

hook 就是 React 函数组件的副效应解决方案,用来为函数组件引入副效应。 函数组件的主体只应该用来返回组件的 HTML 代码,所有的其他操作(副效应)都必须通过钩子引入。

由于副效应非常多,所以钩子有许多种。React 为许多常见的操作(副效应),都提供了专用的钩子。

  • useState():保存状态
  • useContext():保存上下文
  • useRef():保存引用
  • ……

上面这些钩子,都是引入某种特定的副效应,而 useEffect() 是通用的副效应钩子 。找不到对应的钩子时,就可以用它。

useEffect() 的用法

例如,希望组件加载以后,网页标题(document.title)会随之改变。那么,改变网页标题这个操作,就是组件的副效应,必须通过 useEffect() 来实现。

1
2
3
4
5
6
7
8
import React, { useEffect } from 'react';

function Welcome(props) {
useEffect(() => {
document.title = '加载完成';
});
return <h1>Hello, {props.name}</h1>;
}

useEffect()的参数是一个函数,它就是所要完成的副效应(改变网页标题)。组件加载以后,React 就会执行这个函数。

useEffect() 的第二个参数

如果不希望 useEffect() 每次渲染都执行,可以使用它的第二个参数,使用一个数组指定副效应函数的依赖项,只有依赖项发生变化,才会重新渲染。

1
2
3
4
5
6
function Welcome(props) {
useEffect(() => {
document.title = `Hello, ${props.name}`;
}, [props.name]);
return <h1>Hello, {props.name}</h1>;
}

上面例子中,useEffect()的第二个参数是一个数组,指定了第一个参数(副效应函数)的依赖项(props.name)。只有该变量发生变化时,副效应函数才会执行。

如果第二个参数是一个空数组,就表明副效应参数没有任何依赖项。因此,副效应函数这时只会在组件加载进入 DOM 后执行一次,后面组件重新渲染,就不会再次执行。

useEffect() 的用途

只要是副效应,都可以使用useEffect()引入。它的常见用途有下面几种。

  • 获取数据(data fetching)
  • 事件监听或订阅(setting up a subscription)
  • 改变 DOM(changing the DOM)
  • 输出日志(logging)

下面是从远程服务器获取数据的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import React, { useState, useEffect } from 'react';
import axios from 'axios';

function App() {
const [data, setData] = useState({ hits: [] });

useEffect(() => {
const fetchData = async () => {
const result = await axios(
'https://hn.algolia.com/api/v1/search?query=redux',
);

setData(result.data);
};

fetchData();
}, []);

return (
<ul>
{data.hits.map(item => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
);
}

export default App;

上面例子中,useState()用来生成一个状态变量(data),保存获取的数据;useEffect()的副效应函数内部有一个 async 函数,用来从服务器异步获取数据。拿到数据以后,再用setData()触发组件的重新渲染。

由于获取数据只需要执行一次,所以上例的useEffect()的第二个参数为一个空数组。

useEffect() 的返回值

副效应是随着组件加载而发生的,那么组件卸载时,可能需要清理这些副效应。

useEffect()允许返回一个函数,在组件卸载时,执行该函数,清理副效应。如果不需要清理副效应,useEffect()就不用返回任何值。

1
2
3
4
5
6
useEffect(() => {
const subscription = props.source.subscribe();
return () => {
subscription.unsubscribe();
};
}, [props.source]);

上面例子中,useEffect()在组件加载时订阅了一个事件,并且返回一个清理函数,在组件卸载时取消订阅。

实际使用中,由于副效应函数默认是每次渲染都会执行,所以清理函数不仅会在组件卸载时执行一次,每次副效应函数重新执行之前,也会执行一次,用来清理上一次渲染的副效应。

Pyenv工具

作者 xbl
2024年1月8日 13:10

Python 版本管理工具的主要作用是帮助开发者在同一台机器上管理多个 Python 版本和环境。这对于开发和部署不同项目非常有用,因为不同项目可能依赖不同的 Python 版本或者不同的包版本。具体来说,Python 版本管理工具应有以下功能:

(1)避免依赖冲突,不同的项目可能依赖不同版本的库,使用版本管理工具可以创建独立的虚拟环境,避免依赖冲突。

(2)简化开发流程,开发者可以轻松地在不同的 Python 版本之间切换,而不需要重新安装或配置 Python。

(3)便于部署,减少冲突。在开发环境中使用与生产环境相同的 Python 版本和依赖,可以减少部署时出现的问题。

(4)共享环境配置,提高开发环境一致性。可以将环境配置文件(如 requirements.txtpyproject.toml)共享给团队成员,确保大家使用相同的开发环境。

一、工具选择

常见的管理工具有 Pyenv 和 Conda。Pyenv 是当前最流行的 Python 版本管理工具,支持多种 Python 版本,如 CPython、Anaconda、PyPy 等,功能全面且简单易用。Conda 最初由 Anaconda, Inc. 开发,主要用于 Python 和 R 编程语言的软件包(含 Python)及环境管理,特别适合跨平台、多语言项目,Python 版本管理只是其一小部分功能,若仅用于管理 Python 版本,Conda 有些大材小用,且系统较复杂、学习成本略高。相比之下,Pyenv 是常规项目 Python 版本管理的最优选择。

以下详细介绍 Pyenv 的使用方法。

二、Pyenv 安装

建议: 先卸载系统内置的 Python,否则可能导致 pyenv 设置不生效。

1. Windows

pyenv 本身是为 Unix 系统设计的。你可以使用 pyenv-win 这个项目,它是 pyenv 的 Windows 版本。

你需要在 PowerShell 中执行以下命令安装 pyenv-win:

1
Invoke-WebRequest -UseBasicParsing -Uri "https://raw.githubusercontent.com/pyenv-win/pyenv-win/master/pyenv-win/install-pyenv-win.ps1" -OutFile "./install-pyenv-win.ps1"; &"./install-pyenv-win.ps1"

重新打开 PowerShell,运行 pyenv –version 检查安装是否成功。

2. Linux

你可以使用以下命令来安装 pyenv

1
curl https://pyenv.run | bash

之后再将 pyenv 配置到环境变量中并使之生效,执行如下命令:

1
2
3
4
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc 
echo 'command -v pyenv >/dev/null || export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc
echo 'eval "$(pyenv init -)"' >> ~/.bashrc
source ~/.bashrc

上述配置仅能使 pyenv 在 bash 环境生效,更多 shell 环境配置请参考:Set up your shell environment for Pyenv。配置的本质在于将$PYENV_ROOT 下的 shims 和 bin 目录配置到 PATH 变量中,且 shims 需配置在前。配置后的 PATH 如下:

1
# echo $PATH /root/.pyenv/shims:/root/.pyenv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

三、Pyenv 基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
## 查看帮助文档 
pyenv
## 查看某个命令帮助文档
pyenv install --help
## 查看版本
pyenv version
## 检查 Python 是否正常运行
python -c "import sys; print(sys.executable)"
## 查看已安装的 Python 版本
pyenv versions
## 查看当前使用的 Python 版本
pyenv version
## 查看所有可用的 Python 版
pyenv install --list
## 安装指定版本
pyenv install 3.9.1
## 验证
python --version
## 卸载指定版本
pyenv uninstall 3.9.1
## 全局指定 Python 版本(影响所有项目)
pyenv global 3.9.1
## 局部指定 Python 版本(仅影响当前项目目录),指定后在当前项目目录内创建 .python-version 文件,保存版本信息## 优先级高于 global
pyenv local 3.9.1
## 会话级指定 Python 版本(影响所有项目)
pyenv shell 3.9.1
## 查看 python 的安装目录
pyenv which python
## 重新生成 pyenv 的 shims 目录中的可执行文件
pyenv rehash

Python 安装常见问题,可参考:Python common build problems

四、Pyenv 核心原理 -Shims

pyenv 通过 Shims 实现了对不同 Python 版本的透明管理和切换。

1. 工作原理

上述环境配置中,在 PATH 环境变量最前面插入一个 shims 目录,$(pyenv root)/shims:$(pyenv root)/bin:/usr/local/bin:/usr/bin:/bin。通过一个称为 rehashing 的过程,pyenv 在该目录中维护垫片,以匹配每个已安装的 Python 版本中的每个 Python 命令,如: python、pip 等。

Shims 是轻量级可执行文件,它只是将你的命令传递给 pyenv。因此,在安装了 pyenv 的情况下,当你运行 pip 时,你的操作系统将执行以下操作:

(1)搜索 PATH 环境变量,寻找 pip 可执行文件

(2)在 $(pyenv root)/shims 中找到 pip

(3)运行名为 pip 的 shim,它将命令传递给 pyenv

2. 作用

(1)通过使用 Shims,pyenv 可以实现对不同项目使用不同 Python 版本的灵活管理,而不需要手动修改环境变量或路径。

(2)你可以方便地在全局、目录级别甚至是 shell 会话级别设置或切换 Python 版本,极大地方便了开发和测试工作。

3. 示例

(1)假设你在项目 A 中使用 Python 3.8,而在项目 B 中使用 Python 3.9。通过 pyenv 和 Shims,你可以在项目目录中分别设置 Python 版本:

1
2
3
4
# 在项目 A 目录中 
pyenv local 3.8.10
# 在项目 B 目录中
pyenv local 3.9.5

(2)当你在项目 A 目录中运行 python 命令时,Shims 会确保调用的是 Python 3.8.10,而在项目 B 目录中则会调用 Python 3.9.5。

通过这种方式,Shims 实现了对不同 Python 版本的透明管理和切换。

五、Pyenv 初始化操作源码解读

1. pyenv init -

用于初始化 pyenv,使其在当前 shell 会话中工作。运行后,执行如下命令(相关说明附在注释中):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# 1.PATH 变量处理
## 该脚本将当前的 PATH 变量拆分为一个数组 paths,并赋予
## 通过遍历 paths 数组,检查每个路径是否为 '/root/.pyenv/shims',如果是,则将其移除
PATH="$(bash --norc -ec 'IFS=:; paths=($PATH);
for i in ${!paths[@]}; do
if [[ ${paths[i]} == "''/root/.pyenv/shims''" ]]; then unset '\''paths[i]'\'';
fi; done;
echo "${paths[*]}"')" #

# 2. 更新 PATH 变量
## 将 '/root/.pyenv/shims' 添加到 PATH 变量的最前面
export PATH="/root/.pyenv/shims:${PATH}"
## 设置 PYENV_SHELL 环境变量为 bash,sh 环境下,输出的是 shell
export PYENV_SHELL=bash
## sh 环境下,无该行代码,bash 环境下执行改行的作用是:source 命令加载 pyenv 的自动补全脚本
source '/root/.pyenv/libexec/../completions/pyenv.bash'
## 通过 command 命令执行 pyenv rehash(主要作用是重新生成 pyenv 的 shims 目录中的可执行文件),并将错误输出重定向到 /dev/null
command pyenv rehash 2>/dev/null

# 3. 定义一个 pyenv 函数,该函数根据不同的子命令执行不同的操作
## 如果子命令是 activate、deactivate、rehash 或 shell,则通过 eval 执行 pyenv "sh-$command"
## 对于其他子命令,直接调用 command pyenv "$command" "$@"
pyenv() {
local command
command="${1:-}"
if [ "$#" -gt 0 ]; then
shift
fi

case "$command" in
activate|deactivate|rehash|shell)
eval "$(pyenv "sh-$command" "$@")"
;;
*)
command pyenv "$command" "$@"
;;
esac
}

2. pyenv init –path

用于设置 PYENV_ROOT 环境变量,使得 pyenv 可以找到安装的 Python 版本。pyenv init - 包含 pyenv init --path 操作。

sh 或 bash 环境运行后,执行如下命令(相关说明附在注释中):

1
2
3
4
5
6
7
8
9
10
11
12
## 该脚本将当前的 PATH 变量拆分为一个数组 paths,并赋予
## 通过遍历 paths 数组,检查每个路径是否为 '/root/.pyenv/shims',如果是,则将其移除
PATH="$(bash --norc -ec 'IFS=:; paths=($PATH);
for i in ${!paths[@]}; do
if [[ ${paths[i]} == "''/root/.pyenv/shims''" ]]; then unset '\''paths[i]'\'';
fi; done;
echo "${paths[*]}"')"
## 将 '/root/.pyenv/shims' 添加到 PATH 变量的最前面
export PATH="/root/.pyenv/shims:${PATH}"
## 通过 command 命令执行 pyenv rehash,并将错误输出重定向到 /dev/null
command pyenv rehash 2>/dev/null

家庭用电插座

作者 xbl
2024年4月11日 13:48

10a和16a插座的区别

1、外观区别

主要是插孔间距的尺寸区别。16a插座三插孔的孔距比10a插座更大。所以不同标准的插孔和插头并不能适用。

10a插座为五眼插:1个三眼、1个二眼,而16a插座是一个三眼插,比10a三眼插宽广些。

2、使用区别

16a插头和10a插头不通用,10a插头插不到16a插座里去,当然反过来也是不行的。

3、插座金属

16a插座承载电流大于10a插座,用到的铜也比较多,而10a插座用的材料也有所不同。

16a插座需要布置4平方毫米以上规格的铜线,而10a插座最好布置2.5平方毫米铜线。

4、承受范围

16a插座可以承受3000瓦以内电器功率,而10a插座功率控制在2200瓦以内,不然容易发生意外。

哪些电器用16a插座

家里常用的大功率电器主要是空调、电磁炉、热水器

16a插座是承受范围比较大的插座,家居中一般主要在空调电器上使用,所以我们经常可以见到空调的隔壁安装的是一个不一样的插座,这是对用电安全的需求

MacOS 14.4 引发Java 应用崩溃

作者 xbl
2024年3月15日 14:05

根据Java官方发布的文章,由于macOS上运行的进程可能会访问受保护内存区域中的内存。在 macOS 14.4 更新之前,在某些情况下,macOS 内核会通过向进程发送信号 SIGBUS 或 SIGSEGV 来响应这些受保护的内存访问。然后该进程可以选择处理该信号并继续执行。而在最新 macOS 14.4 中,当线程在写入模式下运行时,如果尝试对受保护的内存区域进行内存访问,macOS 将发送信号SIGKILL。该进程无法处理该信号,并且该进程将无条件终止。

目前该问题主要受影响的Mac机型和Java版本包括:

  • Mac机型:M1、M2、M3(Apple Silicon m* 芯片)
  • Java版本:Java 8 - Java 22 所有版本

如果还在使用Intel芯片的话,这次不受影响。

官方文章 Java users on macOS 14 running on Apple silicon systems should skip macOS 14.4 and update directly to macOS 14.4.1 (oracle.com)

在x上,Java开发领域的一些大v们,也发现了这个问题,并提醒大家不要升级。

其他资料

Java users on macOS 14 running on Apple silicon systems should consider delaying the macOS 14.4 update | Lobsters

【译】Lucene 查询语法

作者 lxb
2023年6月28日 15:02

原文:Query Parser Syntax

概览

Lucene 除了提供 API 方便开发者创建查询请求,还通过一个查询解析器(词法分析器,使用 JavaCC 将字符串翻译成 Lucene 查询语句)提供一种功能丰富的查询语言。

一般来说,查询解析器支持的语法在不同发布版本之间可能会有变化。本文档描述的是当前版本的语法。如果你正在使用一个不同版本的 Lucene,请参考该版本自带的 docs/queryparsersyntax.html 文档。

在选择使用这个查询解析器之前,请考虑以下 3 点:

  1. 如果你准备以编程的方式生成一个查询字符串,然后使用查询解析器来解析它。那么,你应该认真考虑一下是否应该直接使用查询 API 来构建查询。换句话说,查询解析器专门用于人类输入的文本,而不是程序生成的文本。
  2. 没有被识别为token的域最好直接添加到查询中,而不是通过查询解析器来解析。如果一个域的值是通过应用自动生成的,那么应该为这个域自动生成查询子句。查询解析器所使用的分析器是专门用于将人类输入的文本转换成词(terms)的。由程序生成的值,如日期、关键字等等,也应该由程序添加到查询中。
  3. 从查询形式来看,如果域的值是普通文本,则应该使用查询解析器。所有其它值类型,比如:日期范围、关键字等等,最好通过查询 API 直接添加。如果一个域的值仅限于一个有限的集合(可以通过一个下拉菜单指定),则不应该添加到查询字符串(后续会被解析)中,而是应该作为一个 TermQuery 子句添加到查询中。

词(Terms)

一个查询语句可以拆解成 词(terms) 和 操作符(operators)。词又分为两种:单个词(single Terms)和短语(Phrases)。

单个词是指 ”test“ 或 ”Hello“ 这类单词。

短语是指以双引号包围起来的一组单词,比如:”hello dolly“。

多个词(Multiple terms)可以使用布尔操作符组合在一起,实现一个更加复杂的查询(如下文所示)。

备注:用于创建索引的解析器也会用于解析查询字符串中的词和短语。因此,选择合适的解析器很重要,否则解析器可能会被查询字符串中的词干扰。

域(Fields)

Lucene 支持分多个字段/域的数据。搜索时,可以指定一个域,也可以使用默认域。域的名称以及默认域与具体实现相关。

输入域的名称,后跟一个冒号(:),再跟目标搜索词,即可对任意一个域进行搜索。

举例来说,假设一个 Lucene 索引包含 2 个域:title 和 text,text 是默认域。若想查找标题为 ”The Right Way“ 且文本内容包含 ”don’t go this way“ 的文档,可以输入:

1
2
title:"The Right Way" AND text:go

或者:

1
title:"The Right Way" AND go

因为 text 是默认域,所以域标识符可以省略。

注意:指定的域仅对紧跟其后的词生效,因此,如下查询:

1
title:The Right Way

将对 title 域仅查找 ”The“,并对默认域(当前这个例子中是指 text 域)查找 ”Right“ 和 ”Way“。

词修饰语(Term Modifiers)

Lucene 支持修饰查询词(modifying query terms)来提供多种搜索方式。

通配符搜索

Lucene 支持对单个词(single terms)(不是短语查询 phrase queries)进行单个字符和多个字符的通配搜索。

使用 ? 符号进行单个字符的通配搜索。

使用 * 符号进行多个字符的通配搜索。

单字符通配搜索用于查找替换单个字符即可匹配的词。举例来说,若要搜索 ”text“ 或 ”test“,可以如下查询:

1
te?t

多字符通配搜索用于查找替换0个或多个字符即可匹配的词。举例来说,若要搜索 ”test“、”tests“ 或 ”tester“,可以如下查询:

1
test*

也可以对词的中间部分进行通配搜索:

1
te*t

备注: *? 符号不能用作搜索语句的第一个字符。

正则表达式搜索

Lucene 支持正则表达式搜索,匹配斜杠(/) 之间的模式。正则表达式的语法在不同的发布版本之间可能会有差异,目前支持的语法在 RegExp 类文档中有说明。举例来说,查找包含 ”moat“ 或 ”boat“ 的文档:

1
/[mb]oat/

模糊搜索

Lucene 支持基于 Damerau-Levenshtein 编辑距离的模糊搜索。在单个词的最后添加波浪符(~)即可进行模糊搜索。举例来说,使用模糊搜索查找在拼写上与 ”roam“ 近似的词:

1
roam~

这个查询语句会找到 foam 和 roams 这类词。

模糊搜索可以通过一个额外(可选)的参数来指定允许的最大编辑次数。这个参数值界于 0 和 2 之间,例如:

1
roam~1

如果未指定该参数,则默认使用 2 个编辑距离。

以前,这里还允许使用浮点数。现在这个语法已被考虑弃用,将于 Lucene 5.0 中移除。

邻近搜索

Lucene 支持查找指定距离的邻近词。在短语的最后添加拨浪符(~)即可进行邻近搜索。举例来说,在文档中搜索 ”apache“ 和 ”jakarta“ 相距 10 个词的模式:

1
"jakarta apache"~10

范围搜索

范围查询可以匹配到域的值在范围查询语句指定的上下界之间的所有文档。对于上下界的值,范围查询可以包含也可以不包含。排序是按照字典序进行的。

1
mod_date:[20020101 TO 20030101]

这个查询语句会查找 mod_date 域的值在 20020101 和 20030101 (包含上下界) 之间的文档。注意:范围查询对非日期的域也可以使用:

1
title:{Aida TO Carmen}

这个查询语句能查找到 title 域的值在 Aida 和 Carmen (不包含上下界)之间的所有文档。

中括号表示范围查询包含上下界,花括号表示范围查询不包含上下界。

相关性查询(Boosting a term)

Lucene 会基于文档中找到的词对匹配到的文档设置相关性的级别。可以在目标搜索词之后紧接一个脱字符 “^”,后跟一个加权系数(一个数字)来提升该搜索词的相关性权重。加权系数越高,查询命中的文档与该词的相关性越强。

你可以通过对某词进行加权来控制文档的相关性。例如,假设你正在搜索:

1
jakarta apache

然后希望搜索结果和词 ”jakarta“ 的相关性更强一些,则可以使用 ”^“ 符号后跟一个加权系数对这个词进行加权,即如下这样查询:

1
jakarta^4 apache

这会使得查找到的文档和词 ”jakarta“ 看起来相关性更强一些。你也可以对短语进行加权,如下所示:

1
"jakarta apache"^4 "Apache Lucene"

默认加权系数是 1。加权系统可以小于 1(比如:0.2),但必须大于 0。

布尔操作符

布尔操作符允许使用逻辑操作符组合多个词。Lucene 支持的布尔操作符包含 AND+ORNOT-

(备注:布尔操作符必须全部是大写字母)。

OR

“OR” 操作符是默认的连接操作符。这意味着如果两个词之间没有布尔操作符,则lucene会使用 “OR” 操作符。OR 操作符链接两个词,并匹配包含其中任意一个词的文档。这相当于集合的并集操作。“||” 符合可用于替代单词 “OR”。

比如,使用如下查询语句来搜索包含 “jakarta apache” 或仅是 “jakarta” 的文档:

1
"jakarta apache" jakarta

或:

1
2
"jakarta apache" OR jakarta

AND

“AND” 操作符会匹配文本内容中同时存在两个要查询的词(因为 AND 是二元操作符)的文档。这相当于集合的交集操作。“&&” 符号可用于替代单词 “AND”。

比如,使用如下查询语句来搜索包含 “jakarta apache” 和 “Apache Lucene” 的文档:

1
"jakarta apache" AND “Apache Lucene”

+

“+”(必需)操作符要求文档的某个域中包含 “+” 符号之后的词。

比如,使用如下查询语句来搜索(必须)包含 “jakarta” 以及可能包含 “lucene”(包不包含都可以)的文档:

1
+jakarta lucene

NOT

”NOT“ 操作符会排除包含”NOT“之后的词的文档。这相当于集合的差集操作。也可以用”!“ 符号代替 ”NOT“。

比如,使用如下查询语句搜索包含 ”jakarta apache“ 但不包含 ”Apache Lucene“ 的文档”:

1
"jakarta apache" NOT "Apache Lucene"

备注:“NOT” 操作符不可以用于单个词。例如,如下搜索不会返回任何结果:

1
NOT "jakarta apache"

-

”-“(禁止)操作符会排除包含”-“符号之后的词的文档。

比如,使用如下查询语句来查询包含 ”jakarta apache“ 但不包含 ”Apache Lucene“ 的文档:

1
"jakarta apache" -"Apache Lucene"

分组

Lucene 支持使用圆括号对子句进行分组,构成子查询。这对于控制一个查询语句的布尔逻辑非常有用。

比如,使用如下查询语句来搜索包含 “jakarta” 或 “apache”,同时包含 “website” 的文档:

1
(jakarta OR apache) AND website

这样查询语句就没有了歧义:必须包含 ”website“,同时包含“jakarta” 或 ”apache“其中之一。

域分组

Lucene 支持使用圆括号对单个域的多个子句进行分组。

例如,若想搜索一个 title 中包含单词“return”同时包含短语“pink panther”,可以使用如下查询:

1
title:(+return +"pink panther")

特殊字符转义

Lucene 支持对查询语法使用的特殊字符进行转义。目前这些特殊字符如下列表所示:

1
+ - && || ! ( ) { } [ ] ^ " ~ * ? : \ /

在特殊字符之前加 \ 来转义。例如,使用如下查询语句来搜索 (1+1):2

1
\(1\+1\)\:2

重定向广告

作者 xbl
2024年3月4日 13:27

为什么在京东搜索的商品,会展示在抖音广告上

不知道你是不是也会遇到这个情况,你刚才说想要个戴森吹风机,头条APP里的广告就展示了戴森的广告。

当你在京东的搜索框里搜索了蒸锅后,你的其他软件可能就被蒸锅攻陷了。

头条广告里看到苏泊尔蒸锅的广告,刷抖音时能看到苏泊尔的广告,上个知乎还能看到苏泊尔的广告……。是不是感觉自己完全陷入了恶性循环,进了套路里。

甚至是,你从两部手机切换过后,还是会看到苏泊尔蒸锅的广告!

如果你认为是巧合,那就大错特错了,其实这是广告界常用的产品,叫做重定向广告

这个操作对广告主来说那是极好的,实际上是却给人带来了极大的恐惧感。就好比你在微信的聊天记录,公开到了所有产品上,被所有人无情鞭策。

互联网时代信息公开透明,但却让人们少了隐私。

为什么主流产品都这么干

思考一个场景:

假设你在京东搜索电视机,可能你是有购买意向的,但最后没有下单转化。对于平台来说,损失了一个客户。如果,同时有几万人都有这个行为,那对平台来说就损失很大了。

所以,平台要做两件事。

一是在站内,做二次营销。

比如弹出搜索的专属红包,或专属优惠券,用红包权益进行二次刺激。

如下图每日优鲜的截图,搜索之后,进入商详页,会直接给相关的优惠券,刺激下单转化。据说让利10元,可以提升转化率40%以上。

二是在站外,做重定向广告

它就像定位器一样,无时不刻找到你并给你展示广告。假如你离开了网站,平台就会有这个方法牢牢拴住你。

比如在京东搜索蒸锅,马上在头条就会投放蒸锅的广告,而且每次的广告品牌可能也会不一样

据 Google Adwords提供的数据,在30天内为同一个用户展示7~10次广告的转化效果最好,做到这个程度的广告收益可以达到三倍以上。

站内浏览广告并购买的用户仅为5%,有95%的用户流失掉了,拉回并转化这些用户,则是重定向广告的使命。

坦福大学商学院的营销学教授Navdeep Sahni曾经做过一个实验,他利用各种重定向活动对 http://BuildDirect.com 的23w用户进行访问观测。

在观测期间,有的用户零广告观看,有的用户观看15次以上,经过4周的试验后,得出结论:重定向广告增加了他们返回网站的可能性接近15%,很大一部分人因为重定向广告,改变了行为。

整个过程是什么样子的

其实,整个过程并不复杂

假如你作为电商网站的产品经理,如果想要接入重定向广告能力,需要做这几步,

1. 在你的网站埋入统计追踪代码

有些是广告联盟提供的是插件或者SDK,比如 Avazu DSP提供的就是一个插件,给到其他商家店铺。

这份代码主要目的是为了把用户的信息记录到浏览器或手机本地的cookie里,其投放平台读取这份cookie,其中包含用户id,电话号,基本信息,访问信息等。

当然,cookie不是万能的,还会引入其他的手段继续识别用户身份,比如设备识别码,手机信息,web浏览器、操作系统、屏幕分辨率、时区、语言、插件、字体等

这些信息可以确定唯一的用户身份,当你换设备的时候,其实已经通过这些信息再一次进行了关联。

逃?那是不可能的,细思极恐吧!

2. 在线竞价

先普及一下广告业务的竞价排名。简单来讲,就是通过价格优势来竞争广告位。

比如百度的广告,你出价“起点学院”的广告词,虽然你是起点学院的负责人老曹,但是并不一定能够拿到这个词,因为友商三节课花的价钱更高,拿到了“起点学院”这个词。

当用户在百度搜索“起点学院”时,出现的是三节课的推广(当然,这并不会发生)。

我们回到重定向广告的竞价,其实一个道理,用户在淘宝搜索蒸锅,也在京东搜索的蒸锅,那到底是展示淘宝的推广还是京东的推广呢?

还是一决雌雄吧,我们靠竞价说话,谁价格高,广告联盟就出谁的广告

3. 展示广告

凡是和广告联盟对接的流量平台, 都可以承接广告。当流量平台识别出用户id后,自动替换掉默认出的广告图,打上个性化广告。

下面的是Avazu DSP可以投放的流量平台,用户在这些流量渠道即可看到重定向广告。

当重定向发展到第二阶段时,由原来的搜索1对1关系,扩散到1对多的关系。算法的引入,可以更精准预测了用户的购买需求。

比如本来搜索的是大疆无人机,那么推荐一些和大疆相关的其他商品,增加购买的可能性。

细思极恐的东西

作为一个吃瓜群众,莫名其妙的就被广告主割了韭菜,防不胜防,也无法防。

在2017年,苹果推出了新功能叫智能反追踪,并集成在 Safari浏览器中,为了保护用户的隐私不被泄露。

不过,苹果受不住利益的诱惑,在可以保住底裤的同时,今年又推出了隐私保护广告点击归因,其旨在即能保护用户隐私,还能给广告主信息进行广告投放。

简单来说,是用户把自己的信息存储到了cookie中,或者存储到手机设备中,广告主可以识别这些信息对应到人,但是却无法解析到更多其他信息了。

这个做法和阿里的数据银行比较相似,用户的详细数据是明确禁止外部传播的,数据银行给品牌产出的数据仅仅是人群包,人群包只能在内部有权限的业务识别,保障了数据的安全性。

微信刚聊完就收到商品推荐,电商App在监视我吗

当你搜索、点击、浏览、收藏、购买了某件商品后,紧接着就会收到网站或电商平台的相关广告推送,这已经不是什么新鲜事了,你在互联网上的一举一动,在商家眼里就是大数据和用户画像。

但是,当你的微信聊天记录、和同事面对面说话时的聊天内容、手机相册里的照片也会被电商APP用于推荐广告时,你会感到害怕吗?

近日有用户称,自己在微信群聊中讨论过一款雨伞,随后就收到了电商平台的短信推送。不仅如此,不少用户反映自己的手机相册、面对面聊天中的内容都有可能已被“窃听”,因为收到了与此相关的精准广告推送。

事实上,一些应用软件在安装时就获取了用户位置、相机、麦克风等诸多权限,你的所有信息和随后的浏览、搜索行为都会成为一个一个的数据库文件,最终组成有标签、有画像的“另一个自己”。不光地域、性别、消费习惯,商家还能知道你手机里装了哪些APP。

从技术上来看,分析提取文字、图片、语音、视频等内容中的商品信息并做精准推荐并无难度,可能泄露个人信息的“重灾区”主要集中在应用软件、输入法、公开WiFi、运营商等方面。对于电商广告投放平台来说,接入第三方数据库的行为非常普遍,基本可以算是标配。

1 无处不在的精准推荐

近日,用户A称,8月14日下午,有朋友在微信群里询问“赤峰有没有蕉下伞专柜”,他回答称“不太清楚”。第二天上午,他就收到当当网的短信,推送了“蕉下小黑伞清仓99元”的购买链接。

用户A表示,除了在微信群里和朋友互动之外,他没有在任何地方搜索、浏览过雨伞。“收到当当短信的那一刻,直觉告诉我,我被监控了。”

事后,当当网客服表示,当当网每期的推送是根据平台的促销活动随机发出,用户在当当网的搜索和购买记录平台能获取,但在其它平台上的痕迹并不能获取到。微信团队尚未对此事给出回复。

用户A的案例只是“被监控”的一种情况,用户B则是相册信息被读取。她穿了一套新衣服,拍了一些照片存在手机相册里,随后她打开淘宝首页,推荐的商品均是该款衣服或类似款式。

汪雨表示,她有一次在跟淘宝客服进行售后交涉时候需要上传照片,所以打开了淘宝访问相册的权限,“我授权该权限是出于购物沟通服务的目的,并没有同意平台用我的相册内容推送广告,更不知道他会不会转给第三方或用作其它用途。”

类似的情况时有发生,有用户称刚发布一条表示希望阿迪达斯可以把某款鞋的设计师请回来,并配了相关图片,随后就收到了该鞋的推荐广告。

以上种种案例表明,除了我们日常搜索、浏览、购物之外,相册照片、微信私人聊天记录和群聊记录、物理对话、在社交平台上发布的文字和图片等,都有可能被广告盯上,在互联网包围下的我们仿佛变成了“透明人”。

2 应用软件、输入法、Wi-Fi 成信息泄露重灾区

电商平台是怎么获取用户数据的?

对于相册照片被读取的情况,淘宝或者这些大平台的隐私协议都写了会获取用户的内容,这中间是有灰色地带的,有可能一些软件借着正当业务需求获得了用户权限之后,再用于广告投放或其它用途,但它用隐私条款巧妙回避了法律责任,打了擦边球。一些软件获取了用户的相册、话筒权限后,识别照片、语音是一件很容易的事。

一些第三方输入法,包括为了提升用户体验开启的云词库等都可能是信息泄露的方式,输入法在免费给用户使用的同时也可能在做一些盈利的事。

公开WiFi也是泄露个人信息的一大风险,很多时候免费连WiFi或者手机开启了WiFi模式,就会自动去检索附近的热点,一旦碰上,WiFi都可以收取一定程度的数据。

此外,安卓手机上的很多应用可以开启特殊权限,是隐私泄露的重灾区。它们可能通过一些应用获取到信息,包括聊天消息上传到云端,AI快速精准识别关键字,推送信息给广告商。

3 网络行为怎么变成广告推荐?

拿输入法来说,输入法的数据类似于一个第三方DMP(数据管理平台),拿到这个数据后,还要进行一次数据库匹配,例如和一部手机的Device ID匹配,如果发现用户之前输入过某品牌的雨伞,就可以推送这款产品给用户。而电商广告投放平台接入第三方DMP的行为非常普遍,基本可以算是标配。

百度、阿里、腾讯等都会将平台上的用户行为打标签做成数据库对第三方开放,付费后就能共享信息。

“我们的广告投放主要通过大数据分析,比如通过阿里数据库得到一部分喜欢网购的人的标签,包括他们的购物习惯、消费行为、地域,比如一个用户常浏览汽车、珠宝等品类,消费客单价较高,可能会被定位成高端消费群体,我们找到这些人平时刷什么样的平台,针对性地去投放广告。”

具体的投放方式可以基于地理位置,或是依据数据定向投放。

另一种方式是可以通过一些数据知道这些人的设备里下载了哪些APP,如果很大比例的人下载了某一款,就可以去该平台投放广告。举例来说,假如某平台有5000万注册用户,经检测发现这些人中有大部分下载了知乎、豆瓣,第三方广告商就可以去这两个平台上投放广告,再结合用户在百度的浏览记录、淘宝的购买记录等,就会有一个更加精准的用户画像。

腾讯广告投放包括微信和广点通(覆盖微信之外的QQ、腾讯新闻、QQ浏览器、天天快报等腾讯旗下产品),微信上目前的广告是公众号内文和末尾广告、朋友圈广告、小程序贴片广告,公众号是随机投放,按点击付费一次0.5元起。

“腾讯大数据会分析用户的行为,比如用户经常会浏览、搜索、点击、购买支付的是哪些内容,腾讯旗下各产品之间的信息是互通的,所有发生的用户行为就会形成一个用户画像,做成一个标签,比如最终得出某用户的健身情况、学历教育、对美妆护肤的喜好等。”

朋友圈广告投放是先根据品牌需求定向筛选用户,再按广告曝光次数收费,北京和上海这样的核心城市是0.1元/次曝光,广州、深圳、苏州、杭州是重点城市,0.06元/次曝光,下一级普通城市0.03元/次曝光。

也就是说,如果刷朋友圈的时候看到了某品牌的广告,不管有没有点击,都算一次广告曝光,北京的用户刷到一个广告就为微信贡献了1毛钱广告费。

“各种拉票软件、会议软件、文献提供者、新闻阅读软件等的第一步都是要得到用户的各种数据获取的授权才能运用,这些APP从诞生之日起都有着强制性、偷窥目的的恶意,不少隐蔽性非常强,或者存在强制性获取个人信息的情况。”

Lucene 三: Lucene 的索引文件格式

作者 xbl
2023年9月3日 14:05

Lucene的索引里面存了些什么,如何存放的,也即Lucene的索引文件格式,是读懂Lucene源代码的一把钥匙。

当我们真正进入到Lucene源代码之中的时候,我们会发现:

  • Lucene的索引过程,就是按照全文检索的基本过程,将倒排表写成此文件格式的过程。
  • Lucene的搜索过程,就是按照此文件格式将索引进去的信息读出来,然后计算每篇文档打分(score)的过程。

参考官网 Apache Lucene - Index File Formats

一、基本概念

下图就是Lucene生成的索引的一个实例:

Lucene的索引结构是有层次结构的,主要分以下几个层次:

  • 索引(Index):
    • 在Lucene中一个索引是放在一个文件夹中的。
    • 如上图,同一文件夹中的所有的文件构成一个Lucene索引。
  • 段(Segment):
    • 一个索引可以包含多个段,段与段之间是独立的,添加新文档可以生成新的段,不同的段可以合并。
    • 如上图,具有相同前缀文件的属同一个段,图中共两个段 “_0” 和 “_1”。
    • segments.gen和segments_5是段的元数据文件,也即它们保存了段的属性信息。
  • 文档(Document):
    • 文档是我们建索引的基本单位,不同的文档是保存在不同的段中的,一个段可以包含多篇文档。
    • 新添加的文档是单独保存在一个新生成的段中,随着段的合并,不同的文档合并到同一个段中。
  • 域(Field):
    • 一篇文档包含不同类型的信息,可以分开索引,比如标题,时间,正文,作者等,都可以保存在不同的域里。
    • 不同域的索引方式可以不同,在真正解析域的存储的时候,我们会详细解读。
  • 词(Term):
    • 词是索引的最小单位,是经过词法分析和语言处理后的字符串。

Lucene的索引结构中,即保存了正向信息,也保存了反向信息。

所谓正向信息:

  • 按层次保存了从索引,一直到词的包含关系:索引(Index) –> 段(segment) –> 文档(Document) –> 域(Field) –> 词(Term)
  • 也即此索引包含了那些段,每个段包含了那些文档,每个文档包含了那些域,每个域包含了那些词。
  • 既然是层次结构,则每个层次都保存了本层次的信息以及下一层次的元信息,也即属性信息,比如一本介绍中国地理的书,应该首先介绍中国地理的概况,以及中国包含多少个省,每个省介绍本省的基本概况及包含多少个市,每个市介绍本市的基本概况及包含多少个县,每个县具体介绍每个县的具体情况。
  • 如上图,包含正向信息的文件有:
    • segments_N保存了此索引包含多少个段,每个段包含多少篇文档。
    • XXX.fnm保存了此段包含了多少个域,每个域的名称及索引方式。
    • XXX.fdx,XXX.fdt保存了此段包含的所有文档,每篇文档包含了多少域,每个域保存了那些信息。
    • XXX.tvx,XXX.tvd,XXX.tvf保存了此段包含多少文档,每篇文档包含了多少域,每个域包含了多少词,每个词的字符串,位置等信息。

所谓反向信息:

  • 保存了词典到倒排表的映射:词(Term) –> 文档(Document)
  • 如上图,包含反向信息的文件有:
    • XXX.tis,XXX.tii保存了词典(Term Dictionary),也即此段包含的所有的词按字典顺序的排序。
    • XXX.frq保存了倒排表,也即包含每个词的文档ID列表。
    • XXX.prx保存了倒排表中每个词在包含此词的文档中的位置。

在了解Lucene索引的详细结构之前,先看看Lucene索引中的基本数据类型。

二、基本类型

Lucene索引文件中,用以下基本类型来保存信息:

  • Byte:是最基本的类型,长8位(bit)。
  • UInt32:由4个Byte组成。
  • UInt64:由8个Byte组成。
  • VInt:
    • 变长的整数类型,它可能包含多个Byte,对于每个Byte的8位,其中后7位表示数值,最高1位表示是否还有另一个Byte,0表示没有,1表示有。
    • 越前面的Byte表示数值的低位,越后面的Byte表示数值的高位。
    • 例如130化为二进制为 1000, 0010,总共需要8位,一个Byte表示不了,因而需要两个Byte来表示,第一个Byte表示后7位,并且在最高位置1来表示后面还有一个Byte,所以为(1) 0000010,第二个Byte表示第8位,并且最高位置0来表示后面没有其他的Byte了,所以为(0) 0000001。

  • Chars:是UTF-8编码的一系列Byte。
  • String:一个字符串首先是一个VInt来表示此字符串包含的字符的个数,接着便是UTF-8编码的字符序列Chars。

三、基本规则

Lucene为了使的信息的存储占用的空间更小,访问速度更快,采取了一些特殊的技巧,然而在看Lucene文件格式的时候,这些技巧却容易使我们感到困惑,所以有必要把这些特殊的技巧规则提取出来介绍一下。

1. 前缀后缀规则(Prefix+Suffix)

Lucene在反向索引中,要保存词典(Term Dictionary)的信息,所有的词(Term)在词典中是按照字典顺序进行排列的,然而词典中包含了文档中的几乎所有的词,并且有的词还是非常的长的,这样索引文件会非常的大,所谓前缀后缀规则,即当某个词和前一个词有共同的前缀的时候,后面的词仅仅保存前缀在词中的偏移(offset),以及除前缀以外的字符串(称为后缀)。

比如要存储如下词:term,termagancy,termagant,terminal,

如果按照正常方式来存储,需要的空间如下:

[VInt = 4] [t][e][r][m],

[VInt = 10][t][e][r][m][a][g][a][n][c][y],

[VInt = 9][t][e][r][m][a][g][a][n][t],

[VInt = 8][t][e][r][m][i][n][a][l]

共需要35个Byte.

如果应用前缀后缀规则,需要的空间如下:

[VInt = 4] [t][e][r][m],

[VInt = 4 (offset)][VInt = 6][a][g][a][n][c][y],

[VInt = 8 (offset)][VInt = 1][t],

[VInt = 4 (offset)][VInt = 4][i][n][a][l]

共需要22个Byte。

大大缩小了存储空间,尤其是在按字典顺序排序的情况下,前缀的重合率大大提高。

2. 差值规则(Delta)

在Lucene的反向索引中,需要保存很多整型数字的信息,比如文档ID号,比如词(Term)在文档中的位置等等。

由上面介绍,我们知道,整型数字是以VInt的格式存储的。随着数值的增大,每个数字占用的Byte的个数也逐渐的增多。所谓差值规则(Delta)就是先后保存两个整数的时候,后面的整数仅仅保存和前面整数的差即可。

比如要存储如下整数:16386,16387,16388,16389

如果按照正常方式来存储,需要的空间如下:

[(1) 000, 0010][(1) 000, 0000][(0) 000, 0001],

[(1) 000, 0011][(1) 000, 0000][(0) 000, 0001],

[(1) 000, 0100][(1) 000, 0000][(0) 000, 0001],

[(1) 000, 0101][(1) 000, 0000][(0) 000, 0001]

供需12个Byte。

如果应用差值规则来存储,需要的空间如下:

[(1) 000, 0010][(1) 000, 0000][(0) 000, 0001],

[(0) 000, 0001],

[(0) 000, 0001],

[(0) 000, 0001]

共需6个Byte。

大大缩小了存储空间,而且无论是文档ID,还是词在文档中的位置,都是按从小到大的顺序,逐渐增大的。

3. 或然跟随规则(A, B?)

Lucene的索引结构中存在这样的情况,某个值A后面可能存在某个值B,也可能不存在,需要一个标志来表示后面是否跟随着B。

一般的情况下,在A后面放置一个Byte,为0则后面不存在B,为1则后面存在B,或者0则后面存在B,1则后面不存在B。

但这样要浪费一个Byte的空间,其实一个Bit就可以了。

在Lucene中,采取以下的方式:A的值左移一位,空出最后一位,作为标志位,来表示后面是否跟随B,所以在这种情况下,A/2是真正的A原来的值。

如果去读Apache Lucene - Index File Formats这篇文章,会发现很多符合这种规则的:

  • .frq文件中的DocDelta[, Freq?],DocSkip,PayloadLength?
  • .prx文件中的PositionDelta,Payload? (但不完全是,如下表分析)

当然还有一些带?的但不属于此规则的:

  • .frq文件中的SkipChildLevelPointer?,是多层跳跃表中,指向下一层表的指针,当然如果是最后一层,此值就不存在,也不需要标志。
  • .tvf文件中的Positions?, Offsets?。
    • 在此类情况下,带?的值是否存在,并不取决于前面的值的最后一位。
    • 而是取决于Lucene的某项配置,当然这些配置也是保存在Lucene索引文件中的。
    • 如Position和Offset是否存储,取决于.fnm文件中对于每个域的配置(TermVector.WITH_POSITIONS和TermVector.WITH_OFFSETS)

为什么会存在以上两种情况,其实是可以理解的:

  • 对于符合或然跟随规则的,是因为对于每一个A,B是否存在都不相同,当这种情况大量存在的时候,从一个Byte到一个Bit如此8倍的空间节约还是很值得的。
  • 对于不符合或然跟随规则的,是因为某个值的是否存在的配置对于整个域(Field)甚至整个索引都是有效的,而非每次的情况都不相同,因而可以统一存放一个标志。

文章中对如下格式的描述令人困惑:

Positions –> <PositionDelta,Payload?> Freq

Payload –> <PayloadLength?,PayloadData>

PositionDelta和Payload是否适用或然跟随规则呢?如何标识PayloadLength是否存在呢?

其实PositionDelta和Payload并不符合或然跟随规则,Payload是否存在,是由.fnm文件中对于每个域的配置中有关Payload的配置决定的(FieldOption.STORES_PAYLOADS) 。

当Payload不存在时,PayloadDelta本身不遵从或然跟随原则。

当Payload存在时,格式应该变成如下:Positions –> <PositionDelta,PayloadLength?,PayloadData> Freq

从而PositionDelta和PayloadLength一起适用或然跟随规则。

4. 跳跃表规则(Skip list)

为了提高查找的性能,Lucene在很多地方采取的跳跃表的数据结构。

跳跃表(Skip List)是如图的一种数据结构,有以下几个基本特征:

  • 元素是按顺序排列的,在Lucene中,或是按字典顺序排列,或是按从小到大顺序排列。
  • 跳跃是有间隔的(Interval),也即每次跳跃的元素数,间隔是事先配置好的,如图跳跃表的间隔为3。
  • 跳跃表是由层次的(level),每一层的每隔指定间隔的元素构成上一层,如图跳跃表共有2层。

需要注意一点的是,在很多数据结构或算法书中都会有跳跃表的描述,原理都是大致相同的,但是定义稍有差别:

  • 对间隔(Interval)的定义: 如图中,有的认为间隔为2,即两个上层元素之间的元素数,不包括两个上层元素;有的认为是3,即两个上层元素之间的差,包括后面上层元素,不包括前面的上层元素;有的认为是4,即除两个上层元素之间的元素外,既包括前面,也包括后面的上层元素。Lucene是采取的第二种定义。
  • 对层次(Level)的定义:如图中,有的认为应该包括原链表层,并从1开始计数,则总层次为3,为1,2,3层;有的认为应该包括原链表层,并从0计数,为0,1,2层;有的认为不应该包括原链表层,且从1开始计数,则为1,2层;有的认为不应该包括链表层,且从0开始计数,则为0,1层。Lucene采取的是最后一种定义。

跳跃表比顺序查找,大大提高了查找速度,如查找元素72,原来要访问2,3,7,12,23,37,39,44,50,72总共10个元素,应用跳跃表后,只要首先访问第1层的50,发现72大于50,而第1层无下一个节点,然后访问第2层的94,发现94大于72,然后访问原链表的72,找到元素,共需要访问3个元素即可。

然而Lucene在具体实现上,与理论又有所不同,在具体的格式中,会详细说明。

四、具体格式

上面曾经交代过,Lucene保存了从Index到Segment到Document到Field一直到Term的正向信息,也包括了从Term到Document映射的反向信息,还有其他一些Lucene特有的信息。下面对这三种信息一一介绍。

4.1. 正向信息

Index –> Segments (segments.gen, segments_N) –> Field(fnm, fdx, fdt) –> Term (tvx, tvd, tvf)

上面的层次结构不是十分的准确,因为segments.gen和segments_N保存的是段(segment)的元数据信息(metadata),其实是每个Index一个的,而段的真正的数据信息,是保存在域(Field)和词(Term)中的。

4.1.1. 段的元数据信息(segments_N)

一个索引(Index)可以同时存在多个segments_N(至于如何存在多个segments_N,在描述完详细信息之后会举例说明),然而当我们要打开一个索引的时候,我们必须要选择一个来打开,那如何选择哪个segments_N呢?

Lucene采取以下过程:

  • 其一,在所有的segments_N中选择N最大的一个。基本逻辑参照SegmentInfos.getCurrentSegmentGeneration(File[] files),其基本思路就是在所有以segments开头,并且不是segments.gen的文件中,选择N最大的一个作为genA。

  • 其二,打开segments.gen,其中保存了当前的N值。其格式如下,读出版本号(Version),然后再读出两个N,如果两者相等,则作为genB。

1
2
3
4
5
6
7
8
9
IndexInput genInput = directory.openInput(IndexFileNames.SEGMENTS_GEN);//"segments.gen"  
int version = genInput.readInt();//读出版本号
if (version == FORMAT_LOCKLESS) {//如果版本号正确
long gen0 = genInput.readLong();//读出第一个N
long gen1 = genInput.readLong();//读出第二个N
if (gen0 == gen1) {//如果两者相等则为genB
genB = gen0;
}
}
  • 其三,在上述得到的genA和genB中选择最大的那个作为当前的N,方才打开segments_N文件。其基本逻辑如下:

    1
    2
    3
    4
    if (genA > genB)  
    gen = genA;
    else
    gen = genB;

如下图是segments_N的具体格式:

  • Format:
    • 索引文件格式的版本号。
    • 由于Lucene是在不断开发过程中的,因而不同版本的Lucene,其索引文件格式也不尽相同,于是规定一个版本号。
    • Lucene 2.1此值-3,Lucene 2.9时,此值为-9。
    • 当用某个版本号的IndexReader读取另一个版本号生成的索引的时候,会因为此值不同而报错。
  • Version:
    • 索引的版本号,记录了IndexWriter将修改提交到索引文件中的次数。
    • 其初始值大多数情况下从索引文件里面读出,仅仅在索引开始创建的时候,被赋予当前的时间,已取得一个唯一值。
    • 其值改变在 IndexWriter.commit->IndexWriter.startCommit->SegmentInfos.prepareCommit->SegmentInfos.write->writeLong(++version)
    • 其初始值之所最初取一个时间,是因为我们并不关心IndexWriter将修改提交到索引的具体次数,而更关心到底哪个是最新的。IndexReader中常比较自己的version和索引文件中的version是否相同来判断此IndexReader被打开后,还有没有被IndexWriter更新。
1
2
3
4
5
//在DirectoryReader中有一下函数。

public boolean isCurrent() throws CorruptIndexException, IOException {
return SegmentInfos.readCurrentVersion(directory) == segmentInfos.getVersion();
}
  • NameCount
    • 是下一个新段(Segment)的段名。
    • 所有属于同一个段的索引文件都以段名作为文件名,一般为_0.xxx, _0.yyy, _1.xxx, _1.yyy ……
    • 新生成的段的段名一般为原有最大段名加一。
    • 如同的索引,NameCount读出来是2,说明新的段为_2.xxx, _2.yyy

  • SegCount
    • 段(Segment)的个数。
    • 如上图,此值为2。
  • SegCount个段的元数据信息:
    • SegName
      • 段名,所有属于同一个段的文件都有以段名作为文件名。
      • 如上图,第一个段的段名为”_0”,第二个段的段名为”_1”
    • SegSize
      • 此段中包含的文档数
      • 然而此文档数是包括已经删除,又没有optimize的文档的,因为在optimize之前,Lucene的段中包含了所有被索引过的文档,而被删除的文档是保存在.del文件中的,在搜索的过程中,是先从段中读到了被删除的文档,然后再用.del中的标志,将这篇文档过滤掉。
      • 如下的代码形成了上图的索引,可以看出索引了两篇文档形成了_0段,然后又删除了其中一篇,形成了_0_1.del,又索引了两篇文档形成_1段,然后又删除了其中一篇,形成_1_1.del。因而在两个段中,此值都是2。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
IndexWriter writer = new IndexWriter(FSDirectory.open(INDEX\_DIR), new StandardAnalyzer(Version.LUCENE\_CURRENT), true, IndexWriter.MaxFieldLength.LIMITED);  
writer.setUseCompoundFile(false);
indexDocs(writer, docDir);//docDir中只有两篇文档

//文档一为:Students should be allowed to go out with their friends, but not allowed to drink beer.

//文档二为:My friend Jerry went to school to see his students but found them drunk which is not allowed.

writer.commit();//提交两篇文档,形成\_0段。

writer.deleteDocuments(new Term("contents", "school"));//删除文档二
writer.commit();//提交删除,形成\_0\_1.del
indexDocs(writer, docDir);//再次索引两篇文档,Lucene不能判别文档与文档的不同,因而算两篇新的文档。
writer.commit();//提交两篇文档,形成\_1段
writer.deleteDocuments(new Term("contents", "school"));//删除第二次添加的文档二
writer.close();//提交删除,形成\_1\_1.del
    • DelGen
      • .del文件的版本号
      • Lucene中,在optimize之前,删除的文档是保存在.del文件中的。
      • 在Lucene 2.9中,文档删除有以下几种方式:
        • IndexReader.deleteDocument(int docID)是用IndexReader按文档号删除。
        • IndexReader.deleteDocuments(Term term)是用IndexReader删除包含此词(Term)的文档。
        • IndexWriter.deleteDocuments(Term term)是用IndexWriter删除包含此词(Term)的文档。
        • IndexWriter.deleteDocuments(Term[] terms)是用IndexWriter删除包含这些词(Term)的文档。
        • IndexWriter.deleteDocuments(Query query)是用IndexWriter删除能满足此查询(Query)的文档。
        • IndexWriter.deleteDocuments(Query[] queries)是用IndexWriter删除能满足这些查询(Query)的文档。
        • 原来的版本中Lucene的删除一直是由IndexReader来完成的,在Lucene 2.9中虽可以用IndexWriter来删除,但是其实真正的实现是在IndexWriter中,保存了readerpool,当IndexWriter向索引文件提交删除的时候,仍然是从readerpool中得到相应的IndexReader,并用IndexReader来进行删除的。下面的代码可以说明:
1
2
3
4

IndexWriter.applyDeletes()
-> DocumentsWriter.applyDeletes(SegmentInfos)
-> reader.deleteDocument(doc);
        • DelGen是每当IndexWriter向索引文件中提交删除操作的时候,加1,并生成新的.del文件。

IndexWriter.commit()

-> IndexWriter.applyDeletes()

    -> IndexWriter$ReaderPool.release(SegmentReader)

         -> SegmentReader(IndexReader).commit()

             -> SegmentReader.doCommit(Map)

                  -> SegmentInfo.advanceDelGen()

                       -> if (delGen == NO) {
                              delGen = YES;
                           } else {
                              delGen++;
                           }

IndexWriter writer = new IndexWriter(FSDirectory.open(INDEX_DIR), new StandardAnalyzer(Version.LUCENE_CURRENT), true, IndexWriter.MaxFieldLength.LIMITED);
writer.setUseCompoundFile(false);

indexDocs(writer, docDir);//索引两篇文档,一篇包含"school",另一篇包含"beer"
writer.commit();//提交两篇文档到索引文件,形成段(Segment) "_0"
writer.deleteDocuments(new Term("contents", "school"));//删除包含"school"的文档,其实是删除了两篇文档中的一篇。
writer.commit();//提交删除到索引文件,形成"_0_1.del"
writer.deleteDocuments(new Term("contents", "beer"));//删除包含"beer"的文档,其实是删除了两篇文档中的另一篇。
writer.commit();//提交删除到索引文件,形成"_0_2.del"
indexDocs(writer, docDir);//索引两篇文档,和上次的文档相同,但是Lucene无法区分,认为是另外两篇文档。
writer.commit();//提交两篇文档到索引文件,形成段"_1"
writer.deleteDocuments(new Term("contents", "beer"));//删除包含"beer"的文档,其中段"_0"已经无可删除,段"_1"被删除一篇。
writer.close();//提交删除到索引文件,形成"_1_1.del"

形成的索引文件如下:

image

    • DocStoreOffset

    • DocStoreSegment

    • DocStoreIsCompoundFile

      • 对于域(Stored Field)和词向量(Term Vector)的存储可以有不同的方式,即可以每个段(Segment)单独存储自己的域和词向量信息,也可以多个段共享域和词向量,把它们存储到一个段中去。
      • 如果DocStoreOffset为-1,则此段单独存储自己的域和词向量,从存储文件上来看,如果此段段名为XXX,则此段有自己的XXX.fdt,XXX.fdx,XXX.tvf,XXX.tvd,XXX.tvx文件。DocStoreSegment和DocStoreIsCompoundFile在此处不被保存。
      • 如果DocStoreOffset不为-1,则DocStoreSegment保存了共享的段的名字,比如为YYY,DocStoreOffset则为此段的域及词向量信息在共享段中的偏移量。则此段没有自己的XXX.fdt,XXX.fdx,XXX.tvf,XXX.tvd,XXX.tvx文件,而是将信息存放在共享段的YYY.fdt,YYY.fdx,YYY.tvf,YYY.tvd,YYY.tvx文件中。
      • DocumentsWriter中有两个成员变量:String segment是当前索引信息存放的段,String docStoreSegment是域和词向量信息存储的段。两者可以相同也可以不同,决定了域和词向量信息是存储在本段中,还是和其他的段共享。
      • IndexWriter.flush(boolean triggerMerge, boolean flushDocStores, boolean flushDeletes)中第二个参数flushDocStores会影响到是否单独或是共享存储。其实最终影响的是DocumentsWriter.closeDocStore()。每当flushDocStores为false时,closeDocStore不被调用,说明下次添加到索引文件中的域和词向量信息是同此次共享一个段的。直到flushDocStores为true的时候,closeDocStore被调用,从而下次添加到索引文件中的域和词向量信息将被保存在一个新的段中,不同此次共享一个段(在这里需要指出的是Lucene的一个很奇怪的实现,虽然下次域和词向量信息是被保存到新的段中,然而段名却是这次被确定了的,在initSegmentName中当docStoreSegment == null时,被置为当前的segment,而非下一个新的segment,docStoreSegment = segment,于是会出现如下面的例子的现象)。
      • 好在共享域和词向量存储并不是经常被使用到,实现也或有缺陷,暂且解释到此。

      IndexWriter writer = new IndexWriter(FSDirectory.open(INDEX_DIR), new StandardAnalyzer(Version.LUCENE_CURRENT), true, IndexWriter.MaxFieldLength.LIMITED);
      writer.setUseCompoundFile(false);

indexDocs(writer, docDir);  writer.flush();

//flush生成segment “_0”,并且flush函数中,flushDocStores设为false,也即下个段将同本段共享域和词向量信息,这时DocumentsWriter中的docStoreSegment= “_0”。

indexDocs(writer, docDir);  writer.commit();

//commit生成segment “_1”,由于上次flushDocStores设为false,于是段”_1”的域以及词向量信息是保存在”_0”中的,在这个时刻,段”_1”并不生成自己的”_1.fdx”和”_1.fdt”。然而在commit函数中,flushDocStores设为true,也即下个段将单独使用新的段来存储域和词向量信息。然而这时,DocumentsWriter中的docStoreSegment= “_1”,也即当段”_2”存储其域和词向量信息的时候,是存在”_1.fdx”和”_1.fdt”中的,而段”_1”的域和词向量信息却是存在”_0.fdt”和”_0.fdx”中的,这一点非常令人困惑。 如图writer.commit的时候,_1.fdt和_1.fdx并没有形成。

indexDocs(writer, docDir);  writer.flush();

//段”_2”形成,由于上次flushDocStores设为true,其域和词向量信息是新创建一个段保存的,却是保存在_1.fdt和_1.fdx中的,这时候才产生了此二文件。

indexDocs(writer, docDir);  writer.flush();

//段”_3”形成,由于上次flushDocStores设为false,其域和词向量信息是共享一个段保存的,也是是保存在_1.fdt和_1.fdx中的

indexDocs(writer, docDir);  writer.commit();

//段”_4”形成,由于上次flushDocStores设为false,其域和词向量信息是共享一个段保存的,也是是保存在_1.fdt和_1.fdx中的。然而函数commit中flushDocStores设为true,也意味着下一个段将新创建一个段保存域和词向量信息,此时DocumentsWriter中docStoreSegment= “_4”,也表明了虽然段”_4”的域和词向量信息保存在了段”_1”中,将来的域和词向量信息却要保存在段”_4”中。此时”_4.fdx”和”_4.fdt”尚未产生。

indexDocs(writer, docDir);  writer.flush();

//段”_5”形成,由于上次flushDocStores设为true,其域和词向量信息是新创建一个段保存的,却是保存在_4.fdt和_4.fdx中的,这时候才产生了此二文件。

indexDocs(writer, docDir);  writer.commit();  writer.close();

//段”_6”形成,由于上次flushDocStores设为false,其域和词向量信息是共享一个段保存的,也是是保存在_4.fdt和_4.fdx中的

    • HasSingleNormFile
      • 在搜索的过程中,标准化因子(Normalization Factor)会影响文档最后的评分。
      • 不同的文档重要性不同,不同的域重要性也不同。因而每个文档的每个域都可以有自己的标准化因子。
      • 如果HasSingleNormFile为1,则所有的标准化因子都是存在.nrm文件中的。
      • 如果HasSingleNormFile不是1,则每个域都有自己的标准化因子文件.fN
    • NumField
      • 域的数量
    • NormGen
      • 如果每个域有自己的标准化因子文件,则此数组描述了每个标准化因子文件的版本号,也即.fN的N。
    • IsCompoundFile
      • 是否保存为复合文件,也即把同一个段中的文件按照一定格式,保存在一个文件当中,这样可以减少每次打开文件的个数。
      • 是否为复合文件,由接口IndexWriter.setUseCompoundFile(boolean)设定。
      • 非符合文件同符合文件的对比如下图:
    • DeletionCount
      • 记录了此段中删除的文档的数目。
    • HasProx
      • 如果至少有一个段omitTf为false,也即词频(term freqency)需要被保存,则HasProx为1,否则为0。
    • Diagnostics
      • 调试信息。
  • User map data

    • 保存了用户从字符串到字符串的映射Map
  • CheckSum

    • 此文件segment_N的校验和。

读取此文件格式参考SegmentInfos.read(Directory directory, String segmentFileName):

  • int format = input.readInt();

  • version = input.readLong(); // read version

  • counter = input.readInt(); // read counter

  • for (int i = input.readInt(); i > 0; i–) // read segmentInfos

    • add(new SegmentInfo(directory, format, input));
      • name = input.readString();
      • docCount = input.readInt();
      • delGen = input.readLong();
      • docStoreOffset = input.readInt();
      • docStoreSegment = input.readString();
      • docStoreIsCompoundFile = (1 == input.readByte());
      • hasSingleNormFile = (1 == input.readByte());
      • int numNormGen = input.readInt();
      • normGen = new long[numNormGen];
      • for(int j=0;j
      • normGen[j] = input.readLong();
    • isCompoundFile = input.readByte();
    • delCount = input.readInt();
    • hasProx = input.readByte() == 1;
    • diagnostics = input.readStringStringMap();
  • userData = input.readStringStringMap();

  • final long checksumNow = input.getChecksum();

  • final long checksumThen = input.readLong();

4.1.2. 域(Field)的元数据信息(.fnm)

一个段(Segment)包含多个域,每个域都有一些元数据信息,保存在.fnm文件中,.fnm文件的格式如下:

  • FNMVersion
    • 是fnm文件的版本号,对于Lucene 2.9为-2
  • FieldsCount
    • 域的数目
  • 一个数组的域(Fields)
    • FieldName:域名,如”title”,”modified”,”content”等。
    • FieldBits:一系列标志位,表明对此域的索引方式
      • 最低位:1表示此域被索引,0则不被索引。所谓被索引,也即放到倒排表中去。
        • 仅仅被索引的域才能够被搜到。
        • Field.Index.NO则表示不被索引。
        • Field.Index.ANALYZED则表示不但被索引,而且被分词,比如索引”hello world”后,无论是搜”hello”,还是搜”world”都能够被搜到。
        • Field.Index.NOT_ANALYZED表示虽然被索引,但是不分词,比如索引”hello world”后,仅当搜”hello world”时,能够搜到,搜”hello”和搜”world”都搜不到。
        • 一个域出了能够被索引,还能够被存储,仅仅被存储的域是搜索不到的,但是能通过文档号查到,多用于不想被搜索到,但是在通过其它域能够搜索到的情况下,能够随着文档号返回给用户的域。
        • Field.Store.Yes则表示存储此域,Field.Store.NO则表示不存储此域。
      • 倒数第二位:1表示保存词向量,0为不保存词向量。
        • Field.TermVector.YES表示保存词向量。
        • Field.TermVector.NO表示不保存词向量。
      • 倒数第三位:1表示在词向量中保存位置信息。
        • Field.TermVector.WITH_POSITIONS
      • 倒数第四位:1表示在词向量中保存偏移量信息。
        • Field.TermVector.WITH_OFFSETS
      • 倒数第五位:1表示不保存标准化因子
        • Field.Index.ANALYZED_NO_NORMS
        • Field.Index.NOT_ANALYZED_NO_NORMS
      • 倒数第六位:是否保存payload

要了解域的元数据信息,还要了解以下几点:

  • 位置(Position)和偏移量(Offset)的区别
    • 位置是基于词Term的,偏移量是基于字母或汉字的。

  • 索引域(Indexed)和存储域(Stored)的区别
    • 一个域为什么会被存储(store)而不被索引(Index)呢?在一个文档中的所有信息中,有这样一部分信息,可能不想被索引从而可以搜索到,但是当这个文档由于其他的信息被搜索到时,可以同其他信息一同返回。
    • 举个例子,读研究生时,您好不容易写了一篇论文交给您的导师,您的导师却要他所第一作者而您做第二作者,然而您导师不想别人在论文系统中搜索您的名字时找到这篇论文,于是在论文系统中,把第二作者这个Field的Indexed设为false,这样别人搜索您的名字,永远不知道您写过这篇论文,只有在别人搜索您导师的名字从而找到您的文章时,在一个角落表述着第二作者是您。
  • payload的使用
    • 我们知道,索引是以倒排表形式存储的,对于每一个词,都保存了包含这个词的一个链表,当然为了加快查询速度,此链表多用跳跃表进行存储。
    • Payload信息就是存储在倒排表中的,同文档号一起存放,多用于存储与每篇文档相关的一些信息。当然这部分信息也可以存储域里(stored Field),两者从功能上基本是一样的,然而当要存储的信息很多的时候,存放在倒排表里,利用跳跃表,有利于大大提高搜索速度。
    • Payload的存储方式如下图:

    • Payload主要有以下几种用法:
      • 存储每个文档都有的信息:比如有的时候,我们想给每个文档赋一个我们自己的文档号,而不是用Lucene自己的文档号。于是我们可以声明一个特殊的域(Field)”_ID”和特殊的词(Term)”_ID”,使得每篇文档都包含词”_ID”,于是在词”_ID”的倒排表里面对于每篇文档又有一项,每一项都有一个payload,于是我们可以在payload里面保存我们自己的文档号。每当我们得到一个Lucene的文档号的时候,就能从跳跃表中查找到我们自己的文档号。

//声明一个特殊的域和特殊的词

public static final String ID_PAYLOAD_FIELD = “_ID”;

public static final String ID_PAYLOAD_TERM = “_ID”;

public static final Term ID_TERM = new Term(ID_PAYLOAD_TERM, ID_PAYLOAD_FIELD);

//声明一个特殊的TokenStream,它只生成一个词(Term),就是那个特殊的词,在特殊的域里面。

static class SinglePayloadTokenStream extends TokenStream {
private Token token;
private boolean returnToken = false;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
SinglePayloadTokenStream(String idPayloadTerm) {  
char[] term = idPayloadTerm.toCharArray();
token = new Token(term, 0, term.length, 0, term.length);
}

void setPayloadValue(byte[] value) {
token.setPayload(new Payload(value));
returnToken = true;
}

public Token next() throws IOException {
if (returnToken) {
returnToken = false;
return token;
} else {
return null;
}
}

}

//对于每一篇文档,都让它包含这个特殊的词,在特殊的域里面

SinglePayloadTokenStream singlePayloadTokenStream = new SinglePayloadTokenStream(ID_PAYLOAD_TERM);
singlePayloadTokenStream.setPayloadValue(long2bytes(id));
doc.add(new Field(ID_PAYLOAD_FIELD, singlePayloadTokenStream));

//每当得到一个Lucene的文档号时,通过以下的方式得到payload里面的文档号

long id = 0;
TermPositions tp = reader.termPositions(ID_PAYLOAD_TERM);
boolean ret = tp.skipTo(docID);
tp.nextPosition();
int payloadlength = tp.getPayloadLength();
byte[] payloadBuffer = new byte[payloadlength];
tp.getPayload(payloadBuffer, 0);
id = bytes2long(payloadBuffer);
tp.close();

      • 影响词的评分
        • 在Similarity抽象类中有函数public float scorePayload(byte [] payload, int offset, int length) 可以根据payload的值影响评分。
  • 读取域元数据信息的代码如下:

FieldInfos.read(IndexInput, String)

  • int firstInt = input.readVInt();
  • size = input.readVInt();
  • for (int i = 0; i < size; i++)
    • String name = input.readString();
    • byte bits = input.readByte();
    • boolean isIndexed = (bits & IS_INDEXED) != 0;
    • boolean storeTermVector = (bits & STORE_TERMVECTOR) != 0;
    • boolean storePositionsWithTermVector = (bits & STORE_POSITIONS_WITH_TERMVECTOR) != 0;
    • boolean storeOffsetWithTermVector = (bits & STORE_OFFSET_WITH_TERMVECTOR) != 0;
    • boolean omitNorms = (bits & OMIT_NORMS) != 0;
    • boolean storePayloads = (bits & STORE_PAYLOADS) != 0;
    • boolean omitTermFreqAndPositions = (bits & OMIT_TERM_FREQ_AND_POSITIONS) != 0;

4.1.3. 域(Field)的数据信息(.fdt,.fdx)

  • 域数据文件(fdt):
    • 真正保存存储域(stored field)信息的是fdt文件
    • 在一个段(segment)中总共有segment size篇文档,所以fdt文件中共有segment size个项,每一项保存一篇文档的域的信息
    • 对于每一篇文档,一开始是一个fieldcount,也即此文档包含的域的数目,接下来是fieldcount个项,每一项保存一个域的信息。
    • 对于每一个域,fieldnum是域号,接着是一个8位的byte,最低一位表示此域是否分词(tokenized),倒数第二位表示此域是保存字符串数据还是二进制数据,倒数第三位表示此域是否被压缩,再接下来就是存储域的值,比如new Field(“title”, “lucene in action”, Field.Store.Yes, …),则此处存放的就是”lucene in action”这个字符串。
  • 域索引文件(fdx)
    • 由域数据文件格式我们知道,每篇文档包含的域的个数,每个存储域的值都是不一样的,因而域数据文件中segment size篇文档,每篇文档占用的大小也是不一样的,那么如何在fdt中辨别每一篇文档的起始地址和终止地址呢,如何能够更快的找到第n篇文档的存储域的信息呢?就是要借助域索引文件。
    • 域索引文件也总共有segment size个项,每篇文档都有一个项,每一项都是一个long,大小固定,每一项都是对应的文档在fdt文件中的起始地址的偏移量,这样如果我们想找到第n篇文档的存储域的信息,只要在fdx中找到第n项,然后按照取出的long作为偏移量,就可以在fdt文件中找到对应的存储域的信息。
  • 读取域数据信息的代码如下:

Document FieldsReader.doc(int n, FieldSelector fieldSelector)

  • long position = indexStream.readLong();//indexStream points to “.fdx”
  • fieldsStream.seek(position);//fieldsStream points to “fdt”
  • int numFields = fieldsStream.readVInt();
  • for (int i = 0; i < numFields; i++)
    • int fieldNumber = fieldsStream.readVInt();
    • byte bits = fieldsStream.readByte();
    • boolean compressed = (bits & FieldsWriter.FIELD_IS_COMPRESSED) != 0;
    • boolean tokenize = (bits & FieldsWriter.FIELD_IS_TOKENIZED) != 0;
    • boolean binary = (bits & FieldsWriter.FIELD_IS_BINARY) != 0;
    • if (binary)
      • int toRead = fieldsStream.readVInt();
      • final byte[] b = new byte[toRead];
      • fieldsStream.readBytes(b, 0, b.length);
      • if (compressed)
        • int toRead = fieldsStream.readVInt();
        • final byte[] b = new byte[toRead];
        • fieldsStream.readBytes(b, 0, b.length);
        • uncompress(b),
    • else
      • fieldsStream.readString()

4.1.3. 词向量(Term Vector)的数据信息(.tvx,.tvd,.tvf)

词向量信息是从索引(index)到文档(document)到域(field)到词(term)的正向信息,有了词向量信息,我们就可以得到一篇文档包含那些词的信息。

  • 词向量索引文件(tvx)
    • 一个段(segment)包含N篇文档,此文件就有N项,每一项代表一篇文档。
    • 每一项包含两部分信息:第一部分是词向量文档文件(tvd)中此文档的偏移量,第二部分是词向量域文件(tvf)中此文档的第一个域的偏移量。
  • 词向量文档文件(tvd)
    • 一个段(segment)包含N篇文档,此文件就有N项,每一项包含了此文档的所有的域的信息。
    • 每一项首先是此文档包含的域的个数NumFields,然后是一个NumFields大小的数组,数组的每一项是域号。然后是一个(NumFields - 1)大小的数组,由前面我们知道,每篇文档的第一个域在tvf中的偏移量在tvx文件中保存,而其他(NumFields - 1)个域在tvf中的偏移量就是第一个域的偏移量加上这(NumFields - 1)个数组的每一项的值。
  • 词向量域文件(tvf)
    • 此文件包含了此段中的所有的域,并不对文档做区分,到底第几个域到第几个域是属于那篇文档,是由tvx中的第一个域的偏移量以及tvd中的(NumFields - 1)个域的偏移量来决定的。
    • 对于每一个域,首先是此域包含的词的个数NumTerms,然后是一个8位的byte,最后一位是指定是否保存位置信息,倒数第二位是指定是否保存偏移量信息。然后是NumTerms个项的数组,每一项代表一个词(Term),对于每一个词,由词的文本TermText,词频TermFreq(也即此词在此文档中出现的次数),词的位置信息,词的偏移量信息。
  • 读取词向量数据信息的代码如下:

TermVectorsReader.get(int docNum, String field, TermVectorMapper)

  • int fieldNumber = fieldInfos.fieldNumber(field);//通过field名字得到field号
  • seekTvx(docNum);//在tvx文件中按docNum文档号找到相应文档的项
  • long tvdPosition = tvx.readLong();//找到tvd文件中相应文档的偏移量
  • tvd.seek(tvdPosition);//在tvd文件中按偏移量找到相应文档的项
  • int fieldCount = tvd.readVInt();//此文档包含的域的个数。
  • for (int i = 0; i < fieldCount; i++) //按域号查找域
    • number = tvd.readVInt();
    • if (number == fieldNumber)
      • found = i;
  • position = tvx.readLong();//在tvx中读出此文档的第一个域在tvf中的偏移量
  • for (int i = 1; i <= found; i++)
    • position += tvd.readVLong();//加上所要找的域在tvf中的偏移量
  • tvf.seek(position);
  • int numTerms = tvf.readVInt();
  • byte bits = tvf.readByte();
  • storePositions = (bits & STORE_POSITIONS_WITH_TERMVECTOR) != 0;
  • storeOffsets = (bits & STORE_OFFSET_WITH_TERMVECTOR) != 0;
  • for (int i = 0; i < numTerms; i++)
    • start = tvf.readVInt();
    • deltaLength = tvf.readVInt();
    • totalLength = start + deltaLength;
    • tvf.readBytes(byteBuffer, start, deltaLength);
    • term = new String(byteBuffer, 0, totalLength, “UTF-8”);
    • if (storePositions)
      • positions = new int[freq];
      • int prevPosition = 0;
      • for (int j = 0; j < freq; j++)
        • positions[j] = prevPosition + tvf.readVInt();
        • prevPosition = positions[j];
    • if (storeOffsets)
      • offsets = new TermVectorOffsetInfo[freq];
      • int prevOffset = 0;
      • for (int j = 0; j < freq; j++)
      • int startOffset = prevOffset + tvf.readVInt();
      • int endOffset = startOffset + tvf.readVInt();
      • offsets[j] = new TermVectorOffsetInfo(startOffset, endOffset);
      • prevOffset = endOffset;

4.2. 反向信息

反向信息是索引文件的核心,也即反向索引。

反向索引包括两部分,左面是词典(Term Dictionary),右面是倒排表(Posting List)。

在Lucene中,这两部分是分文件存储的,词典是存储在tii,tis中的,倒排表又包括两部分,一部分是文档号及词频,保存在frq中,一部分是词的位置信息,保存在prx中。

  • Term Dictionary (tii, tis)
    • –> Frequencies (.frq)
    • –> Positions (.prx)

4.2.1. 词典(tis)及词典索引(tii)信息

在词典中,所有的词是按照字典顺序排序的。

  • 词典文件(tis)
    • TermCount:词典中包含的总的词数
    • IndexInterval:为了加快对词的查找速度,也应用类似跳跃表的结构,假设IndexInterval为4,则在词典索引(tii)文件中保存第4个,第8个,第12个词,这样可以加快在词典文件中查找词的速度。
    • SkipInterval:倒排表无论是文档号及词频,还是位置信息,都是以跳跃表的结构存在的,SkipInterval是跳跃的步数。
    • MaxSkipLevels:跳跃表是多层的,这个值指的是跳跃表的最大层数。
    • TermCount个项的数组,每一项代表一个词,对于每一个词,以前缀后缀规则存放词的文本信息(PrefixLength + Suffix),词属于的域的域号(FieldNum),有多少篇文档包含此词(DocFreq),此词的倒排表在frq,prx中的偏移量(FreqDelta, ProxDelta),此词的倒排表的跳跃表在frq中的偏移量(SkipDelta),这里之所以用Delta,是应用差值规则。
  • 词典索引文件(tii)
    • 词典索引文件是为了加快对词典文件中词的查找速度,保存每隔IndexInterval个词。
    • 词典索引文件是会被全部加载到内存中去的。
    • IndexTermCount = TermCount / IndexInterval:词典索引文件中包含的词数。
    • IndexInterval同词典文件中的IndexInterval。
    • SkipInterval同词典文件中的SkipInterval。
    • MaxSkipLevels同词典文件中的MaxSkipLevels。
    • IndexTermCount个项的数组,每一项代表一个词,每一项包括两部分,第一部分是词本身(TermInfo),第二部分是在词典文件中的偏移量(IndexDelta)。假设IndexInterval为4,此数组中保存第4个,第8个,第12个词。。。
  • 读取词典及词典索引文件的代码如下:

origEnum = new SegmentTermEnum(directory.openInput(segment + “.” + IndexFileNames.TERMS_EXTENSION,readBufferSize), fieldInfos, false);//用于读取tis文件

  • int firstInt = input.readInt();
  • size = input.readLong();
  • indexInterval = input.readInt();
  • skipInterval = input.readInt();
  • maxSkipLevels = input.readInt();

SegmentTermEnum indexEnum = new SegmentTermEnum(directory.openInput(segment + “.” + IndexFileNames.TERMS_INDEX_EXTENSION, readBufferSize), fieldInfos, true);//用于读取tii文件

  • indexTerms = new Term[indexSize];
  • indexInfos = new TermInfo[indexSize];
  • indexPointers = new long[indexSize];
  • for (int i = 0; indexEnum.next(); i++)
    • indexTerms[i] = indexEnum.term();
    • indexInfos[i] = indexEnum.termInfo();
    • indexPointers[i] = indexEnum.indexPointer;

4.2.2. 文档号及词频(frq)信息

文档号及词频文件里面保存的是倒排表,是以跳跃表形式存在的。

  • 此文件包含TermCount个项,每一个词都有一项,因为每一个词都有自己的倒排表。
  • 对于每一个词的倒排表都包括两部分,一部分是倒排表本身,也即一个数组的文档号及词频,另一部分是跳跃表,为了更快的访问和定位倒排表中文档号及词频的位置。
  • 对于文档号和词频的存储应用的是差值规则和或然跟随规则,Lucene的文档本身有以下几句话,比较难以理解,在此解释一下:

For example, the TermFreqs for a term which occurs once in document seven and three times in document eleven, with omitTf false, would be the following sequence of VInts:

15, 8, 3

If omitTf were true it would be this sequence of VInts instead:

7,4

首先我们看omitTf=false的情况,也即我们在索引中会存储一个文档中term出现的次数。

例子中说了,表示在文档7中出现1次,并且又在文档11中出现3次的文档用以下序列表示:15,8,3.

那这三个数字是怎么计算出来的呢?

首先,根据定义TermFreq –> DocDelta[, Freq?],一个TermFreq结构是由一个DocDelta后面或许跟着Freq组成,也即上面我们说的A+B?结构。

DocDelta自然是想存储包含此Term的文档的ID号了,Freq是在此文档中出现的次数。

所以根据例子,应该存储的完整信息为[DocID = 7, Freq = 1] [DocID = 11, Freq = 3](见全文检索的基本原理章节)。

然而为了节省空间,Lucene对编号此类的数据都是用差值来表示的,也即上面说的规则2,Delta规则,于是文档ID就不能按完整信息存了,就应该存放如下:

[DocIDDelta = 7, Freq = 1][DocIDDelta = 4 (11-7), Freq = 3]

然而Lucene对于A+B?这种或然跟随的结果,有其特殊的存储方式,见规则3,即A+B?规则,如果DocDelta后面跟随的Freq为1,则用DocDelta最后一位置1表示。

如果DocDelta后面跟随的Freq大于1,则DocDelta得最后一位置0,然后后面跟随真正的值,从而对于第一个Term,由于Freq为1,于是放在DocDelta的最后一位表示,DocIDDelta = 7的二进制是000 0111,必须要左移一位,且最后一位置一,000 1111 = 15,对于第二个Term,由于Freq大于一,于是放在DocDelta的最后一位置零,DocIDDelta = 4的二进制是0000 0100,必须要左移一位,且最后一位置零,0000 1000 = 8,然后后面跟随真正的Freq = 3。

于是得到序列:[DocDleta = 15][DocDelta = 8, Freq = 3],也即序列,15,8,3。

如果omitTf=true,也即我们不在索引中存储一个文档中Term出现的次数,则只存DocID就可以了,因而不存在A+B?规则的应用。

[DocID = 7][DocID = 11],然后应用规则2,Delta规则,于是得到序列[DocDelta = 7][DocDelta = 4 (11 - 7)],也即序列,7,4.

  • 对于跳跃表的存储有以下几点需要解释一下:
    • 跳跃表可根据倒排表本身的长度(DocFreq)和跳跃的幅度(SkipInterval)而分不同的层次,层次数为NumSkipLevels = Min(MaxSkipLevels, floor(log(DocFreq/log(SkipInterval)))).
    • 第Level层的节点数为DocFreq/(SkipInterval^(Level + 1)),level从零计数。
    • 除了最低层之外,其他层都有SkipLevelLength来表示此层的二进制长度(而非节点的个数),方便读取某一层的跳跃表到缓存里面。
    • 高层在前,低层在后,当读完所有的高层后,剩下的就是最低一层,因而最后一层不需要SkipLevelLength。这也是为什么Lucene文档中的格式描述为 NumSkipLevels-1, SkipLevel,也即低NumSKipLevels-1层有SkipLevelLength,最后一层只有SkipLevel,没有SkipLevelLength。
    • 除最低层以外,其他层都有SkipChildLevelPointer来指向下一层相应的节点。
    • 每一个跳跃节点包含以下信息:文档号,payload的长度,文档号对应的倒排表中的节点在frq中的偏移量,文档号对应的倒排表中的节点在prx中的偏移量。
    • 虽然Lucene的文档中有以下的描述,然而实验的结果却不是完全准确的:

Example: SkipInterval = 4, MaxSkipLevels = 2, DocFreq = 35. Then skip level 0 has 8 SkipData entries, containing the 3rd, 7th, 11th, 15th, 19th, 23rd, 27th, and 31st document numbers in TermFreqs. Skip level 1 has 2 SkipData entries, containing the 15th and 31st document numbers in TermFreqs.

按照描述,当SkipInterval为4,且有35篇文档的时候,Skip level = 0应该包括第3,第7,第11,第15,第19,第23,第27,第31篇文档,Skip level = 1应该包括第15,第31篇文档。

然而真正的实现中,跳跃表节点的时候,却向前偏移了,偏移的原因在于下面的代码:

  • FormatPostingsDocsWriter.addDoc(int docID, int termDocFreq)
    • final int delta = docID - lastDocID;
    • if ((++df % skipInterval) == 0)
      • skipListWriter.setSkipData(lastDocID, storePayloads, posWriter.lastPayloadLength);
      • skipListWriter.bufferSkip(df);

从代码中,我们可以看出,当SkipInterval为4的时候,当docID = 0时,++df为1,1%4不为0,不是跳跃节点,当docID = 3时,++df=4,4%4为0,为跳跃节点,然而skipData里面保存的却是lastDocID为2。

所以真正的倒排表和跳跃表中保存一下的信息:

4.2.3. 词位置(prx)信息

词位置信息也是倒排表,也是以跳跃表形式存在的。

  • 此文件包含TermCount个项,每一个词都有一项,因为每一个词都有自己的词位置倒排表。
  • 对于每一个词的都有一个DocFreq大小的数组,每项代表一篇文档,记录此文档中此词出现的位置。这个文档数组也是和frq文件中的跳跃表有关系的,从上面我们知道,在frq的跳跃表节点中有ProxSkip,当SkipInterval为3的时候,frq的跳跃表节点指向prx文件中的此数组中的第1,第4,第7,第10,第13,第16篇文档。
  • 对于每一篇文档,可能包含一个词多次,因而有一个Freq大小的数组,每一项代表此词在此文档中出现一次,则有一个位置信息。
  • 每一个位置信息包含:PositionDelta(采用差值规则),还可以保存payload,应用或然跟随规则。

4.3. 其他信息

4.3.1. 标准化因子文件(nrm)

为什么会有标准化因子呢?从第一章中的描述,我们知道,在搜索过程中,搜索出的文档要按与查询语句的相关性排序,相关性大的打分(score)高,从而排在前面。相关性打分(score)使用向量空间模型(Vector Space Model),在计算相关性之前,要计算Term Weight,也即某Term相对于某Document的重要性。在计算Term Weight时,主要有两个影响因素,一个是此Term在此文档中出现的次数,一个是此Term的普通程度。显然此Term在此文档中出现的次数越多,此Term在此文档中越重要。

这种Term Weight的计算方法是最普通的,然而存在以下几个问题:

  • 不同的文档重要性不同。有的文档重要些,有的文档相对不重要,比如对于做软件的,在索引书籍的时候,我想让计算机方面的书更容易搜到,而文学方面的书籍搜索时排名靠后。
  • 不同的域重要性不同。有的域重要一些,如关键字,如标题,有的域不重要一些,如附件等。同样一个词(Term),出现在关键字中应该比出现在附件中打分要高。
  • 根据词(Term)在文档中出现的绝对次数来决定此词对文档的重要性,有不合理的地方。比如长的文档词在文档中出现的次数相对较多,这样短的文档比较吃亏。比如一个词在一本砖头书中出现了10次,在另外一篇不足100字的文章中出现了9次,就说明砖头书应该排在前面码?不应该,显然此词在不足100字的文章中能出现9次,可见其对此文章的重要性。

由于以上原因,Lucene在计算Term Weight时,都会乘上一个标准化因子(Normalization Factor),来减少上面三个问题的影响。

标准化因子(Normalization Factor)是会影响随后打分(score)的计算的,Lucene的打分计算一部分发生在索引过程中,一般是与查询语句无关的参数如标准化因子,大部分发生在搜索过程中,会在搜索过程的代码分析中详述。

标准化因子(Normalization Factor)在索引过程总的计算如下:

它包括三个参数:

  • Document boost:此值越大,说明此文档越重要。
  • Field boost:此域越大,说明此域越重要。
  • lengthNorm(field) = (1.0 / Math.sqrt(numTerms)):一个域中包含的Term总数越多,也即文档越长,此值越小,文档越短,此值越大。

从上面的公式,我们知道,一个词(Term)出现在不同的文档或不同的域中,标准化因子不同。比如有两个文档,每个文档有两个域,如果不考虑文档长度,就有四种排列组合,在重要文档的重要域中,在重要文档的非重要域中,在非重要文档的重要域中,在非重要文档的非重要域中,四种组合,每种有不同的标准化因子。

于是在Lucene中,标准化因子共保存了(文档数目乘以域数目)个,格式如下:

  • 标准化因子文件(Normalization Factor File: nrm):
    • NormsHeader:字符串“NRM”外加Version,依Lucene的版本的不同而不同。
    • 接着是一个数组,大小为NumFields,每个Field一项,每一项为一个Norms。
    • Norms也是一个数组,大小为SegSize,即此段中文档的数量,每一项为一个Byte,表示一个浮点数,其中02为尾数,38为指数。

4.3.2. 删除文档文件(del)

  • 被删除文档文件(Deleted Document File: .del)
    • Format:在此文件中,Bits和DGaps只能保存其中之一,-1表示保存DGaps,非负值表示保存Bits。
    • ByteCount:此段中有多少文档,就有多少个bit被保存,但是以byte形式计数,也即Bits的大小应该是byte的倍数。
    • BitCount:Bits中有多少位被至1,表示此文档已经被删除。
    • Bits:一个数组的byte,大小为ByteCount,应用时被认为是byte*8个bit。
    • DGaps:如果删除的文档数量很小,则Bits大部分位为0,很浪费空间。DGaps采用以下的方式来保存稀疏数组:比如第十,十二,三十二个文档被删除,于是第十,十二,三十二位设为1,DGaps也是以byte为单位的,仅保存不为0的byte,如第1个byte,第4个byte,第1个byte十进制为20,第4个byte十进制为1。于是保存成DGaps,第1个byte,位置1用不定长正整数保存,值为20用二进制保存,第2个byte,位置4用不定长正整数保存,用差值为3,值为1用二进制保存,二进制数据不用差值表示。

五、总体结构

  • 图示为Lucene索引文件的整体结构:
    • 属于整个索引(Index)的segment.gen,segment_N,其保存的是段(segment)的元数据信息,然后分多个segment保存数据信息,同一个segment有相同的前缀文件名。
    • 对于每一个段,包含域信息,词信息,以及其他信息(标准化因子,删除文档)
    • 域信息也包括域的元数据信息,在fnm中,域的数据信息,在fdx,fdt中。
    • 词信息是反向信息,包括词典(tis, tii),文档号及词频倒排表(frq),词位置倒排表(prx)。

大家可以通过看源代码,相应的Reader和Writer来了解文件结构,将更为透彻。

参考资料

Lucene 概述

2023年4月25日 20:54

Lucene是apache软件基金会4 jakarta项目组的一个子项目,是一个开放源代码的全文检索引擎工具包,但它不是一个完整的全文检索引擎,而是一个全文检索引擎的架构,提供了完整的查询引擎和索引引擎,部分文本分析引擎(英文与德文两种西方语言)。Lucene的目的是为软件开发人员提供一个简单易用的工具包,以方便的在目标系统中实现全文检索的功能,或者是以此为基础建立起完整的全文检索引擎

全文检索概述

比如,我们一个文件夹中,或者一个磁盘中有很多的文件,记事本、world、Excel、pdf,我们想根据其中的关键词搜索包含的文件。例如,我们输入Lucene,所有内容含有Lucene的文件就会被检查出来。这就是所谓的全文检索。

因此,很容易的我们想到,应该建立一个关键字与文件的相关映射,盗用ppt中的一张图,很明白的解释了这种映射如何实现。

倒排索引

有了这种映射关系,我们就来看看Lucene的架构设计。 下面是Lucene的资料必出现的一张图,但也是其精髓的概括。

可以看到,Lucene的使用主要体现在两个步骤:

1 创建索引,通过IndexWriter对不同的文件进行索引的创建,并将其保存在索引相关文件存储的位置中。

2 通过索引查寻关键字相关文档。

在Lucene中,就是使用这种“倒排索引”的技术,来实现相关映射。

Lucene数学模型

文档、域、词元

文档是Lucene搜索和索引的原子单位,文档为包含一个或者多个域的容器,而域则是依次包含“真正的”被搜索的内容,域值通过分词技术处理,得到多个词元。

For Example,一篇小说(斗破苍穹)信息可以称为一个文档,小说信息又包含多个域,例如:标题(斗破苍穹)、作者、简介、最后更新时间等等,对标题这个域采用分词技术又可以得到一个或者多个词元(斗、破、苍、穹)。

Lucene文件结构

层次结构

index 一个索引存放在一个目录中

segment 一个索引中可以有多个段,段与段之间是独立的,添加新的文档可能产生新段,不同的段可以合并成一个新段

document 文档是创建索引的基本单位,不同的文档保存在不同的段中,一个段可以包含多个文档

field 域,一个文档包含不同类型的信息,可以拆分开索引

term 词,索引的最小单位,是经过词法分析和语言处理后的数据。

正向信息

按照层次依次保存了从索引到词的包含关系:index–>segment–>document–>field–>term。

反向信息

反向信息保存了词典的倒排表映射:term–>document

IndexWriter lucene中最重要的的类之一,它主要是用来将文档加入索引,同时控制索引过程中的一些参数使用。

Analyzer 分析器,主要用于分析搜索引擎遇到的各种文本。常用的有StandardAnalyzer分析器,StopAnalyzer分析器,WhitespaceAnalyzer分析器等。

Directory 索引存放的位置;lucene提供了两种索引存放的位置,一种是磁盘,一种是内存。一般情况将索引放在磁盘上;相应地lucene提供了FSDirectory和RAMDirectory两个类。

Document 文档;Document相当于一个要进行索引的单元,任何可以想要被索引的文件都必须转化为Document对象才能进行索引。

Field 字段。

IndexSearcher 是lucene中最基本的检索工具,所有的检索都会用到IndexSearcher工具;

Query 查询,lucene中支持模糊查询,语义查询,短语查询,组合查询等等,如有TermQuery,BooleanQuery,RangeQuery,WildcardQuery等一些类。

QueryParser 是一个解析用户输入的工具,可以通过扫描用户输入的字符串,生成Query对象。

Hits 在搜索完成之后,需要把搜索结果返回并显示给用户,只有这样才算是完成搜索的目的。在lucene中,搜索的结果的集合是用Hits类的实例来表示的。

理解BNF

2023年3月27日 21:21

BNF范式

下面来自百度百科:

巴科斯范式(BNF)所描述的语法是与上下文无关的。它具有语法简单,表示明确,便于语法分析和编译的特点。

例:四则运算的BNF,<>表示非叶子节点,|表示或者关系,:=就是等于的意思。

<expr> ::= <expr> + <term>         | <expr> - <term>         | <term> <term> ::= <term> * <factor>         | <term> / <factor>         | <factor> <factor> ::= ( <expr> )           | Num

可见,BNF本质上就是树形分解,分解成一棵名为AST的抽象语法树。


产生式

BNF中的表达式叫做产生式。产生式就是将语法的分解规则表达出来的等式。如

句子 = 主 + 谓 + 宾

将语法规则用产生式描述出来是为了便于计算,产生式可以看作是对语法的数学建模。

产生式的特点是 不断向下分解。这种特点和数据结构中的树是一样的。
通过类比,有:

  • 每个产生式就是一个子树,在写编译器时,每个子树对应一个解析函数。
  • 叶子节点叫做 终结符,非叶子节点叫做 非终结符

递归

产生式分成递归的和非递归的,如果一个产生式用自己来表示自己就会产生递归。
递归地调用自己的定义来定义自己,是无穷尽的。
比如:

  • GNU项目的命名GUN NOT UNIX;
  • A=A’b’(‘b’表示字符常量b)。


明明代码文本都是有限长的,为什么还要引入递归这种模型来解析,搞得很复杂?


实际的代码文本确实是有限长的。
但是,递归产生式只是定义了一个计算公式,通过这个计算公式,可以生成无穷种字符串。例如,四则运算可以用递归产生式来描述,它表示你可以写出无数个任意长的四则运算表达式,它是为了用一个公式来描述所有情况,是对实际规则的抽象与归纳。实际中,我们总不可能对每一种四则运算表达式都写一段解析代码,相反地,是所有四则运算表达式的解析都只用相同的解析程序。

递归又分为左递归和右递归,下面分别用图形的方式直观地进行描述。


左递归

左递归图解
如上图所示,产生式一直朝左侧延伸,无法结束,永远不会结束,所以叫左递归。
显然左递归无法分解出有限的叶子节点。尤其是,它永远无法得到第一个叶子节点。因为左侧在无限递归产生新的左叶子节点。 这个结论很重要,要牢记


右递归

右递归图解
与左递归相反,右递归有第一个叶子节点,没有最后一个叶子节点。


如何判断产生式有递归?

示意图如下:
产生式递归 子节点和父节点相同叫直接左递归,子节点和非父节点的祖先相同叫间接左递归


递归为什么可以消除?

因为某些左右递归可以相互转化,注意不是所有左右递归的都可以相互转化。具体是哪些呢,下面会说到,是包含终结符分支的递归表达式,终结符是转化的桥梁。


为什么只需要消除左递归?

我们需要明确第一个叶子节点的重要含义:第一个节点是语法的起点,所以第一个节点很重要,如果没有第一个叶子节点,那么就永远无法判断此语法是从哪个字符开始的。所以存在左递归的文法,是无法通过程序解析的,这样的程序无法实现

相反地,右递归有第一个叶子节点,没有最后一个叶子节点。有第一个叶子节点就可以判断语法从哪个字符开始,但是不知道语法在何时结束。

但是,在实际解析中,因为被解析的文本是有限长的,所以右递归一定会停止。除此之外,因为右递归一定有起始符号,所以在解析文本时,一旦遇到非起始符的字符串,也会停止解析。也就是说,右递归能自动保证语法的正确性,而且不会无穷递归。

综上,只有左递归需要消除。


如何消除左递归

目前我们连产生式都不会写,怎么消除?所以接下来先把消除左递归的问题放一放,先弄清楚怎么编写产生式。


怎么编写产生式

要明确一个重要规则:优先级高的产生式必然是需要先被计算的。 所以优先级越高(如乘除运算)的产生式,在BNF树中越靠近叶子节点。优先级越低(如加减运算)的产生式,在BNF树中越靠近根节点。所以优先级低的产生式一定会被分解成优先级高的产生式。

另外,产生式中不可分解的元素一定是在叶子节点,叶子节点没有子节点,无法分解。

综上:

  • BNF算法工作过程是,先向下分解到叶子节点,再从叶子节点沿着分解时的路径/调用堆栈向上反向计算。
  • 产生式优先级和在BNF树中的深度成正比。
  • 叶子节点有两种,一种是不可分解的元素,一种是优先级最高的元素。

以四则运算为例,双括号()的优先级最高,乘除运算*/的优先级其次,加减运算的优先级最低。从下面的四则运算的BNF可以看出,一个表达式是先分解成±运算,然后分解成乘除运算 */,最后再分解为Expr,这表明我们的理解是正确的。

<expr> ::= <expr> + <term>         | <expr> - <term>         | <term> 
<term> ::= <term> * <factor>         | <term> / <factor>         | <factor> 
<factor> ::= ( <expr> )           | Num

根据以上思想,下面具体描述产生式的编写过程。

四则运算表达式编写

问:

已知Expr支持形如5+3 * (2+1)的运算表达式,即支持运算优先级,乘除高于加减,支持括号括起来的子表达式。求产生式。

答:

  • 5为Num,3为Num,(2 + 1)为含括号的子表达式(Expr)
  • Num、(Expr)优先级最高,所以做叶子节点,把他们统称为Factor,即
    Factor=Num | (Expr)
  • 次高优先级的运算为乘除运算*/,表达式称为Term,基本运算元素为上一步的Factor,有
    • 当乘除运算符个数为0时,Term = Factor
    • 当乘除运算符个数为1时,Term = Factor * Factor | Factor / Factor,将上式Term = Factor带入得 Term = Term * Factor | Term / Factor(左递归)。
    • 当乘除运算符个数大于1时,
      Term = Factor * Factor / Factor …* Factor = (Factor * Factor / Factor…) * Factor =Term * Factor

      Term = Factor * Factor / Factor…/ Factor = (Factor * Factor / Factor…) / Factor = Term / Factor。
      于是运算符个数 >= 1时,具有相同产生式。综合得出:
      Term = Term * Factor | Term / Factor | Factor
  • 最低优先级的运算为加减运算±,表达式称为Expr,基本运算元素为上一步的Term,有
    • 当加减运算符的个数为0时,Expr = Term
    • 当加减运算符的个数为1时,Expr = Term + Term | Term - Term,把上式代入得,
      Expr = Expr + Term | Expr - Term
    • 当加减运算符的个数大于1时,Expr = Term + Term - Term…+ Term = (Term + Term - Term…) + Term = Expr + Term,或 Expr = Term + Term - Term…- Term = (Term + Term - Term…) - Term = Expr - Term
      于是运算符个数 >= 1时,具有相同产生式。综合得出:
      Expr = Expr + Term | Expr - Term | Term

消除左递归

上面我们已经推导出四则运算的产生式了,但是产生式中存在之前说的左递归。具有左递归的产生式是无法用来解析代码的,所以需要消除左递归。
如何消除左递归呢?前面说过,用非递归的部分来表示左递归的部分就可以了。如果没有非递归部分,就无法消除左递归。就相当于一条路走不通,绕个弯子走另一条路。如果没有一条路可以走,就是真的无路可走。

消除直接左递归(省略号…表示无数次重复):

现有直接左递归:A = Aa | b,
即A=A…a ,右边的A用b替换掉,等价于: A = ba…a = ba…
消除了左递归,即消除A =…a,只剩下A = ba…。
此时A的产生式为:A = bAtail,Atail是除了开始符号b剩下的部分。
那剩下的部分是什么呢?Atail = a…(右递归),即Atail = aAtail。右递归是可以处理的。
综上直接左递归消除方法是:
A = Aa | b =>消除左递归 => A = bA’, A’=aA’

由此可见,终结符是转化的桥梁。

消除间接左递归:

思路就是使用变量代换将间接左递归写成直接左递归,然后消除直接左递归。、

解决ctrl+左右方向键失效

2022年9月25日 21:57

问题: 在终端按下 ctrl + 左/右 方向键时,出现以下字符

# ctrl+ 方向键的结果
;5D
;5C

解决: 新建 ~/.inputrc 文件,新增以下内容

# mappings for Ctrl-left-arrow and Ctrl-right-arrow for word moving
"\e[1;5C": forward-word
"\e[1;5D": backward-word
"\e[5C": forward-word
"\e[5D": backward-word
"\e\e[C": forward-word
"\e\e[D": backward-word

参考资料 https://www.linuxfromscratch.org/lfs/view/11.3/chapter09/inputrc.html

【译】10大静态网站生成工具

2022年10月3日 02:26

在寻找部署静态网页的方法吗?这几个开源的静态网站生成工具可以帮你迅速部署界面优美、功能强大的静态网站,无需掌握复杂的 HTML 和 CSS 技能。

静态网站是什么?

技术上来讲,静态网站是指网页不是由服务器动态生成的。HTML、CSS 和 JavaScript 文件就静静地躺在服务器的某个路径下,它们的内容与终端用户接收到的版本是一样的。原始的源码文件已经提前编译好了,源码在每次请求后都不会变化。

Linux.CN 是一个依赖多个数据库的动态网站,当有浏览器的请求时,网页就会生成并提供服务。大部分网站是动态的,你与这些网站互动时,大量的内容会经常改变。

静态网站有一些好处,比如加载时间更短,请求的服务器资源更少、更安全(值得商榷)。

传统上,静态网站更适合于创建只有少量网页、内容变化不频繁的小网站。

然而,随着静态网站生成工具出现后,静态网站的适用范围越来越大。你还可以使用这些工具搭建博客网站。

我整理了几个开源的静态网站生成工具,这些工具可以帮你搭建界面优美的网站。

最好的开源静态网站生成工具

请注意,静态网站不会提供很复杂的功能。如果你需要复杂的功能,那么你可以参考适用于动态网站的最佳开源 CMS列表。

1、Jekyll

Jekyll 是用 Ruby 写的最受欢迎的开源静态生成工具之一。实际上,Jekyll 是 GitHub 页面 的引擎,它可以让你免费用 GitHub 托管网站。

你可以很轻松地跨平台配置 Jekyll,包括 Ubuntu。它利用 MarkdownLiquid(模板语言)、HTML 和 CSS 来生成静态的网页文件。如果你要搭建一个没有广告或推广自己工具或服务的产品页的博客网站,它是个不错的选择。

它还支持从常见的 CMS(内容管理系统Content management system)如 Ghost、WordPress、Drupal 7 迁移你的博客。你可以管理永久链接、类别、页面、文章,还可以自定义布局,这些功能都很强大。因此,即使你已经有了一个网站,如果你想转成静态网站,Jekyll 会是一个完美的解决方案。你可以参考官方文档GitHub 页面了解更多内容。

2、Hugo

Hugo 是另一个很受欢迎的用于搭建静态网站的开源框架。它是用 Go 语言写的。

它运行速度快、使用简单、可靠性高。如果你需要,它也可以提供更高级的主题。它还提供了一些有用的快捷方式来帮助你轻松完成任务。无论是组合展示网站还是博客网站,Hogo 都有能力管理大量的内容类型。

如果你想使用 Hugo,你可以参照它的官方文档或它的 GitHub 页面来安装以及了解更多相关的使用方法。如果需要的话,你还可以将 Hugo 部署在 GitHub 页面或任何 CDN 上。

3、Hexo

Hexo 是一个有趣的开源框架,基于 Node.js。像其他的工具一样,你可以用它搭建相当快速的网站,不仅如此,它还提供了丰富的主题和插件。

它还根据用户的每个需求提供了强大的 API 来扩展功能。如果你已经有一个网站,你可以用它的迁移扩展轻松完成迁移工作。

你可以参照官方文档GitHub 页面 来使用 Hexo。

4、Gatsby

Gatsby 是一个越来越流行的开源网站生成框架。它使用 React.js 来生成快速、界面优美的网站。

几年前在一个实验性的项目中,我曾经非常想尝试一下这个工具,它提供的成千上万的新插件和主题的能力让我印象深刻。与其他静态网站生成工具不同的是,你可以使用 Gatsby 生成一个网站,并在不损失任何功能的情况下获得静态网站的好处。

它提供了与很多流行的服务的整合功能。当然,你可以不使用它的复杂的功能,或将其与你选择的流行 CMS 配合使用,这也会很有趣。你可以查看他们的官方文档或它的 GitHub 页面了解更多内容。

5、VuePress

VuePress 是由 Vue.js 支持的静态网站生成工具,而 Vue.js 是一个开源的渐进式 JavaScript 框架。

如果你了解 HTML、CSS 和 JavaScript,那么你可以无压力地使用 VuePress。你应该可以找到几个有用的插件和主题来为你的网站建设开个头。此外,看起来 Vue.js 的更新一直很活跃,很多开发者都在关注 Vue.js,这是一件好事。

你可以参照他们的官方文档GitHub 页面了解更多。

6、Nuxt.js

Nuxt.js 使用了 Vue.js 和 Node.js,但它致力于模块化,并且有能力依赖服务端而非客户端。不仅如此,它的目标是为开发者提供直观的体验,并提供描述性错误,以及详细的文档等。

正如它声称的那样,在你用来搭建静态网站的所有工具中,Nuxt.js 可以做到功能和灵活性两全其美。他们还提供了一个 Nuxt 线上沙盒,让你不费吹灰之力就能直接测试它。

你可以查看它的 GitHub 页面官方网站了解更多。

7、Docusaurus

Docusaurus 是一个有趣的开源静态网站生成工具,为搭建文档类网站量身定制。它还是 Facebook 开源计划的一个项目。

Docusaurus 是用 React 构建的。你可以使用所有的基本功能,像文档版本管理、文档搜索和翻译大多是预先配置的。如果你想为你的产品或服务搭建一个文档网站,那么可以试试 Docusaurus。

你可以从它的 GitHub 页面和它的官网获取更多信息。

8、Eleventy

Eleventy 自称是 Jekyll 的替代品,旨在以更简单的方法来制作更快的静态网站。

它似乎很容易上手,而且它还提供了适当的文档来帮助你。如果你想找一个简单的静态网站生成工具,Eleventy 似乎会是一个有趣的选择。

你可以参照它的 GitHub 页面官网来了解更多的细节。

9、Publii

Publii 是一个令人印象深刻的开源 CMS,它能使生成一个静态网站变得很容易。它是用 Electron 和 Vue.js 构建的。如果有需要,你也可以把你的文章从 WorkPress 网站迁移过来。此外,它还提供了与 GitHub 页面、Netlify 及其它类似服务的一键同步功能。

如果你利用 Publii 生成一个静态网站,你还可以得到一个所见即所得的编辑器。你可以从官网下载它,或者从它的 GitHub 页面了解更多信息。

10、Primo

一个有趣的开源静态网站生成工具,目前开发工作仍很活跃。虽然与其他的静态生成工具相比,它还不是一个成熟的解决方案,有些功能还不完善,但它是一个独特的项目。

Primo 旨在使用可视化的构建器帮你构建和搭建网站,这样你就可以轻松编辑和部署到任意主机上。

你可以参照官网或查看它的 GitHub 页面了解更多信息。

结语

还有很多文章中没有列出的网站生成工具。然而,我试图提到最好的静态生成器,为您提供最快的加载时间,最好的安全性和令人印象深刻的灵活性。

列表中没有你最喜欢的工具?在下面的评论中告诉我。


via: https://itsfoss.com/open-source-static-site-generators/

作者:Ankush Das 选题:lujun9972 译者:Xiaobin.Liu 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

【译】How to (std::)find something efficiently with the STL

2017年3月17日 06:07

本文分3部分: 1. 怎么使用STL进行高效的查找: 借用传统STL算法对元素进行范围搜索 2. 搜索STL容器: 当你有直接读取STL容器里元素的权限时, 怎么进行高效准确的搜索(与简单的范围搜索相比较) 3. STL搜索算法的秘密: 向公众展示不为人知的算法, 这些算法在已经学习过的人眼里确实是很有用的

STL根据查看方式的不同, 一共分为两种: 排序的和不排序的. * 排序集合的遍历, 通常需要对数时长, 而乱序集合的遍历, 需要线性时长 * 排序容器中比较元素大小的函数根据equivalence(comparing with <), 而乱序容器中的函数根据equality(comparing with ==).

本文将展示对于在一个范围内搜索一个给定的值, C++怎么样去阐述下面3个问题: * 它存在否 * 它在哪 * 它应该在什么位置(排序容器)

Is it there?

乱序容器的元素

这个问题可以用std::find来表达(需要和与范围的终点值的比较相结合):

vector<int> v = ... // v filled with values
if (std::find(v.begin(), v.end(), 42) != v.end())
{
...

“Is it there"这个问题也可以用std::count来表达:

vector<int> v = ... // v filled with values
if (std::count(v.begin(), v.end(), 42))
{
...

std::count()的返回值会被隐式地转换成if条件里的bool值: 如果该范围里有至少一个值为42, 则返回true.

与std::find相比, std::count的优劣: 优势:

  • std::count避免了与范围的end值相比较

弊端:

  • std::count遍历整个集合, 而std::find在第一个与要查找的值相等的位置停下
  • 可以证明, 对于"想要查找某个值"这件事, std::find 表达得更明确 基于以上, std::find用得更多.

Note 若要确认某个值存在而非是与要搜索的值相等, 请使用std::count_if, std::find_if, std::find_if_not

排序容器的元素

使用的算法是std::binary_search, 此函数返回一个bool值, 此bool值表示在集合中是否存在与搜索的值相等的元素.

std::set<int> numbers = // sorted elements
bool is42InThere = std::binary_search(numbers.begin(), numbers.end(), 42);
```</int>

### Where is it?
(当确定了要搜索的值存在后,) 我们想更进一步, 得到指向那个元素的迭代器.

#### 乱序容器的元素

使用std::find. 返回指向第一个与搜索的值相等的元素的迭代器, 如果找不到, 则返回集合的终点.

std::vector numbers = … auto searchResult = std::find(numbers.begin(), numbers.end(), 42);

if (searchResult != numbers.end()) { …

#### 排序容器的元素

对于排序集合, STL并没有像std::find一样直接的算法. std::find并不是为排序容器设计的, 因为它依据的是"=="而不是"&lt;", 消耗的时间为线性时长而不是对数时长.
对于一个给定的容器, 如果容器内元素的"equality"和"equivalence"是相同的, 且你能接受消耗的线性时长, 那么std::find会为你返回正确的结果, 你也能从它简单直接的接口中获益. **但是,** 不能忘记, std::find并不是为排序容器设计的.

这里推荐使用`std::equal_range`. (并非`std::lower_bound`)
函数原型: 

template< class ForwardIt, class T > std::pair<forwardit,forwardit> equal_range( ForwardIt first, ForwardIt last, const T& value );

`std::equal_range` 返回与搜索值相等的元素的范围, 这个范围用一对集合内的迭代器表示. 这两个迭代器分别指向 与搜索值相等的范围里第一个元素和最后一个元素的下一个位置.</forwardit,forwardit>

然而, 它的接口有些笨重:
例A:

std::vector v = {3, 7, 3, 11, 3, 3, 2}; sort(v.begin(), v.end());

// equal_range, attempt 1: natively clumsy std::pair<std::vector::iterator, std::vector::iterator> range1 = equal_range(v.begin(), v.end(), 3); std::for_each(range1.first, range1.second, doSomething);

用一个`typedef` 或者`using`让它更简洁:
例B:

std::vector v = {3, 7, 3, 11, 3, 3, 2}; sort(v.begin(), v.end());</std::vector

using IteratorPair = std::pair<std::vector::iterator, std::vector::iterator>;</std::vector

// equal_range, attempt 2: with the classical typedef IteratorPair range2 = equal_range(v.begin(), v.end(), 3); std::for_each(range2.first, range2.second, doSomething);

例B确实简洁了很多, 但是仍有一个根本问题: 没有考虑 抽象等级.
尽管返回的是一个范围, 但这对迭代器强迫我们在操作返回的范围时必须按照"第一""第二"这种方式来写代码. 范围就应该用"首""尾"这种方式来表达. 这不仅给我们在其他地方使用这个返回值时造成很大的麻烦, 而且使代码很别扭.

为了解决这个问题, 我么可以把`std::equal_range` 返回的迭代器对封装进一个有"范围"这种语义的`object`

template

class Range

{

public:

Range(std::pair range)

m_begin(range.first), m_end(range.second) {} typename Container::iterator begin() { return m_begin; } typename Container::iterator end() { return m_end; }

private: typename Container::iterator m_begin; typename Container::iterator m_end; };

注意: 尽管`std::equal_range` 返回的结果是一个"范围", 但是`std::begin` 和 `std::end` 不能用在这个结果上. 而上面的封装解决了这个问题.
可以像下面这样使用:

std::vector v = {3, 7, 3, 11, 3, 3, 2}; sort(v.begin(), v.end());

// equal_range, attempt 3: natural al last Rangestd::vector\ range3 = equal_range(v.begin(), v.end(), 3); std::for_each(range3.begin(), range3.end(), doSomething);

不管你使用上面的哪种方式, `std::equal_range` 都会返回一个范围, 要确定它是否为空, 可以通过检查那两个迭代器(是否相等)或者使用`std::distance` 检查它的大小. </std::vector<int>

bool noElementFound = range3.begin() == range3.end(); size_t numberOfElementFound = std::distance(range3.begin(), range3.end()) ```

Where should it be?

这个问题仅仅针对排序的范围, 因为对于乱序的范围, 某个元素可能会存在任何位置.

对于排序的范围, 这个问题可以简化为: 如果它存在, 那么它在哪儿? 如果它不存在, 那么它应该在哪儿?

这个问题可以用算法std::lower_boundstd::upper_bound 来解释.

当你理解了std::equal_range 后, 上面这句话就很容易理解了: std::lower_boundstd::upper_bound 都会返回 std::equal_range 返回的那个迭代器对的第一个和第二个迭代器.

要插入某个值x, 使用std::lower_bound 得到指向 在范围里与x相等的元素之前的位置的迭代器, 使用std::upper_bound 得到指向 在范围里与x相等的元素之后的位置的迭代器.

注意: 如果仅仅是搜索某个元素, 永远不要使用std::lower_bound

std::find 相反, 你不能根据 判断std::lower_bound 返回的迭代器是否与终点的迭代器相等 来判断要搜索的值是否存在于这个集合. 事实上, 如果这个值在集合里不存在, 则std::lower_bound 返回它应该在的位置, 而不是终点的迭代器. 所以, 你不仅需要确认返回的迭代器不是终点的迭代器, 还要确认它指向的元素跟要搜索的值是相等的.

总结

Question to express in C++

NOT SORTED

SORTED

Is it there?

std::find != end

std::binary_search

Where is it?

std::find

std::equal_range

Where should it be?

std::lower_bound / std::upper_bound

原文地址: http://www.fluentcpp.com/2017/01/16/how-to-stdfind-something-efficiently-with-the-stl/?hmsr=toutiao.io&utm_medium=toutiao.io&utm_source=toutiao.io

❌
❌