本篇文章,聊聊福布斯全球网站前俩月发布的 2023 AI 50 榜单中的唯一一个开源的向量数据库:Weaviate。

它在数据持久化和容错性上表现非常好、支持混合搜索、支持水平扩展,同时又保持了轻量化。官方主打做 AI 时代的原生数据库,减少幻觉、数据泄漏和厂商绑定。

写在前面

本文相关的代码和配置,我开源在了 soulteary/weaviate-quickstart,有需要自取,欢迎“一键三连”。

言归正传,随着越来越多的传统数据库和搜索引擎对于向量数据检索能力的完善(MySQL、PostgreSQL、MongoDB、Elasticsearch、Redis、ClickHouse …),专用的向量数据库的能力要求被不断拔高,伴随着不时传来的“向量数据库已死”、“只是一个数据库功能”的观点,4 月 11 日福布斯全球网站发布的 2023 AI 50 榜单中只有两家做向量数据库的公司登陆了名单。

第一家,是一直以来在北美市场生态建设做的非常好的“松果”(Pinecone),产品做的非常好,拥有目前最强的产品生态和文档建设,CEO 为前 AWS & Yahoo 的研发总监,年初刚刚发布了 Serverless 版本的新产品。你所知道的:Notion、微软、Shopify、Klarna(欧洲“支付宝”)、ClickUp、HubSpot、Help Scout、TaskUS、Disco、Frontier、GONG、Sixfold、Godaddy、InpharmD、YCombinator 等等都是他们的客户。他们在去年“寒冬”中就募集了 1 亿美元,目前公司估值 7.5 亿美元。

另外一家,是和 Pinecone 同年建立公司,但是选择了开源商业化路线的 Weaviate,在 2023 年公司人数只有 65 人( Pinecone 一半)的情况下,保持非常高的发布和产品迭代频率,B 轮融资 5000 万美元,目前估值 2 亿美元。Pinecone 支持的 Serverless SaaS 服务, Weaviate 同样也是支持的。

不论是从以下哪个角度来看,它都值得我们来试着使用,尤其是你需要稳定不出错的私有化部署,并且不希望进行复杂的运维时

  • 公司的资金充裕程度,或许是垂类创业公司中最宽松的,不需要快速变现能够让产品的迭代质量和目标更有保障。
  • 开发交付能力较好,一年多的时间里,保持了每周都有新版本发布,接近 80 个版本,配合相对完善的自动化测试,让用户可以放心的跟着软件走下去。
  • 所有写操作会立即保存,并且可以容忍程序或系统崩溃,对象和向量都可以随意操作(增删改查)。同时,你也可以随意的备份和恢复数据,不需要担心冗长的数据重建或者业务停服问题。
  • 软件生态越来越完善,从 OpenAI(包括 Azure OAI)、Google AI、AWS、Mistral、Cohere、HuggingFace 等等在线头部厂商到本地化的 Transformers 或其他方式运行的模型,都能快速的集成接入。
  • 部署简单,维护也同样简单,从一台笔记本到一整个集群,都可以使用它,毕竟是“云原生设计”,本地容器化还是云端基于云厂商组件快速扩展都不是问题,毕竟头部厂商和头部云厂商的云市场里都有它的身影。

熟悉我的朋友知道我使用 Dify 有一段时间了,在 Dify 的官方支持中,默认支持的几种向量数据库,最简单的选择一定是 Weaviate(或许最好的选择是 Qdrant,最经济的是 PG Vector,我们下篇再聊)。

好了,接下来,让我们来快速上手非常简单,数据相对安全可靠,并且程序本身不失轻量的向量数据库吧。

准备工作

为了让更多的朋友能够快速上手和复现,本篇文章使用 Docker 来作为基础环境。

Docker 运行环境

想顺滑的完成实践,推荐你安装 Docker,不论你的设备是否有显卡,都可以根据自己的操作系统喜好,参考这两篇来完成基础环境的配置《基于 Docker 的深度学习环境:Windows 篇》、《基于 Docker 的深度学习环境:入门篇》。当然,使用 Docker 之后,你还可以做很多事情,比如:之前几十篇有关 Docker 的实践,在此就不赘述啦。

向量化模型(Embedding Model)

本篇文章中,我选择的是阿里通义实验室的 GTE 向量模型,你可以在 HuggingFace 或者 ModelScope 上下载到模型。

至于模型的下载,你可以选择使用《节省时间:AI 模型靠谱下载方案汇总》这篇文章中提到的下载方法,快速的获取本来就不是很大的模型权重文件。

