语义搜索底层:从数学公式到极简词袋矩阵特征计算的原生复现

语义搜索底层:从数学公式到极简词袋矩阵特征计算的原生复现

语义搜索底层:从数学公式到极简词袋矩阵特征计算的原生复现

现在做 RAG 和多模态搜索,大家基本都用稠密向量做相似度检索。但实际生产环境里,光靠稠密向量容易漏掉精准的实体词。所以混合检索成了标配——稠密向量加稀疏特征一起用。稀疏检索的核心还是 TF-IDF 和它的演进版 BM25。这篇文章想讲清楚怎么把 TF-IDF 的数学公式变成能跑的代码,用纯 Python 原生实现。

一、词袋和 TF-IDF

要让计算机处理文本,得先把文字变成数字。

**词袋模型(BoW)**是最基础的方法。它不管词出现的顺序,也不管语法关系,就是把文档当成一个无序的词汇集合,每个维度代表一个词出现的次数。

TF-IDF在词袋的基础上加了权重。像"的"、"在"、"我们"这种高频词,在所有文档里都大量出现,对区分文档没什么用。而"数据库"、"卡方检验"这种词,只在特定文档里频繁出现,在其他文档里很少见,区分度就高。

二、TF-IDF 的公式

TF-IDF 是两个分量相乘:

词频(TF):词在当前文档里出现的频率。

$$\text{TF}(t, d) = \frac{\text{词 } t \text{ 在文档 } d \text{ 中出现的次数}}{\text{文档 } d \text{ 中的总词数}}$$

逆文档频率(IDF):词在整个语料库里的罕见程度。

$$\text{IDF}(t, D) = \log\left(\frac{\text{语料库中的总文档数 } N}{\text{包含词 } t \text{ 的文档数 } + 1}\right)$$

两者相乘就是每个词的特征权重:

$$\text{TF-IDF}(t, d, D) = \text{TF}(t, d) \times \text{IDF}(t, D)$$

文档集变成稀疏特征矩阵的流程:

graph TD A[原始文档集 Corpus] --> B[分词与去标点 Tokenization] B --> C[构建全局唯一词表 Vocabulary] B --> D[计算每个词在单文档的词频 TF] A --> E[计算每个词在全局的逆文档频率 IDF] D --> F[TF-IDF 乘积计算] E --> F F --> G[生成稀疏文档特征矩阵 Sparse Matrix] G --> H[输入混合检索系统执行相似度比对]

三、原生 Python 实现

为了搞清楚稀疏矩阵怎么算的,这里用纯 Python 标准库(只用了mathre)实现了一个 TF-IDF 向量化工具。不依赖 scikit-learn 或 NumPy。

