Embedding与向量数据库:两条检索路线的实践复盘
先梳理一下今天要聊的核心:如何在“三国演义”这类中文语料上打通本地Word2Vec和云端文本向量API这两条技术路线?词级表示和段级表示到底有什么区别?以及,Embedding与向量索引在整个RAG链路中扮演什么角色?这些问号,会通过一份完整的学习日志来逐一拉直。

语料与目录约定
先把底层的目录结构说清楚,因为路径问题往往是第一道坎。
语料原文统一放在 data/课程练习/source/{语料名}/ 下,分词结果则另起炉灶,写在同级目录的 segmented/{语料名}/ 里。为什么这么设计?核心是为了避免PathLineSentences在读取时把未分词正文和分词结果混在一起,产生数据污染。
至于Word2Vec模型的存储位置,约定放在 vectorModels/{语料名}/{语料名}_word2vec.model。这里有个小坑:Gensim的model.sa ve方法要求参数必须是字符串路径,或者带有write方法的文件对象。如果传了一个pathlib.Path对象,内部会调用.endswith进行检查,然后直接抛出类型错误。所以,路径用字符串,别省事。
Word2Vec:词表、近似词与句子级查询
Word2Vec的核心能力建立在词表之上。比如直接调用wv.most_similar("曹操"),只能查词表中已有的token,把整句当作键值去查,必然触发KeyError。
但实际场景中,用户输入往往是整句问话。比如“谁在长坂坡救了阿斗?”,怎么在词向量空间里找近邻词?流程是:先用jieba分词,过滤掉OOV词,然后对剩余词向量取平均,最后调用wv.similar_by_vector。实践中封装了一个叫similar_words_for_sentence的函数来干这个活。
不过必须承认,这种方法的局限很明显:它只能返回与句向量方向接近的单个词,而不是另一段原文句子。真正要做“句对段”的检索,还是得靠API向量库或者Doc2Vec这类段落级模型。
为了便于两条路线对照,写了一个demo_query_changban_adou_similarity函数,与API侧的同名函数使用同一个问句,默认不在__main__里自动调用,需要手动触发。
DashScope Text Embedding:批量建库、本地复用与TopK检索
API路线的建库逻辑比较清晰:枚举语料目录下的所有文件,对长文按字符上限进行切块处理,然后分批调用TextEmbedding.call,默认用pickle将结果写入vectorModels/{语料名}/{语料名}_API.model。对应的脚本是embedding_API_pickle.py。
返回的数据结构需要注意:output["embeddings"]是一个列表,每个元素包含embedding(浮点向量)和text_index两个字段。取单条输入的向量,需要用[0]["embedding"]来定位。
一个非常实用的工程技巧是skip_if_exists参数。当本地已经存在*_API.model文件时,跳过重新编码,直接复用,能省下大量API调用。但有个细节:即使库文件已经被缓存,对新的问句做相似度计算时,仍然需要为query单独调用一次Embedding,除非手动传入已经算好的query_vector。
检索环节就是标准的余弦相似度计算:对query与库中每个块的向量做比较,取TopK返回。每个条目可以附带snippet字段,方便肉眼快速确认结果质量。
额度问题(403 / AllocationQuota.FreeTierOnly)是绕不开的坎。免费额度用尽后,需要在DashScope控制台关闭“仅使用免费额度”选项,或者开通按量付费。开发阶段一个比较稳妥的做法是对失败信息做中文说明,并用本地第一条语料向量演示离线TopK,完全不调API。同时务必避免对同一语料反复全量重算,优先复用本地*.model或*.faiss。每次运行前检查skip_if_exists=True,这能省下一大笔无畏请求。
从Pickle到FAISS:索引与元数据的分工
Embedding的本质是把语义压进高维向量空间,检索则是在这个空间里找近邻。找到近邻后,再靠ID回找原文。LLM负责理解和生成,而向量库与元数据共同完成“索引→查ID→拼进上下文”这条链路。
练习中有两种落盘形式:
- Pickle:
{语料名}_API.model,向量与条目信息一锅端,简单直接,适合起步。 - FAISS:拆成两个文件,
{语料名}_API_FAISS.faiss(二进制索引)和{语料名}_API_FAISS.meta.json(存储file、chunk_index、snippet等元数据,不含向量)。从Pickle迁移到FAISS时,可以复用已有的向量,无需重新调用Embedding。
封装了一个FaissVectorStore类(位于faiss_vector_store.py),统一管理索引与ID对齐的items,提供sa ve、load、search接口。工程上常见的组合是FAISS(或同类ANN)负责快速近邻检索,JSON/SQLite负责ID到原文与业务字段的映射。
检索踩坑实录:切块策略是关键
语义稀释:chunk过大
当max_chars_per_chunk设置为3500这样的大值时,搜索“桃园结义”可能会排到与主题无关的段落。原因很简单:一块文本里信息太多,单条向量更像整块内容的“平均语义”,无法对齐短查询所期望的“特写”效果。实践表明,将单块缩小到约500字量级,可以显著改善针对特定情节的召回率。代价是块数增加,建库的API调用次数和存储成本也跟着上升。成本与效果之间的平衡,需要根据实际场景去取舍。
语义断层与强行匹配
搜索“谁在长坂坡救了阿斗”,结果却排出了姜维相关的段落。问题出在语料本身——如果语料是节选或删节版,根本没有对应的回目,RAG自然无法召回到不存在的内容。但向量检索不会乖乖承认失败,它会返回一个“相对最近”的块(比如同样包含“救人”“落马”等表面词),反而容易产生误导。生产环境里,建议设置相似度阈值,分数过低时宁可回答“未知”,并确保语料覆盖与问题域一致。
查询太短
只输入“赵云”这样的极短查询,向量方向非常含糊,TopK飘到泛化的“武将、征战”段落是常态。使用完整的问句,检索稳定性明显更高。
两条路线怎么选
| 维度 | Word2Vec(本地) | 文本Embedding API |
|---|---|---|
| 粒度 | 词 | 整段/切块文本 |
| 典型查询 | 单词近邻、加减类比 | 问句→与语料块相似度 |
| 依赖 | 分词语料、训练时间 | 网络、Key、配额 |
| 离线 | 训练后可完全本地 | 库可离线读;新query一般仍要联网 |
遗留问题与后续方向
- Word2Vec句向量的简单平均只是基线方案。可以探索加权的TF-IDF、Doc2Vec,或者多语言句向量模型,同时研究RAG中的chunk与overlap策略。
- API侧的
text_type(query/document)、切块大小与重叠率对排序效果的影响,值得在有额度时做一组对比实验。 - 数据方面,用更完整、更对齐问题域的文本去测试全量检索与压力表现。
- 查询优化:借助LLM将白话问句改写为古籍高频关键词,可以提升专名召回率。
- 引入重排序(Rerank),缓解仅依靠向量相似度带来的表面字词或场景混淆问题。
小结
前端往往追求像素级的确定性,而检索与生成链路更多是在概率与阈值上做权衡。同一套代码,换一个chunk大小或语料覆盖范围,结果可以从“看似胡扯”变成“基本可用”。Day9把这条因果链从目录结构一直梳理到额度管理、本地复用与FAISS落盘,目的就是为后续接正式向量库与RAG时,少踩一些重复的坑。