当然,如果你只想验证下向量数据库,了解下 GTE 模型的,也可以选择更小尺寸的 GTE Small,相比较上面的大尺寸模型,精度并未下降太多(当然,有资源的情况下,还是建议跑大一些的模型,效果会更棒)。

在开源项目 soulteary/weaviate-quickstart 中,默认已经集成了身材不到 60M 的模型,你只需要下载开源项目的代码就好啦。

git clone https://github.com/soulteary/weaviate-quickstart.git
cd weaviate-quickstart

用于计算相似度的数据集

为了更好的演示语义检索,我从网上找了一份描述传统节气的内容,并整理为了一份简单的数据集:

[
  {
    "SolarTerms": "立春",
    "Title": "木兰花·立春日作",
    "Author": "陆游",
    "Poem": "春盘春酒年年好,试戴银旛判醉倒。今朝一岁大家添,不是人间偏我老。",
    "Description": "《月令七十二候集解》有载:“立,始建也。春气始而建立也。”在生生不息的春风中,一年的序幕由此开启。我们用一颗丰盈而善良的心,向着春意盎然的天地间走去。立春丨一候东风解冻,二候蜇虫始振,三候鱼陟负冰。"
  },
  {
    "SolarTerms": "雨水",
    "Title": "早春呈水部张十八员外",
    "Author": "韩愈",
    "Poem": "天街小雨润如酥,草色遥看近却无。最是一年春好处,绝胜烟柳满皇都。",
    "Description": "雨水是二十四节气之中的第二个节气,此时,气温回升、冰雪融化、降水增多,故取名为雨水。我们将要迎接的,不仅有温暖的晨曦,更是一场被东风吹来的,新的、充满生机的甘霖。雨水丨一候獭祭鱼;二候鸿雁来;三候草木萌动。"
  },
  {
    "SolarTerms": "惊蛰",
    "Title": "走笔谢孟谏议寄新茶",
    "Author": "卢仝",
    "Poem": "闻道新年入山里,蛰虫惊动春风起。天子须尝阳羡茶,百草不敢先开花。",
    "Description": "惊蛰,古称“启蛰”,预示着生命在这一刻重生。春的希望就在眼前,惊蛰过后,万物复苏,一派生机勃勃的景色。让我们且听风吟,且闻鸟语,邂逅最美的花开!惊蛰丨一候桃始华;二候仓庚(黄鹂)鸣;三候鹰化为鸠。"
  },
  {
    "SolarTerms": "春分",
    "Title": "春日",
    "Author": "朱熹",
    "Poem": "胜日寻芳泗水滨,无边光景一时新。等闲识得东风面,万紫千红总是春。",
    "Description": "春分,古时又称为“日中”、“仲春之月”。自此,进入春和日丽、万红千翠争媚时节。好花不常看,好景不常在,趁着风和日丽出门踏青,莫要辜负了好春光。春分丨一候元鸟至;二候雷乃发声;三候始电。"
  },
  {
    "SolarTerms": "清明",
    "Title": "清明",
    "Author": "杜牧",
    "Poem": "清明时节雨纷纷,路上行人欲断魂。借问酒家何处有,牧童遥指杏花村。",
    "Description": "清明是为春天而来,它将蓬勃的生机暗自酝酿。清明节,我们缅怀逝去的家人。清明节,我们思念远方的亲人。生活无遗憾,人世有牵挂!清明丨一候桐始华;二候田鼠化为鹌;三候虹始见。"
  },
  {
    "SolarTerms": "谷雨",
    "Title": "晚春田园杂兴",
    "Author": "范成大",
    "Poem": "谷雨如丝复似尘,煮瓶浮蜡正尝新。牡丹破萼樱桃熟,未许飞花减却春。",
    "Description": "雨我公田,雨其谷于水,播种时节到了。谷雨已至,愿你工作中一“谷”作气,事业高升;朋友间“谷”道热肠,人缘美好;生活中欢欣“谷”舞,快乐舒畅。谷雨丨一候萍始生;二候鸣鸠拂其羽;三候为戴任降于桑。"
  },
  {
    "SolarTerms": "立夏",
    "Title": "初夏",
    "Author": "朱淑真",
    "Poem": "竹摇清影罩幽窗,两两时禽噪夕阳。谢却海棠飞尽絮,困人天气日初长。",
    "Description": "立夏表示即将告别春天,是夏天的开始。立夏之美,在于希望。愿你所有的烦恼,都将随风而去,幸福的感觉似夏花弥漫!立夏丨一候蝼蝈鸣;二候蚯蚓出;三候王瓜生。"
  },
  {
    "SolarTerms": "小满",
    "Title": "自桃川至辰州绝句四十有二",
    "Author": "赵蕃",
    "Poem": "一春多雨慧当悭,今岁还防似去年。玉历检来知小满,又愁阴久碍蚕眠。",
    "Description": "小满,是一年中最有智慧的节气。小有所得,小有所望。我们的生活开始安稳顺遂,有沉静的增长,有从容的精进,小有知足,小有可期。小满丨一候苦菜秀;二候靡草死;三候麦秋至。"
  },
  {
    "SolarTerms": "芒种",
    "Title": "梅雨五绝(其二)",
    "Author": "范成大",
    "Poem": "乙酉甲申雷雨惊,乘除却贺芒种晴。插秧先插蚤籼稻,少忍数旬蒸米成。",
    "Description": "“芒种”也称为“忙种”“忙着种”,预示着要开始忙碌的田间生活。收获过后,又要种下新一轮,人们种下希望的同时,也种下了期待。仲夏至此始,未来皆可期!芒种丨一候螳螂生;二候鵙(jú)始鸣;三候反舌无声。"
  },
  {
    "SolarTerms": "夏至",
    "Title": "积雨辋川庄作",
    "Author": "王维",
    "Poem": "积雨空林烟火迟,蒸藜炊黍饷东菑。漠漠水田飞白鹭,阴阴夏木啭黄鹂。",
    "Description": "夏为大,至为极,万物到此壮大繁茂到极点、阳气也达到极致,所以是一年中夜最短、昼最长的一天。愿你在炎暑,有凉风袭人;愿你在夏日,有良人送爽;愿你向阳而立,拥抱无限生机!夏至丨一候鹿角解;二候蝉始鸣;三候半夏生。"
  },
  {
    "SolarTerms": "小暑",
    "Title": "赠别王侍御赴上都",
    "Author": "韩翃",
    "Poem": "相思掩泣复何如,公子门前人渐疏。幸有心期当小暑,葛衣纱帽望回车。",
    "Description": "小暑,表示季夏时节的正式开始。天气开始炎热,但还没到最热,故称“小暑”。小暑时节,心静自然凉。眼前无长物,窗下有清风。散热有心静,凉生为室空。小暑丨一候温风至;二候蟋蟀居宇;三候鹰始鸷。"
  },
  {
    "SolarTerms": "大暑",
    "Title": "鹧鸪天",
    "Author": "晁补之",
    "Poem": "吉梦灵蛇朱夏宜。佳辰阿母会瑶池。竹风荷雨来消暑,玉李冰瓜可疗饥。",
    "Description": "在骄阳的侵袭下,一年之中最热的时光,也就此拉开序幕。大暑时节,去见想见的人,去做想做的事,在前行的路上不断的遇见美好,遇见幸福,遇见心中期待的一切。大暑丨一候腐草为萤;二候土润溽暑;三候大雨时行。"
  },
  {
    "SolarTerms": "立秋",
    "Title": "城中晚夏思山",
    "Author": "齐己",
    "Poem": "葛衣沾汗功虽健,纸扇摇风力甚卑。苦热恨无行脚处,微凉喜到立秋时。",
    "Description": "立秋是秋季的第一个节气,意味着秋天真正开始了。秋天是思念的季节,思念在远方的亲人,思念外出未归的子女,思念相处一生的伴侣。愿我们能在这个收获的季节,让心灵充盈,满载而归!立秋丨一候凉风至;二候白露生;三候寒蝉鸣。"
  },
  {
    "SolarTerms": "处暑",
    "Title": "早秋山中作",
    "Author": "王维",
    "Poem": "草间蛩响临秋急,山里蝉声薄暮悲。寂寞柴门人不到,空林独与白云期。",
    "Description": "暑气将于这一天结束,秋意冉冉,从而开始一年之中最美好的秋高气爽的时节。白天要注意防暑,夜晚要记得添衣。暑去秋来,愿君务必珍重,秋日安康!处暑丨一候鹰乃祭鸟;二候天地始肃;三候禾乃登。"
  },
  {
    "SolarTerms": "白露",
    "Title": "金陵城西楼月下吟",
    "Author": "李白",
    "Poem": "金陵夜寂凉风发,独上高楼望吴越。白云映水摇空城,白露垂珠滴秋月。",
    "Description": "白露秋分夜,一夜凉一夜;一场秋雨一场凉,一场白露一场霜;白露白茫茫,谷子满田黄。成熟与收获,尽数来到眼前。沉重的汗水凝结成沉甸甸的果实,人们喜悦,感念,分享。白露丨一候鸿雁来;二候元鸟归;三候群鸟养羞。"
  },
  {
    "SolarTerms": "秋分",
    "Title": "秋词二首(其二)",
    "Author": "刘禹锡",
    "Poem": "山明水净夜来霜,数树深红出浅黄。试上高楼清入骨,岂如春色嗾人狂。",
    "Description": "秋分,天高云淡,气净风轻,是一个浪漫的节气。秋云飘逸,秋水如镜,处处迷人,令人陶醉。正是邀三五好友快意潇洒,观秋之华彩、赏世间美景的好时候。秋分丨一候雷始收声;二候蛰虫坯户;三候水始涸。"
  },
  {
    "SolarTerms": "寒露",
    "Title": "秋兴八首·其七",
    "Author": "杜甫",
    "Poem": "波漂菰米沈云黑,露冷莲房坠粉红。关塞极天唯鸟道,江湖满地一渔翁。",
    "Description": "《月令七十二候集解》中说:九月节,露气寒冷,将凝结也。又是一年寒露至,又是一年秋意浓。最美的秋在眼前,最美的风景在身边,用心珍惜方能长久!寒露丨一候鸿雁来宾;二候雀入大水为蛤;三候菊有黄华。"
  },
  {
    "SolarTerms": "霜降",
    "Title": "村夜",
    "Author": "白居易",
    "Poem": "霜草苍苍虫切切,村南村北行人绝。独出前门望野田,月明荞麦花如雪。",
    "Description": "从霜降当天起,暮秋已至天气凉,万物秋收冬藏。好柿成霜,喜从天降。活在当下,不留遗憾。岁岁年年,愿你安暖!霜降丨一候豺乃祭兽;二候草木黄落;三候蜇虫咸俯。"
  },
  {
    "SolarTerms": "立冬",
    "Title": "初冬夜饮",
    "Author": "杜牧",
    "Poem": "淮阳多病偶求欢,客袖侵霜与烛盘。砌下梨花一堆雪,明年谁此凭阑干?",
    "Description": "立冬,代表着冬季开始,万物收藏,规避寒冷。一岁年华,已近尾声。在今年的最后一季里,定要修身养心,持善悦己!立冬丨一候水始冰;二候地始冻;三候雉入大水为蜃。"
  },
  {
    "SolarTerms": "小雪",
    "Title": "小雪",
    "Author": "戴叔伦",
    "Poem": "花雪随风不厌看,更多还肯失林峦。愁人正在书窗下,一片飞来一片寒。",
    "Description": "小雪时雪还未盛,故称“小雪”。人生实苦,善待自己。在这个季节里,如果累了,就放松心情,再冷的天气,也要温暖自己的心!小雪丨一候虹藏不见;二候天气上升地气下降;三候闭塞而成冬。"
  },
  {
    "SolarTerms": "大雪",
    "Title": "北风行",
    "Author": "李白",
    "Poem": "燕山雪花大如席,片片吹落轩辕台。幽州思妇十二月,停歌罢笑双蛾摧。",
    "Description": "从这天起,大雪来临,寒冬已至。天雪鬃呼啸。待早上一睁眼,冰封的窗户就亮得耀目。风中闻草木,雪里见江山。想要记起和忘掉一切,只需这一场大雪。大雪丨一候鹃鸥不鸣;二候虎始交;三候荔挺出。"
  },
  {
    "SolarTerms": "冬至",
    "Title": "邯郸冬至夜思家",
    "Author": "白居易",
    "Poem": "邯郸驿里逢冬至,抱膝灯前影伴身。想得家中夜深坐,还应说着远行人。",
    "Description": "冬至,古称“至日”,“至”是极致的意思,冬藏之气至此而极。依着血缘的眷念,漂泊的游子都将踏上归家的旅途,冬至的温暖也安抚着每一个思归的心。冬至丨一候蚯蚓结;二候麋角解;三候水泉动。"
  },
  {
    "SolarTerms": "小寒",
    "Title": "冬夜寄温飞卿",
    "Author": "鱼玄机",
    "Poem": "苦思搜诗灯下吟,不眠长夜怕寒衾。满庭木叶愁风起,透幌纱窗惜月沉。",
    "Description": "小寒一过,就进入“出门冰上走”的三九天了。小寒,是一个充满希望的时节——此时旧岁近暮,新岁即将登场,坚毅的鸿雁已先开始启程北飞了。万物开始复苏,生机已然蠢动。小寒丨一候雁北乡,二候鹊始巢,三候雉始鸲。"
  },
  {
    "SolarTerms": "大寒",
    "Title": "寒夜",
    "Author": "杜耒",
    "Poem": "寒夜客来茶当酒,竹炉汤沸火初红。寻常一样窗前月,才有梅花便不同。",
    "Description": "大寒是这一年里的最后一个节气,也是一年中阴阳转换的重要时机。大寒过后,又是一年新的轮回。愿下个轮回,依旧有你相伴!大寒丨一候鸡乳;二候征鸟厉疾;三候水泽腹坚。"
  }
]