import math import re from typing import List, Dict, Set class NativeTFIDFVectorizer: def __init__(self): # 全局唯一词表 (Vocabulary) self.vocabulary: List[str] = [] # 记录每个词的 IDF 权重 self.idf_weights: Dict[str, float] = {} def _tokenize(self, text: str) -> List[str]: """简单的英文/拼音分词,剥离标点并统一转小写""" clean_text = text.lower() # 匹配所有单词或英文词素 return re.findall(r'\b[a-zA-Z0-9_u4e00-u9fa5]+\b', clean_text) def fit(self, raw_documents: List[str]): """基于语料库构建词表并计算每个词的 IDF""" total_docs = len(raw_documents) doc_count_per_word: Dict[str, int] = {} unique_words_set: Set[str] = set() # 1. 统计每个词在多少篇文档中出现过 for doc in raw_documents: words = self._tokenize(doc) unique_in_doc = set(words) for w in unique_in_doc: doc_count_per_word[w] = doc_count_per_word.get(w, 0) + 1 unique_words_set.add(w) # 排序后确立全局词表的维度映射 self.vocabulary = sorted(list(unique_words_set)) # 2. 计算每个词的 IDF 权重(加 1 进行平滑处理,防止除以 0) for w, count in doc_count_per_word.items(): self.idf_weights[w] = math.log((total_docs) / (count + 1)) + 1.0 def transform(self, raw_documents: List[str]) -> List[List[float]]: """将文档集转化为 TF-IDF 稀疏矩阵""" matrix = [] for doc in raw_documents: words = self._tokenize(doc) doc_len = len(words) # 计算当前文档的 TF 统计 tf_counts: Dict[str, int] = {} for w in words: tf_counts[w] = tf_counts.get(w, 0) + 1 # 对应全局词表生成特征向量 feature_vector = [] for vocab_word in self.vocabulary: if vocab_word in tf_counts and doc_len > 0: tf = tf_counts[vocab_word] / doc_len idf = self.idf_weights.get(vocab_word, 0.0) tf_idf = tf * idf else: tf_idf = 0.0 feature_vector.append(round(tf_idf, 4)) matrix.append(feature_vector) return matrix # 验证测试 if __name__ == "__main__": vectorizer = NativeTFIDFVectorizer() # 模拟包含三个简单文档的微型语料库 mock_corpus = [ "Database monitoring and query optimization", "Machine learning database for query prediction", "Optimizing cooking recipes for daily lunch" ] print("【TF-IDF 稀疏特征矩阵原生复现测试】\n") print("输入语料库文档:") for idx, doc in enumerate(mock_corpus, 1): print(f" 文档 {idx}: \"{doc}\"") # 训练模型 vectorizer.fit(mock_corpus) # 转换特征 tfidf_matrix = vectorizer.transform(mock_corpus) print(f"\n全局唯一词表大小 (维度数): {len(vectorizer.vocabulary)}") print("全局部分词汇 IDF 权重:") sample_words = ["database", "query", "cooking"] for w in sample_words: print(f" - '{w}': {round(vectorizer.idf_weights.get(w, 0.0), 4)}") print("\n生成的 TF-IDF 稀疏特征矩阵(第一篇文档的特征向量前 10 维展示):") print(tfidf_matrix[0][:10])

四、工程上的几个问题

把 TF-IDF 或 BM25 从公式写到生产代码里,有几个坑要注意:

零词频防分母为零。测试集里可能出现训练语料库里没见过的词,包含该词的文档数是 0。不加平滑项的话,IDF 计算会直接报错。所以公式里要加平滑修正,比如 $\log(N / (DF + 1))$。

稀疏矩阵的存储。真实语料库的词表维度可能高达几十万,但单个文档只包含几十个词,特征向量里 99% 都是 0.0。用稠密浮点数组存太浪费内存。生产环境应该用稀疏字典格式,只记录非零的索引和权重。

中文分词。中文字符之间没有空格,不能直接用split拆分。做中文检索的话,前面得接一个分词器,比如基于字典的最大匹配法,或者用现成的倒排索引分词库。

五、小结

TF-IDF 是老牌的检索算法,混合检索里用来兜底精确匹配很有效。从公式到代码,关键是把词频和全局区分度这两个量算对,然后处理好边界情况——平滑项防止除零、稀疏格式省内存。理论到工程,中间差的就是这些细节。


改写总结

修改项原文问题处理方式
开场白"在当前火热的"、"本文将探讨"等填充词删除,直接切入主题
夸大表述"底层基石"、"不可或缺的精确控制手段"、"工业级检索生产力"改为平实描述
三段式列举多处使用三项并列简化为两项或自然过渡
AI 词汇"底层"、"核心"、"关键"、"稳健地"替换为日常用语
公式化结论总结段过于宏大抽象重写为具体、务实的收尾
过度结构化标题过于学术化简化标题,减少层级感
语气过于正式、像教科书调整为技术博客风格,更自然

质量评分

维度得分
直接性8/10
节奏7/10
信任度8/10
真实性7/10
精炼度7/10
总分37/50

评价:良好,已去除大部分 AI 痕迹,但部分段落节奏仍偏均匀,可进一步打破句式结构。