Skip to main content

文本匹配与相似度计算的深度解析

· 12 min read
郭流芳
资深算法工程师

"在信息的海洋中,找到相似的文本就像在茫茫人海中寻找知己。这不仅需要技巧,更需要对语言深层次的理解。" —— 2017年在广联达优化智能客服系统时的思考

开篇:文本匹配的无处不在

从搜索引擎到推荐系统,从智能客服到论文查重,文本匹配与相似度计算是NLP领域最基础也最核心的技术之一。在广联达设计智能客服系统时,我面临的核心挑战就是:如何让机器准确判断用户问题与知识库中哪个问题最相似?

这个问题看似简单,实则充满了挑战:

  • 同义词问题电脑 vs 计算机
  • 语序问题A和B vs B和A
  • 句法结构问题我把书给他 vs 他把书给我
  • 深层语义问题苹果手机多少钱 vs iPhone价格

这篇博客将带你深入探索文本匹配技术的演进,从传统方法到深度学习模型。

传统方法:从字符串到向量

1. 编辑距离(Edit Distance)

编辑距离是最直观的相似度度量,它计算一个字符串转换成另一个字符串所需的最少编辑操作次数(插入、删除、替换)。

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import jieba
from collections import Counter

class TraditionalSimilarity:
"""传统相似度计算方法"""

def __init__(self):
pass

def edit_distance(self, s1, s2):
"""计算编辑距离"""
if len(s1) < len(s2):
return self.edit_distance(s2, s1)

if len(s2) == 0:
return len(s1)

previous_row = list(range(len(s2) + 1))
for i, c1 in enumerate(s1):
current_row = [i + 1]
for j, c2 in enumerate(s2):
insertions = previous_row[j + 1] + 1
deletions = current_row[j] + 1
substitutions = previous_row[j] + (c1 != c2)
current_row.append(min(insertions, deletions, substitutions))
previous_row = current_row

return previous_row[-1]

def edit_distance_similarity(self, s1, s2):
"""基于编辑距离的相似度"""
max_len = max(len(s1), len(s2))
if max_len == 0:
return 1.0
return 1 - self.edit_distance(s1, s2) / max_len

def jaccard_similarity(self, s1, s2):
"""Jaccard相似度"""
set1 = set(jieba.lcut(s1))
set2 = set(jieba.lcut(s2))
intersection = len(set1.intersection(set2))
union = len(set1.union(set2))
return intersection / union if union > 0 else 0

def visualize_traditional_methods(self):
"""可视化传统方法的效果"""
test_cases = [
("钢筋怎么计算", "如何计算钢筋用量"), # 语义相似,字面不同
("混凝土强度等级", "混凝土等级强度"), # 语序不同
("软件操作指南", "软件使用手册"), # 同义词
("造价预算", "成本预算"), # 部分同义
("施工安全", "项目管理") # 不相关
]

results = []
for s1, s2 in test_cases:
edit_sim = self.edit_distance_similarity(s1, s2)
jaccard_sim = self.jaccard_similarity(s1, s2)
results.append({
'pair': f"{s1[:4]}... vs {s2[:4]}...",
'edit_similarity': edit_sim,
'jaccard_similarity': jaccard_sim
})

df = pd.DataFrame(results)

fig, ax = plt.subplots(figsize=(12, 7))
df.set_index('pair').plot(kind='bar', ax=ax, alpha=0.7)

ax.set_ylabel('相似度')
ax.set_title('传统相似度方法比较')
ax.tick_params(axis='x', rotation=45)
ax.grid(True, alpha=0.3, axis='y')

# 添加数值标注
for p in ax.patches:
ax.annotate(f"{p.get_height():.2f}",
(p.get_x() + p.get_width() / 2., p.get_height()),
ha='center', va='center', xytext=(0, 9),
textcoords='offset points')

plt.tight_layout()
plt.show()

print("=== 传统方法评估 ===")
print("编辑距离:对字面相似度敏感,无法处理同义词和语序问题。")
print("Jaccard相似度:基于词袋模型,能处理语序问题,但无法处理同义词。")

# 运行传统方法演示
traditional_sim = TraditionalSimilarity()
traditional_sim.visualize_traditional_methods()

2. 向量空间模型(Vector Space Model)

这是文本匹配领域的一次飞跃,它将文本表示为向量,从而可以在向量空间中计算距离。

TF-IDF (Term Frequency-Inverse Document Frequency)

TF-IDF是最经典的文本表示方法,它衡量一个词在文档中的重要性。

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

class VectorSpaceModel:
"""向量空间模型"""