将上面的内容保存为 traditional-festival.json,数据集就准备就绪啦。

验证环境(可选)

虽然我们一般情况下,会将所有模型相关的部分都封装到容器环境中,尤其是 Python 相关的环境,让相关的程序在调用过程中的环境都更加纯粹、简单。

但是,考虑到很多同学并不会使用向量化模型(Embedding Model),所以这里我们增加一个简单的验证环境,来展示下如何使用向量化模型。

当然,即使不使用 Docker 作为 Python 运行环境,我们也可以使用诸如 Conda 等支持环境隔离的方案,来运行我们的程序代码。

你可以参考《在搭载 M1 及 M2 芯片 MacBook设备上玩 Stable Diffusion 模型》这篇文章中的“基础环境准备”部分,来完成 Conda 环境的配置和软件包镜像的配置,来快速的完成验证环境的准备。

当 Conda 就绪后,只需要执行下面的代码,就能够获得一个完全干净的环境啦:

conda create -n weaviate python=3.9 -y
conda activate weaviate

快速上手 AI 数据库的实践

快速上手非常简单,但是在这个过程中,我想相对详细、清晰的分享下一些了解后有助于你清晰的改进服务的技术细节。

快速上手 GTE 向量模型

为了快速上手模型,我们可以编写一段简单的程序,原始代码在 soulteary/weaviate-quickstart/usage1.py,注释比较详细就不展开赘述啦:

