在开启新内容前,先快速回顾前两节课的核心要点:
第10课通过
ollama的API,将一段文本成功转换为了【句向量】。第11课使用Python代码,把一篇Markdown文档切分成适合生成句向量的短文本片段。
没错,本节课的目标非常明确:借助已有的工具生成句向量,并将其妥善存储起来。
一、向量存储库的选择
市面上向量数据库种类繁多,例如老牌的 Milvus、云端的 Pinecone、以及 Qdrant。但最终我们选择了 ChromaDB,原因就两个字:便捷。
无需 Docker、无需端口映射、无需操心繁杂的环境配置——简单高效。不过它有一个容易踩坑的地方:采用“写入时推断 (Schema-on-Write)”模式。
它不要求像写 SQL 那样预先定义表结构、维度或相似度算法。你只需将模型计算出的向量丢入,它就自动锁定对应的格式。一旦提交,终身锁定。
二、双引擎,更强力
ChromaDB 的能力远不止向量存储与查询,它内部集成了两种引擎:
- 结构化引擎: SQLite(专门管理元数据)
- 向量引擎: hnswlib(专门负责距离计算)
当我们拆分数据并插入时,实际上执行了两次写入:
第一次:带有元数据(比如
{"source": "company_rules.md", "章节": "考勤管理"})和原始文本的数据,存入本地的 SQLite 关系型数据库。第二次:由 bge-small 计算出的 512 维向量数组,并未存入 SQLite(关系型数据库无法高效计算高维距离),而是被插入到由 C++ 编写的基础图索引库——hnswlib。
查询时采用分层方式:先查 SQLite,再查 hnswlib,从而实现极致提速。
三、计算向量
按照常规编程思路,流程通常是:
- 调用 Ollama 的 API,获取每个文本片段的句向量。
- 调用向量数据库的 API,将这些句向量连同元数据存储起来。
思路完全正确!但实际编码时并不完全依照这个步骤,因为本次使用的向量数据库 chromadb 天然就是为此场景设计的。因此实际编码思路应为:
- 定义好向 Ollama 发送请求的函数。
- 准备好文本切片和元数据。
- 把这些交给 chromadb,它会自动完成上述步骤。
核心代码如下:
import chromadb
import ollama
class OllamaEmbeddingFunction:
def __init__(self, model_name):
self.model_name = model_name
def __call__(self, input):
# 自动调用本地 Ollama 计算句向量
response = ollama.embed(model=self.model_name, input=input)
return response['embeddings']
def name(self):
return self.model_name
def main():
client = chromadb.PersistentClient(path=DB_PATH)
# 获取或创建集合
collection = client.get_or_create_collection(
name=COLLECTION_NAME,
embedding_function=OllamaEmbeddingFunction(model_name=MODEL_NAME)
)
collection.add(
documents=docs_texts,
metadatas=docs_metadatas,
ids=docs_ids
)
你可能会好奇:上面的代码里怎么多了一个 docs_ids?它是什么?为什么一定要手动传入?
四、chromadb 拒绝自动生成主键
使用 chromadb 之前,必须了解它与传统关系型数据库最大的不同:
- chromadb 不提供自动生成主键的能力。
为什么不呢?
原因很现实:如果数据库帮你自动生成 ID(比如 1, 2, 3 或随机的 abc-123),会引发两个致命问题:
无法更新和删除: 假如文档中改了一个错别字,你想更新向量库,但你根本无法知道 ChromaDB 当初给这段话分配了什么 ID,也就无法精准替换。
灾难级的重复存入: 如果不小心又把脚本跑了一遍,自动生成 ID 会导致数据库里出现两份完全相同的文本和向量。搜索时前两个结果可能就是一模一样的内容。
因此,从工程角度出发,ChromaDB 把 ID 的控制权交给开发者——必须在插入时主动传入 ids。
五、选择你的 id
目前最常见的方式有两种:
- 插入前生成 uuid,简单直接,但每次生成的 ID 必然不同。
- 计算切片的 hash,再与文档名拼接,实现“文本不变则 ID 稳定”的机制。
如果对 ID 稳定性要求高,选择方案2;如果只是做 demo 或文档检索,先用方案1更高效。
docs_ids.append(str(uuid.uuid4()))
六、先删后插
当原始文档更新后(比如改了一个错别字、增加了一段话),切分后的块会发生剧烈的“雪崩位移”。
此时重新训练并再次入库,为了避免重复存储或数据污染,最稳妥简单的做法是:
collection.delete(where={"source": SOURCE_NAME})
# 执行写入 (因为前面已经删干净了,这里直接用 add 即可)
collection.add(
documents=docs_texts,
metadatas=docs_metadatas,
ids=docs_ids
)
七、测试代码
在配套的 demo 工程中,可以直接获取到生成向量库的完整代码。执行以下命令即可:
python .lesson_09split_and_sa ve.py
切分、向量化、入库,一气呵成。
下一步预告
本节课完成了向量化和入库。下一节课将完成 RAG 最后一片拼图:查询!
敬请期待!