def __init__(self):
self.vectorizer = TfidfVectorizer(tokenizer=jieba.lcut)

def tfidf_similarity(self, corpus):
"""计算语料库中所有文本对的TF-IDF余弦相似度"""
tfidf_matrix = self.vectorizer.fit_transform(corpus)

# 计算两两之间的余弦相似度
cosine_sim_matrix = cosine_similarity(tfidf_matrix)

return cosine_sim_matrix, self.vectorizer.get_feature_names_out()

def visualize_tfidf_heatmap(self, corpus):
"""可视化TF-IDF相似度热图"""
cosine_sim_matrix, _ = self.tfidf_similarity(corpus)

# 截断语料库标签以便显示
labels = [text[:10] + '...' if len(text) > 10 else text for text in corpus]

plt.figure(figsize=(10, 8))
sns.heatmap(cosine_sim_matrix, annot=True, cmap='viridis', fmt='.2f',
xticklabels=labels, yticklabels=labels)
plt.title('TF-IDF 余弦相似度热图')
plt.xticks(rotation=45, ha='right')
plt.yticks(rotation=0)
plt.tight_layout()
plt.show()

def analyze_tfidf_vectors(self, corpus):
"""分析TF-IDF向量"""
tfidf_matrix = self.vectorizer.fit_transform(corpus)
feature_names = self.vectorizer.get_feature_names_out()

df_tfidf = pd.DataFrame(tfidf_matrix.toarray(),
columns=feature_names,
index=[f"Doc {i+1}" for i in range(len(corpus))])

print("=== TF-IDF 向量分析 ===")
print(df_tfidf)

# 找出每个文档中最重要的词
print("\n=== 各文档中最重要的TF-IDF词汇 ===")
for i, doc in enumerate(corpus):
top_word_idx = np.argmax(tfidf_matrix[i])
top_word = feature_names[top_word_idx]
top_score = tfidf_matrix[i, top_word_idx]
print(f"文档 {i+1} ('{doc[:10]}...'): '{top_word}' (TF-IDF: {top_score:.3f})")

# 运行向量空间模型演示
vsm = VectorSpaceModel()
corpus = [
"钢筋搭接长度怎么计算",
"如何计算钢筋的搭接长度",
"混凝土强度等级如何选择",
"造价软件的操作指南",
"广联达软件使用手册"
]
vsm.visualize_tfidf_heatmap(corpus)
vsm.analyze_tfidf_vectors(corpus)

词嵌入方法:让机器理解语义

TF-IDF虽然强大,但它无法理解词语之间的语义关系。词嵌入(Word Embedding)技术解决了这个问题。

1. Word2Vec

Word2Vec是Google在2013年提出的经典词嵌入模型,它能将词语映射到一个低维向量空间,使得语义相近的词在空间中也相近。

from gensim.models import Word2Vec
from sklearn.decomposition import PCA

class WordEmbeddingSimilarity:
"""基于词嵌入的相似度计算"""

def __init__(self):
self.model = None

def train_word2vec_model(self, sentences):
"""训练Word2Vec模型"""
# 分词
tokenized_sentences = [jieba.lcut(s) for s in sentences]

# 训练模型
self.model = Word2Vec(sentences=tokenized_sentences,
vector_size=100, # 向量维度
window=5, # 上下文窗口大小
min_count=1, # 最小词频
workers=4) # 训练线程数

print("Word2Vec模型训练完成")
return self.model

def get_sentence_vector(self, sentence):
"""计算句子向量(简单平均法)"""
words = jieba.lcut(sentence)
vectors = [self.model.wv[word] for word in words if word in self.model.wv]

if not vectors:
return np.zeros(self.model.vector_size)

return np.mean(vectors, axis=0)

def word2vec_similarity(self, s1, s2):
"""计算基于Word2Vec的余弦相似度"""
if self.model is None:
raise ValueError("模型未训练")

vec1 = self.get_sentence_vector(s1).reshape(1, -1)
vec2 = self.get_sentence_vector(s2).reshape(1, -1)

return cosine_similarity(vec1, vec2)[0][0]

def visualize_word_embeddings(self, words_to_visualize):
"""可视化词向量"""
if self.model is None:
raise ValueError("模型未训练")

word_vectors = np.array([self.model.wv[word] for word in words_to_visualize if word in self.model.wv])

# 使用PCA降维到2D
pca = PCA(n_components=2)
result = pca.fit_transform(word_vectors)

plt.figure(figsize=(12, 8))
plt.scatter(result[:, 0], result[:, 1])

for i, word in enumerate(words_to_visualize):
if word in self.model.wv:
plt.annotate(word, xy=(result[i, 0], result[i, 1]), fontsize=12)