import torch.nn.functional as F
from transformers import AutoTokenizer, AutoModel

# 定义输入内容
input_texts = [
    "天气好热,哪里有卖冰棍的",
    "今天好冷,该多穿两件",
    "夏天",
    "冬天"
]

# 指定预训练模型,在线模型 "thenlper/gte-base-zh",此处使用本地目录中的模型
model_id = "./thenlper/gte-small"

# 加载分词器和模型
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModel.from_pretrained(model_id)

# 对输入内容进行分词、编码
batch_dict = tokenizer(input_texts, max_length=512, padding=True, truncation=True, return_tensors='pt')

# 获取 Embeddings
outputs = model(**batch_dict)
embeddings = outputs.last_hidden_state[:, 0]
 
# 标准化 embeddings,使用 L2 归一化,使其长度为 1
embeddings = F.normalize(embeddings, p=2, dim=1)
# 计算相似度,选择第一个元素,和除了第一个元素进行比较
scores = (embeddings[:1] @ embeddings[1:].T) * 100
# 打印结果分数
print(scores.tolist())

将上面的代码保存为 usage1.py,我们就可以准备运行模型啦。想要通过上面的代码运行模型,我们还需要安装两个基础的 Python 依赖:

pip install torch transformers

当依赖安装完毕,我们可以执行 python usage1.py,执行结果类似下面:

