大家好,我是半夏之沫 😁😁 一名金融科技领域的JAVA系统研发😊😊
我希望将自己工作和学习中的经验以最朴实,最严谨的方式分享给大家,共同进步👉💓👈
👉👉👉👉👉👉👉👉💓写作不易,期待大家的关注和点赞💓👈👈👈👈👈👈👈👈
前言
基于RAG构建知识库的时候,需要选择Embedding模型来将知识语料进行向量化,这就让很多人误以为向量化等于Embedding,严格来讲,这两者并不完全等同。
本文将从向量化,Embedding的由来和RAG里的Embedding这三方面展开,解释清楚向量化是什么,以及RAG里的Embedding和传统Embedding有什么不同。
正文
一. 文本向量化
为了让文本参与数学计算,需要将文本转换为数字,这里的数字实际上就是一个向量,将文本转换为向量的过程就叫做文本向量化。
词袋法和TF-IDF是两种简单且经典的文本向量化算法,可以通过这两个算法来理解文本向量化的核心思想。
1. 词袋法
假如给定如下三个句子。
猫喜欢鱼并且也喜欢玩耍
狗喜欢骨头
猫和狗都喜欢玩耍
对每个句子进行分词,分词后表示如下。
猫 喜欢 鱼 并且 也 喜欢 玩耍
狗 喜欢 骨头
猫 和 狗 都 喜欢 玩耍
从每个句子分词后的结果里提取不重复的词语,可以得到如下表格。
| 猫 | 喜欢 | 鱼 | 并且 | 也 | 玩耍 | 狗 | 骨头 | 和 | 都 |
|---|
上述表格称为词表。
此时根据词表,有两种方式将给定的三个句子表示成向量。
第一种方式是有去重词袋法。
统计词表中每个词在句子中是否出现,出现记作1,未出现记作0。
| 句子 | 猫 | 喜欢 | 鱼 | 并且 | 也 | 玩耍 | 狗 | 骨头 | 和 | 都 | 向量表示 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 猫喜欢鱼并且也喜欢玩耍 | 1 | 1 | 1 | 1 | 1 | 1 | 0 | 0 | 0 | 0 | |
| 狗喜欢骨头 | 0 | 1 | 0 | 0 | 0 | 0 | 1 | 1 | 0 | 0 | |
| 猫和狗都喜欢玩耍 | 1 | 1 | 0 | 0 | 0 | 1 | 1 | 0 | 1 | 1 |
第二种方式是无去重词袋法。
统计词表中每个词在句子中出现的次数,出现次数是多少就记作几。
| 句子 | 猫 | 喜欢 | 鱼 | 并且 | 也 | 玩耍 | 狗 | 骨头 | 和 | 都 | 向量表示 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 猫喜欢鱼并且也喜欢玩耍 | 1 | 2 | 1 | 1 | 1 | 1 | 0 | 0 | 0 | 0 | |
| 狗喜欢骨头 | 0 | 1 | 0 | 0 | 0 | 0 | 1 | 1 | 0 | 0 | |
| 猫和狗都喜欢玩耍 | 1 | 1 | 0 | 0 | 0 | 1 | 1 | 0 | 1 | 1 |
词袋法有一个大前提是:认为有很多相同词语的句子之间的语义是相近的。所以词袋法将句子转换为向量时,只关心句子里有什么词,这个词在句子中出现了多少次。
2. TF-IDF
将句子转换为向量后,向量里每个值就称作特征值,词在句子中越重要,特征值就应该越高。
考虑如下三个句子。
- 今天的天气很好,但是我的计划是在家里休闲的睡觉。
- 安逸的生活是所有人的向往。
- 每天最开心的事情就是美美的吃一顿。
注意到的这个词出现的次数特别多,但是的这个词没有什么语义,不应该因为其出现的次数很高就让其特征值很高。
所以引出了TF-IDF算法。
TF(Term Frequency)叫做词频,计算公式是,即认为一个词的重要性与这个词在句子中出现的次数呈正比。
IDF(Inverse Document Frequency)叫做逆向文档频率(稀有度),计算公式是,即认为一个词如果在其它文档中出现得很少,则IDF值就会很大,此时表示这个词稀有度很高。
稀有度和含有该词文档数的曲线图可以表示如下。
TF-IDF值就是TF值乘上IDF值,即。
示例代码如下。
import math
from collections import Counter
# 准备三个句子并使用jieba完成分词
corpus = [
"我 爱 学习 数据 结构",
"我 爱 学习 算法",
"数据 结构 很 重要"
]
doc_words = [doc.split() for doc in corpus]
total_docs = len(doc_words)
# 计算TF
def compute_tf(word, doc):
word_count = Counter(doc)
return word_count[word] / len(doc)
# 计算IDF
def compute_idf(word, all_docs):
contain_word_docs = sum(1 for doc in all_docs if word in doc)
return math.log(total_docs / (contain_word_docs + 1)) + 1
# 计算TF-IDF
def compute_tfidf(doc_index):
current_doc = doc_words[doc_index]
tfidf_dict = {}
for word in set(current_doc):
tf = compute_tf(word, current_doc)
idf = compute_idf(word, doc_words)
tfidf = tf * idf
tfidf_dict[word] = round(tfidf, 4)
return tfidf_dict
# 输出结果
for i in range(total_docs):
print(f"📄 文档{i + 1} 的TF-IDF得分:")
print(compute_tfidf(i))
print("-" * 50)
运行示例代码,输出结果如下。
📄 文档1 的TF-IDF得分:
{'数据': 0.2, '爱': 0.2, '学习': 0.2, '结构': 0.2, '我': 0.2}
--------------------------------------------------
📄 文档2 的TF-IDF得分:
{'算法': 0.3514, '学习': 0.25, '我': 0.25, '爱': 0.25}
--------------------------------------------------
📄 文档3 的TF-IDF得分:
{'重要': 0.3514, '数据': 0.25, '很': 0.3514, '结构': 0.25}
--------------------------------------------------
示例代码中的三个句子通过TF-IDF就可以表示成如下向量。
| 句子 | 我 | 爱 | 学习 | 数据 | 结构 | 算法 | 很 | 重要 | 向量表示 |
|---|---|---|---|---|---|---|---|---|---|
| 我爱学习数据结构 | 0.2 | 0.2 | 0.2 | 0.2 | 0.2 | 0 | 0 | 0 | |
| 我爱学习算法 | 0.25 | 0.25 | 0.25 | 0 | 0 | 0.3514 | 0 | 0 | |
| 数据结构很重要 | 0 | 0 | 0 | 0.25 | 0.25 | 0 | 0.3514 | 0.3514 |
二. Embedding-word2vec
词袋法和TF-IDF有两个巨大缺点。
- 本质上是在统计词频,会丢失词在上下文中的语义;
- 得到的向量维度和词个数一样,导致语料库庞大时向量维度又高又稀疏(稀疏的意思是一个向量只有少数位置上有特征值,其余位置特征值全部是0)。
为了解决这个问题,在03年的A Neural Probabilistic Language Model (NNLM)中提出了Embedding概念,也就是词嵌入。
Embedding可以将高维稀疏向量嵌入为低维稠密向量,但是在2013年以前,Embedding模型的训练速度上不来,所以一直没有大规模应用,直到2013年谷歌先后在Efficient Estimation of Word Representations in Vector Space和Distributed Representations of Words and Phrases and their Compositionality这两篇论文中引出了word2vec算法,才算是真正的把Embedding推到了大众视野前。
前面提到的词袋法和TF-IDF都是将句子表示成向量,但是如果要将一个一个的词语表示成向量,其实最原始且简单的方式是使用独热编码(ont-hot)。
比如现在词表是,词表里每个词可以用独热编码表示如下。
| 词 | 独热编码 |
|---|---|
| 我 | |
| 你 | |
| 他 | |
| 好 | |
| 坏 |
词表里每一个词通过独热编码都能得到一个独一无二的向量表示,但是缺点也很明显。
- 向量纬度高;
- 信息稀疏;
- 无法进行向量间的运算。
此时需要通过Embedding操作将高维,稀疏且无法运算的向量映射到低维,稠密且可以运算的向量上。
word2vec是Embedding经典算法之一,其有两种模式。
- CBOW。给定上下文预测中间的词;
- Skip-Gram。给定中间的词预测上下文。
下文将选择Skip-Gram来对word2vec算法进行讲解,在正式讲解前,有两点重要说明。
- word2vec算法有一个重要前提假设:句子中离得越近的词语相关度越高;
- 无论是NNLM还是word2vec,其本质都是神经网络,所以下面的讲解均是基于神经网络的训练阶段。
👉 正式讲解Skip-Gram算法。
假如有一个语料库,并且从语料库中能提取出10000个词,那么这10000个词就组成了这个语料库的词表,按照独热编码可以得到10000个10000维的向量。
在某一次训练时,从这10000个词中假定选择了一个叫做“ants”的词来进行训练,此时称本次训练的中心词是“ants”,“ants”这个词通过独热编码可以得到下面这样一个向量。
需要训练的神经网络结构是下面这样。
输入层是10000维的独热编码向量,先与一个的权重矩阵相乘,做第一次线性变换得到一个300维的中间层向量,然后300维的中间层向量再与一个 的权重矩阵相乘,做第二次线性变化得到10000个输出得分,这些输出得分表示输入是“ants”的情况下,输出是词表中对应位置的词的得分,得分越高,说明神经网络越认为应该输出这个词。
因为词表中有10000个词,所以会有10000个得分,对这些得分做Softmax就可以得到输出概率,概率越高,说明神经网络越认为应该输出这个词。
有了输出概率,现在就要计算神经网络的损失,前面提到Skip-Gram是给定中间的词预测上下文,那么损失的计算就需要确定中间的词以及确定要预测多少个上下文的词。
在例子中,中心词是“ants”,所以中间的词就是“ants”,此时从语料库中采样得到一条包含中心词“ants”的句子,假定采样得到的这个句子表示如下。
现在有一个参数选项叫做窗口大小,如果确定为2,意思就是取中心词左边2个词,以及中心词右边2个词作为上下文,即一共要预测4个上下文的词,在例子中就是给定“ants”时,窗口大小如果为2,那么要预测的上下文就是“are”,“many”,“carrying”和“things”。
再回顾一下word2vec的重要前提假设。
句子中离得越近的词语相关度越高。
也就是给定中心词,那么和中心词相关度最高的词就是这个中心词的上下文,那么输出这些上下文里的词的概率就应该是最高的,如果不是最高,说明预测产生了损失,所以在计算损失时,只关注上下文里的词的概率。
在例子中给定中心词是“ants”,确定了窗口大小是2,所以上下文就是“are”,“many”,“carrying”和“things”,用表示给定中心词是的情况下,神经网络预测输出词为的概率,此时计算损失时就只关心,,和。
再罗嗦一下,上面两段话想表达:
- 给定“ants”,输出“are”,“many”,“carrying”和“things”的概率越高,说明神经网络效果越好,损失越小,表明参数越准确;
- 给定“ants”,输出“are”,“many”,“carrying”和“things”的概率越小,说明神经网络效果越差,损失越大,表明参数越不准确。
损失函数使用,那么在例子中,损失值计算如下。
在训练神经网络时,希望损失值越小越好,所以基于损失值L对和的每个参数求偏导得到梯度,然后更新和的每个参数,让损失值L逐渐变小,最终完成收敛,此时就认为训练完毕。
训练完毕后,得到了两个矩阵和的,其中就是需要的最终产物,在word2vec算法中,Embedding模型指的就是这个矩阵中的一堆参数。
在例子的最开始,给定了10000个10000维的独热编码向量,通过神经网络训练得到了矩阵,现在通过矩阵可以将这10000个独热编码向量都映射成1个300维的嵌入向量,并且映射关系唯一,以“ants”举例。
此时就相当于一个查找表,词表里每一个词,都可以从这个查找表里直接查表得到一个向量表示,维度是300维。
通过gensim包可以快速完成word2vec的训练和使用,相关代码可以让大语言模型帮助生成,这里不再演示。
三. RAG里面的Embedding
通常当需要基于RAG做知识库构建时,需要先准备知识语料,然后选择一个Embedding模型,接着将知识语料按照指定策略分片,再接着将分片后的语料进行向量化,最后存入向量数据库。
在RAG中,是将分片后的一段内容表示成一个向量,但是刚刚了解到的Embedding模型是通过查找表将一个词(或者Token)转换为一个向量,所以RAG里面的Embedding和word2vec的Embedding肯定是有所不同的。
RAG中的Embedding结构通常可以表示如下。
RAG中做Embedding前需要先对分片内容进行分词,分词后得到Token集合,比如分词后得到100个Token,同时Embedding模型的嵌入维度是1024维,那么这100个Token经过查找表后会得到100个1024维的向量,传统的Embedding到这里就结束了,但是RAG的Embedding还有后面的流程:位置编码,Transformer编码器和池化。
位置编码(Position Encoding)就是将每个向量与位置向量相加,让每个Token对应的向量携带上位置信息,这里不做展开。
Transformer编码器结构和大语言模型里的Encoder保持一致,由多头自注意力机制层,残差连接层和前馈层组成,经过位置编码后的100个1024维向量会在Encoder中进行Nx轮的信息聚合,信息聚合后会得到新的100个1024维向量,此时这100个1024维向量中的任何一个向量,都可以认为其包含了分片后语料中的完整信息,这是由Transformer中的Encoder的自注意力机制特性决定的,也不做展开。
池化就是从Transformer编码器送出的多个向量中,按照一定的策略选择一个向量作为最终分片语料的向量,RAG中常见的池化策略如下。
- CLS Pooling。取第一个Token的向量,在代码里面通常取名为cls_token_pooling;
- Last Token Pooling。取最后一个Token的向量,在代码里面通常取名为last_token_pooling。
因为RAG中做Embedding使用的是Transformer中的Encoder,所以在信息聚合的时候,每个中心词都能看到完整的上下文,所以在信息聚合后,第一个Token向量中包含的信息,和最后一个Token向量中包含的信息理论上应该是保持一致的。
到这里其实RAG里面的Embedding的结构就很清晰了,不单单有查找表,还额外加入了位置编码,Transformer编码器和池化,额外需要说明的一点是,RAG里面的Embedding的所有参数,是需要单独训练的。
聪明的人已经发现了,除了RAG的Embedding,LLM中也有Embedding,且LLM中的Embedding其实更贴近于传统Embedding,也就是做一个查找表的作用,但是LLM中的Embedding不是单独训练的,而是在进行大语言模型训练的时候,一并训练得到的。
大家好,我是半夏之沫 😁😁 一名金融科技领域的JAVA系统研发😊😊
我希望将自己工作和学习中的经验以最朴实,最严谨的方式分享给大家,共同进步👉💓👈
👉👉👉👉👉👉👉👉💓写作不易,期待大家的关注和点赞💓👈👈👈👈👈👈👈👈