plt.title('Word2Vec 词向量可视化 (PCA降维)')
plt.xlabel('PCA Component 1')
plt.ylabel('PCA Component 2')
plt.grid(True, alpha=0.3)
plt.show()

def find_similar_words(self, word, topn=5):
"""查找相似词"""
if self.model is None or word not in self.model.wv:
return []

return self.model.wv.most_similar(word, topn=topn)

# 运行词嵌入方法演示
embedding_sim = WordEmbeddingSimilarity()

# 模拟建筑领域语料库
construction_corpus = [
"钢筋搭接长度怎么计算", "如何计算钢筋的搭接长度", "钢筋用量计算方法",
"混凝土强度等级如何选择", "C30混凝土是什么意思", "混凝土配合比设计",
"造价软件的操作指南", "广联达软件使用手册", "如何使用造价软件",
"施工安全注意事项", "项目管理中的安全问题", "建筑工地安全规范"
]

# 训练Word2Vec模型
w2v_model = embedding_sim.train_word2vec_model(construction_corpus)

# 可视化词向量
words_to_show = ['钢筋', '混凝土', '造价', '软件', '安全', '计算', '选择', '操作']
embedding_sim.visualize_word_embeddings(words_to_show)

# 查找相似词
print("\n=== 相似词查找 ===")
similar_words = embedding_sim.find_similar_words('软件')
print(f"'软件'的相似词: {similar_words}")

# 计算句子相似度
s1 = "钢筋怎么计算"
s2 = "如何计算钢筋用量"
similarity = embedding_sim.word2vec_similarity(s1, s2)
print(f"\n句子相似度 ('{s1}' vs '{s2}'): {similarity:.3f}")

深度学习方法:端到端的匹配

随着深度学习的发展,文本匹配进入了新时代。深度学习模型可以直接从原始文本中学习匹配模式,无需手动设计特征。

1. 孪生网络(Siamese Network)

孪生网络是深度学习文本匹配的经典架构,它使用两个共享权重的神经网络分别处理两个输入文本,然后计算它们的向量表示的相似度。

import tensorflow as tf
from tensorflow.keras.models import Model
from tensorflow.keras.layers import Input, Embedding, LSTM, Dense, GlobalAveragePooling1D
from tensorflow.keras.preprocessing.text import Tokenizer
from tensorflow.keras.preprocessing.sequence import pad_sequences

class SiameseNetworkDemo:
"""孪生网络文本匹配演示"""

def __init__(self, vocab_size=1000, max_length=20, embedding_dim=64, lstm_units=32):
self.vocab_size = vocab_size
self.max_length = max_length
self.embedding_dim = embedding_dim
self.lstm_units = lstm_units

self.tokenizer = Tokenizer(num_words=self.vocab_size, oov_token="<unk>")
self.model = self._build_siamese_model()

def _build_base_network(self):
"""构建基础网络(共享部分)"""
input_layer = Input(shape=(self.max_length,))
embedding_layer = Embedding(self.vocab_size, self.embedding_dim)(input_layer)
lstm_layer = LSTM(self.lstm_units)(embedding_layer)
return Model(inputs=input_layer, outputs=lstm_layer)

def _build_siamese_model(self):
"""构建孪生网络模型"""
base_network = self._build_base_network()

# 定义两个输入
input_a = Input(shape=(self.max_length,))
input_b = Input(shape=(self.max_length,))

# 两个输入共享同一个基础网络
processed_a = base_network(input_a)
processed_b = base_network(input_b)

# 计算L1距离
distance = tf.keras.layers.Lambda(
lambda x: tf.abs(x[0] - x[1])
)([processed_a, processed_b])

# 输出层
output = Dense(1, activation='sigmoid')(distance)

model = Model(inputs=[input_a, input_b], outputs=output)
model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy'])

return model

def prepare_data(self, text_pairs, labels):
"""准备数据"""
# 建立词汇表
all_texts = [pair[0] for pair in text_pairs] + [pair[1] for pair in text_pairs]
self.tokenizer.fit_on_texts(all_texts)

# 文本序列化和填充
sequences_a = self.tokenizer.texts_to_sequences([pair[0] for pair in text_pairs])
sequences_b = self.tokenizer.texts_to_sequences([pair[1] for pair in text_pairs])

padded_a = pad_sequences(sequences_a, maxlen=self.max_length, padding='post', truncating='post')
padded_b = pad_sequences(sequences_b, maxlen=self.max_length, padding='post', truncating='post')

return [padded_a, padded_b], np.array(labels)