# python usage1.py
[[80.17919921875, 82.7370376586914, 84.4228286743164]]

如果你觉得上面的代码比较冗长,我们还可以使用 Sentence Transformer 的方式来运行模型:

from sentence_transformers import SentenceTransformer
from sentence_transformers.util import cos_sim

# 定义输入内容
sentences = [
    "天气好热,哪里有卖冰棍的",
    "今天好冷,该多穿两件",
    "夏天",
    "冬天"
]

# 指定预训练模型,在线模型 "thenlper/gte-base-zh",此处使用本地目录中的模型
model_id = "./thenlper/gte-small"

# 加载 SentenceTransformer 模型
model = SentenceTransformer(model_id)
# 获取向量
embeddings = model.encode(sentences)
# 计算第一个句子(index 0)和第二个句子(index 1)的嵌入向量之间的余弦相似度,并打印结果
print(cos_sim(embeddings[0], embeddings[1]))

将上面的代码保存为 usage2.py,然后我们完成必要的依赖安装:

pip install sentence_transformers

然后,执行 python usage2.py,我们就能够得到相似度结果了:

# python usage2.py
tensor([[0.8448]])

这段代码保存在 soulteary/weaviate-quickstart/usage2.py,有需要可以自取。

了解了通常情况下,我们如何使用向量模型,那么接下来,我们来看看如何使用 Weaviate。

使用 Docker 启动 Weaviate 和 AI 服务

Weaviate 和其他向量数据库产品或者用户量更大的支持向量功能的数据库不同的是,它的原生向量模块。

我们可以使用下面的配置,一键启动一个能够将我们输入的普通文本内容,自动转换为向量检索、相似度计算的向量数据库服务。

