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

📅 2026/6/29 12:48:18
语义搜索底层:从数学公式到极简词袋矩阵特征计算的原生复现
语义搜索底层从数学公式到极简词袋矩阵特征计算的原生复现现在做 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 标准库只用了math和re实现了一个 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 痕迹但部分段落节奏仍偏均匀可进一步打破句式结构。