def train(self, text_pairs, labels, epochs=5, batch_size=8):
"""训练模型"""
X, y = self.prepare_data(text_pairs, labels)

history = self.model.fit(X, y, epochs=epochs, batch_size=batch_size, validation_split=0.2)

# 可视化训练过程
self._plot_training_history(history)

return history

def _plot_training_history(self, history):
"""可视化训练历史"""
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 5))

# 绘制损失
ax1.plot(history.history['loss'], label='训练损失')
ax1.plot(history.history['val_loss'], label='验证损失')
ax1.set_title('模型损失')
ax1.set_xlabel('轮次')
ax1.set_ylabel('损失')
ax1.legend()
ax1.grid(True, alpha=0.3)

# 绘制准确率
ax2.plot(history.history['accuracy'], label='训练准确率')
ax2.plot(history.history['val_accuracy'], label='验证准确率')
ax2.set_title('模型准确率')
ax2.set_xlabel('轮次')
ax2.set_ylabel('准确率')
ax2.legend()
ax2.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

def predict(self, text_pair):
"""预测一对文本的相似度"""
sequences_a = self.tokenizer.texts_to_sequences([text_pair[0]])
sequences_b = self.tokenizer.texts_to_sequences([text_pair[1]])

padded_a = pad_sequences(sequences_a, maxlen=self.max_length, padding='post', truncating='post')
padded_b = pad_sequences(sequences_b, maxlen=self.max_length, padding='post', truncating='post')

prediction = self.model.predict([padded_a, padded_b])
return prediction[0][0]

# 运行孪生网络演示
siamese_demo = SiameseNetworkDemo()

# 模拟训练数据
train_pairs = [
("钢筋怎么计算", "如何计算钢筋用量"),
("混凝土强度等级", "混凝土等级强度"),
("软件操作指南", "软件使用手册"),
("造价预算", "成本预算"),
("施工安全", "项目管理"),
("钢筋怎么计算", "混凝土强度等级"),
("软件操作指南", "施工安全"),
("造价预算", "项目管理")
]
train_labels = [1, 1, 1, 1, 0, 0, 0, 0] # 1: 相似, 0: 不相似

# 训练模型
siamese_demo.train(train_pairs, train_labels, epochs=10)

# 测试模型
print("\n=== 孪生网络预测 ===")
test_pair_1 = ("钢筋计算方法", "钢筋用量如何算")
test_pair_2 = ("钢筋计算方法", "造价软件怎么用")

pred1 = siamese_demo.predict(test_pair_1)
pred2 = siamese_demo.predict(test_pair_2)

print(f"预测 '{test_pair_1[0]}' vs '{test_pair_1[1]}': {pred1:.3f} (预期: 相似)")
print(f"预测 '{test_pair_2[0]}' vs '{test_pair_2[1]}': {pred2:.3f} (预期: 不相似)")

方法选择与广联达实战经验

在广联达智能客服项目中,我并没有选择单一的"最优"算法,而是采用了多模型融合的策略:

  1. 初步筛选:使用TF-IDF + Faiss(一个高效的相似度搜索库)快速从海量知识库中召回Top-K个候选问题。
  2. 精排阶段:对召回的候选问题,使用更复杂的模型(如Word2Vec相似度、孪生网络)进行重新排序。
  3. 规则补充:对于一些特定场景(如产品型号、规范编号),使用基于规则和实体识别的方法进行强匹配。

性能对比

方法优点缺点适用场景
编辑距离简单快速,不需训练对字面变化敏感拼写纠错、短文本匹配
TF-IDF速度快,效果好无法处理语义相似大规模文本召回
Word2Vec能捕捉语义信息上下文信息丢失语义相似度计算
孪生网络端到端,效果好需要大量标注数据精排、语义匹配

延伸阅读与技术演进

文本匹配技术仍在不断发展,BERT、Transformer等预训练模型的出现,将匹配的准确率提升到了新的高度。

总结:没有银弹,只有组合拳

文本匹配与相似度计算是一个复杂的NLP问题,没有一种方法能完美解决所有场景。在广联达的实践中,我深刻体会到:

  1. 场景驱动:业务场景决定了技术选型。
  2. 多模型融合:结合不同模型的优点,取长补短。
  3. 效率与效果的平衡:在保证准确率的同时,要考虑系统的响应速度。
  4. 数据为王:高质量的标注数据是深度学习模型成功的关键。

理解各种匹配技术的原理和优缺点,并根据实际需求进行组合创新,才是解决实际问题的王道。


希望这篇文章能帮助你系统地理解文本匹配技术。在下一篇文章中,我将分享情感分析在客户反馈中的应用,敬请期待!