version: "3.4"
services:
  weaviate:
    command:
      - --host
      - 0.0.0.0
      - --port
      - "8080"
      - --scheme
      - http
    image: semitechnologies/weaviate:1.25.5
    ports:
      - 8086:8080
      - 50051:50051
    volumes:
      - ./weaviate_data:/var/lib/weaviate
    restart: on-failure:0
    environment:
      QUERY_DEFAULTS_LIMIT: 25
      # https://weaviate.io/developers/weaviate/configuration/authentication
      AUTHENTICATION_APIKEY_ENABLED: "true"
      AUTHENTICATION_APIKEY_ALLOWED_KEYS: "soulteary,password"
      AUTHENTICATION_APIKEY_USERS: "soulteary,user@lab.io"
      AUTHORIZATION_ADMINLIST_ENABLED: "true"
      AUTHORIZATION_ADMINLIST_USERS: "soulteary,user@lab.io"
      PERSISTENCE_DATA_PATH: "/var/lib/weaviate"
      CLUSTER_HOSTNAME: "node1"
      ENABLE_MODULES: "text2vec-transformers"
      # Support enabled modules, ENABLE_MODULES: "text2vec-cohere,text2vec-huggingface,text2vec-palm,text2vec-openai,generative-openai,generative-cohere,generative-palm,ref2vec-centroid,reranker-cohere,qna-openai"
      # Only store vectors, DEFAULT_VECTORIZER_MODULE: "none"
      DEFAULT_VECTORIZER_MODULE: "text2vec-transformers"
      TRANSFORMERS_INFERENCE_API: "http://t2v-transformers:8080"

  t2v-transformers:
    image: soulteary/t2v-transformers:2024.06.27
    environment:
      # set to 1 to enable
      ENABLE_CUDA: 0
    ports:
      - 9090:8080

上面的配置,就是 Weaviate 的全部配置啦,将上面的配置保存为 docker-compose.yml 之后,我们可以使用 docker compose up -d 启动这个服务。

接着,我们就能够使用各种各样的 “Weaviate SDK”,在各种传统的程序中,启用“向量检索”能力啦。

编写一个简单的向量检索程序

你可以选择 Python、JavaScript(TS)、Java、Go、PHP、Ruby、C# 等方式来完成 Weaviate 的调用。

本篇文章,我选择简单又高效的 Golang,从初始化 Weaviate 客户端实例到创建向量数据索引,再到使用我们的查询内容去查找数据库中最相近的内容,完整程序大概只需要 150 行:

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"time"

	"github.com/weaviate/weaviate-go-client/v4/weaviate"
	"github.com/weaviate/weaviate-go-client/v4/weaviate/auth"
	"github.com/weaviate/weaviate-go-client/v4/weaviate/graphql"
	"github.com/weaviate/weaviate/entities/models"
)

func init() {
	os.Setenv("WEAVIATE_INSTANCE_URL", "localhost:8086")
	os.Setenv("WEAVIATE_SCHEME", "http")
	os.Setenv("WEAVIATE_API_KEY", "soulteary")
}

func CreateClient(host string, scheme string, key string) (*weaviate.Client, error) {
	cfg := weaviate.Config{
		Host:       host,
		Scheme:     scheme,
		AuthConfig: auth.ApiKey{Value: key},
	}

	client, err := weaviate.NewClient(cfg)
	if err != nil {
		return nil, err
	}

	return client, nil
}

func CreateDB(client *weaviate.Client) error {
	classObj := &models.Class{
		Class:      "TraditionalFestival",
		Vectorizer: "text2vec-transformers",
	}

	// add the schema
	err := client.Schema().ClassCreator().WithClass(classObj).Do(context.Background())
	if err != nil {
		return err
	}

	buf, err := os.ReadFile("./traditional-festival.json")
	if err != nil {
		return err
	}

	var items []map[string]string
	err = json.Unmarshal(buf, &items)
	if err != nil {
		return err
	}

	objects := make([]*models.Object, len(items))
	for i := range items {
		objects[i] = &models.Object{
			Class: "TraditionalFestival",
			Properties: map[string]any{
				"SolarTerms":  items[i]["SolarTerms"],
				"Title":       items[i]["Title"],
				"Author":      items[i]["Author"],
				"Poem":        items[i]["Poem"],
				"Description": items[i]["Description"],
			},
		}
	}

	batchRes, err := client.Batch().ObjectsBatcher().WithObjects(objects...).Do(context.Background())
	if err != nil {
		return err
	}
	for _, res := range batchRes {
		if res.Result.Errors != nil {
			return fmt.Errorf("error: %v", res.Result.Errors)
		}
	}

	return nil
}

func Query(client *weaviate.Client, queries []string) error {
	st := time.Now()

	fields := []graphql.Field{
		{Name: "solarTerms"},
		{Name: "title"},
		{Name: "author"},
		{Name: "poem"},
		{Name: "description"},
	}

	nearText := client.GraphQL().
		NearTextArgBuilder().
		WithConcepts(queries)

	result, err := client.GraphQL().Get().
		WithClassName("TraditionalFestival").
		WithFields(fields...).
		WithNearText(nearText).
		WithLimit(2).
		Do(context.Background())
	if err != nil {
		return err
	}

	fmt.Println()
	fmt.Println("Time:", time.Since(st))
	fmt.Println()

	buf, err := json.Marshal(result)
	if err != nil {
		return err
	}

	fmt.Printf("%v", string(buf))

	return nil
}

func main() {
	hostURL := os.Getenv("WEAVIATE_INSTANCE_URL")
	scheme := os.Getenv("WEAVIATE_SCHEME")
	apikey := os.Getenv("WEAVIATE_API_KEY")

	// create a client
	client, err := CreateClient(hostURL, scheme, apikey)
	if err != nil {
		panic(err)
	}

	// try to create a database
	err = CreateDB(client)
	if err != nil {
		fmt.Println("CreateDB", err)
	}

	// try to query
	queries := []string{"夏天吃瓜,冰棍也行吧"}
	fmt.Println("Queries", queries)

	err = Query(client, queries)
	if err != nil {
		fmt.Println("Query", err)
	}
}

上面的这段代码,同样保存在了项目中 soulteary/weaviate-quickstart/main.go,你可以自取。我们可以先进入本文提供的开源项目的根目录,并完成 Go 的依赖下载,两条命令即可(如果你不熟悉 Go,可以阅读《搭建可维护的 Golang 开发环境》这篇文章,快速初始化可维护的环境):

# cd weaviate-quickstart
# go mod tidy

go: downloading gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c
go: downloading github.com/kr/pretty v0.3.1
go: downloading github.com/rogpeppe/go-internal v1.11.0
go: downloading github.com/kr/text v0.2.0

接着,执行 go run main.go 运行程序,程序将自动创建向量数据的索引,并查询我们输入的内容,获取最相近的计算结果,最终的输出类似下面这样:

Queries [夏天吃瓜,冰棍也行吧]

Time: 13.65638ms

{"data":{"Get":{"TraditionalFestival":[{"author":"杜牧","description":"立冬,代表着冬季开始,万物收藏,规避寒冷。一岁年华,已近尾声。在今年的最后一季里,定要修身养心,持善悦己!立冬丨一候水始冰;二候地始冻;三候雉入大水为蜃。","poem":"淮阳多病偶求欢,客袖侵霜与烛盘。砌下梨花一堆雪,明年谁此凭阑干?","solarTerms":"立冬","title":"初冬夜饮"},{"author":"韩翃","description":"小暑,表示季夏时节的正式开始。天气开始炎热,但还没到最热,故称“小暑”。小暑时节,心静自然凉。眼前无长物,窗下有清风。散热有心静,凉生为室空。小暑丨一候温风至;二候蟋蟀居宇;三候鹰始鸷。","poem":"相思掩泣复何如,公子门前人渐疏。幸有心期当小暑,葛衣纱帽望回车。","solarTerms":"小暑","title":"赠别王侍御赴上都"}]}}}

是不是简单又有效呢?让我们来多了解几个简单的细节。

技术细节

在这里,再和大家展开聊几个技术细节吧。

封装一个 T2V Transformer 服务

在上面的 Docker 配置中,我们能够看到一个我定义的向量转换服务。

...
  t2v-transformers:
    image: soulteary/t2v-transformers:2024.06.27
    environment:
      # set to 1 to enable
      ENABLE_CUDA: 0
    ports:
      - 9090:8080

这个 Docker 服务中的 soulteary/t2v-transformers:2024.06.27,包含了我们上文中提到的通义实验室团队的 GTE 模型。将 GTE 模型转换为一个可以被向量数据库调用的服务,其实也很简单。

Dockerfile 中的内容是这样的,我们只需要将 HuggingFace 或 ModelScope 中下载的模型,在构建服务的时候,扔到 /app/models/model 中即可:

FROM semitechnologies/transformers-inference:custom

COPY ./thenlper/gte-small /app/models/model

然后,执行命令完成镜像的构建:

docker build -t soulteary/t2v-transformers:2024.06.27 .

最后,将服务的访问地址和启用模块名称,配置在 weaviate 容器服务的环境变量中即可。

version: "3.4"
services:
  weaviate:
    # 省略 ...
    environment:
      # 省略 ...
      CLUSTER_HOSTNAME: "node1"
      ENABLE_MODULES: "text2vec-transformers"
      DEFAULT_VECTORIZER_MODULE: "text2vec-transformers"
      TRANSFORMERS_INFERENCE_API: "http://t2v-transformers:8080"

  t2v-transformers:
    image: soulteary/t2v-transformers:2024.06.27
    environment:
      # set to 1 to enable
      ENABLE_CUDA: 0
    ports:
      - 9090:8080

快速建立“结构化”的非结构化数据向量索引

想要搜索又快又好,或者完成向量和非向量的数据搜索,建立数据的向量索引过程就非常关键。

在上面的代码中,有一段代码是关键:

	objects := make([]*models.Object, len(items))
	for i := range items {
		objects[i] = &models.Object{
			Class: "TraditionalFestival",
			Properties: map[string]any{
				"SolarTerms":  items[i]["SolarTerms"],
				"Title":       items[i]["Title"],
				"Author":      items[i]["Author"],
				"Poem":        items[i]["Poem"],
				"Description": items[i]["Description"],
			},
		}
	}

	batchRes, err := client.Batch().ObjectsBatcher().WithObjects(objects...).Do(context.Background())

在上面的代码中,我们将下面的数据按照“字段”进行了数据入库:

[
  {
    "SolarTerms": "立春",
    "Title": "木兰花·立春日作",
    "Author": "陆游",
    "Poem": "春盘春酒年年好,试戴银旛判醉倒。今朝一岁大家添,不是人间偏我老。",
    "Description": "《月令七十二候集解》有载:“立,始建也。春气始而建立也。”在生生不息的春风中,一年的序幕由此开启。我们用一颗丰盈而善良的心,向着春意盎然的天地间走去。立春丨一候东风解冻,二候蜇虫始振,三候鱼陟负冰。"
  },
...
]

所以,除了像本文一样的查找之外,我们还可以在查询的过程中,使用其他字段内容进行数据聚合、过滤,或者排序等等,比如上面的搜索结果,我只想要作者“陆游”的结果,之前的代码可以改成这样:

where := filters.Where().
	WithPath([]string{"author"}).
	WithOperator(filters.Equal).
	WithValueString("陆游")

result, err := client.GraphQL().Get().
	WithClassName("TraditionalFestival").
	WithFields(fields...).
	WithNearText(nearText).
	WithLimit(2).
	WithWhere(where).
	Do(context.Background())

再次执行代码,我们就可以得到过滤后的结果啦:

# go run main.go
Queries [夏天吃瓜,冰棍也行吧]

Time: 16.928206ms

{"data":{"Get":{"TraditionalFestival":[{"author":"陆游","description":"《月令七十二候集解》有载:“立,始建也。春气始而建立也。”在生生不息的春风中,一年的序幕由此开启。我们用一颗丰盈而善良的心,向着春意盎然的天地间走去。立春丨一候东风解冻,二候蜇虫始振,三候鱼陟负冰。","poem":"春盘春酒年年好,试戴银旛判醉倒。今朝一岁大家添,不是人间偏我老。","solarTerms":"立春","title":"木兰花·立春日作"}]}}}%        

向量数据落地存储的膨胀问题

默认情况下,我们启动 weaviate 数据库后,一个空的数据库目录大概是 112KB 左右。

du -hs weaviate_data                       
112K	weaviate_data

而本文中,我们使用和建立向量索引的数据是 12KB。

du -hs traditional-festival.json 
12K	traditional-festival.json

如果我们完成向量数据的索引,之前的数据库目录将膨胀至 556KB :

du -hs weaviate_data
556K	weaviate_data

增长的数据量相比原始数据,膨胀了 37 倍之多,在做内容的向量索引的时候,我们需要进行数据的容量预估。(目前 Weaviate 使用的量化方案是 PQ、BQ)

当然,解决这个问题的方法有很多,包括使用低维度一些的模型(本文使用的是 512 维的模型),或者不要对所有的内容都建立索引(本文对五个字段都进行了索引)。

以及,Weaviate 其实会自动检测我们的数据量的多少,并在合适的数据量量级下自动切换向量索引的方式为更节约资源的 HNSW。如果我们的吞吐量比较大,还可以使用水平扩展的方式,来完成容量和吞吐能力的提升。

最后

好了,这篇文章就先写到这里,下一篇类似的话题里,或许我们展开聊聊复杂一些的多路召回和相对准确的排序、打分策略。也或许聊聊赞誉度同样很高的其他的国产模型。

说起来这个月好忙,这篇文章已经拖了一个月多了,才整理成文,希望下个月可以更从容些。

–EOF