2024年4月

学习Altium Designer

Altium Designer的工程文件后缀为.PrjPcb,主要包含Source Documents和Libraries。Source Documents里面有SchDoc文件,即原理图文件;PcbDoc文件,即PCB文件。Libraries即库,包含原理图库和pcb库,原理图库里面是component,可以自行绘制,在properties可以添加footprint,将原理图库和pcb库一一对应起来。


image-20240419212305-d9j7lm3

一、绘制原理图库

需要注意的点:

  • 学会使用Panels面板,在原理图库界面的Panels比较有用的是SCH Library一项,通常将其放在左边界面,可以在此界面添加新的器件原理图。


    image-20240419212728-fl5hq9f

  • 这个是工具面板,常用的大概就是放置管脚、放置线、放置文本。


    image-20240419213044-o0t9c01

  • 器件原理图下面有footprint界面,可以在此添加footprint,右下角是footprint的3D模型。

    image-20240419212916-2iiocb4

  • 在进行
    多管脚芯片绘制
    的时候,可以使用编辑中的阵列式粘贴功能,复制多个管脚。不建议挨个进行管脚的命名,可以再excel表格中先对每个管脚从1~n号依次进行命名,然后在原理图中选中所有的管脚(可用查找相似对象功能,下面有讲),点开Panels面板中的SCH List,在第一个管脚的名称位置可以将excel表格中的所有名字直接复制粘贴过来,完成统一命名。

二、绘制原理图

  • 从Panels界面的Components可以找到自己创建的原理图库,选择自己需要的器件进行绘制schematic即可。


    image-20240419213406-w9r69to

  • 原理图界面的工具栏如图,比较常用的是连线(在放置线下有网络标签一项)、放置电源地、防止文本、画线等等。


    image-20240419213627-1runs0f

  • 多使用右键的查找相似对象,找到后按下
    ctrl+A
    可进行全选,再统一修改相关器件的参数。

  • 想让连线高亮,可按下
    Alt+目标连线
    ,可迅速找到该连线与其他器件的连接情况。

  • 多利用工具栏,里面有相当多有用的工具,比如原理图标注,当摆放好所有的器件之后,可以在原理图标志中进行一键标注,再也不用挨个对元器件进行标号了。


    image-20240419214232-lpazm3e

  • “工具”中的
    从库中更新
    可以在改变原理图库后,直接对schematic上的器件进行更新,非常方便。

  • “工具”中的
    参数管理器
    可以配合查找相似对象使用。


    image-20240419214547-gmpueh0

  • “工具”中的
    封装管理器
    可以快速修改元器件的封装。

  • 绘制完schematic后,在菜单栏工程处找到“Validate PCB……”可以进行
    错误检查
    。在Panels面板找到“Message”可以看到相关的error和warning。


    image-20240422164322-jxn4x4y

三、绘制PCB库

PCB库和原理图库类似。

  • 器件PCB的绘制最好都是关于中心对称,因此参考点可以设置在中心位置。


    image-20240422155411-k5gt33q

  • 按下L键可以打开层面板,通常使用到的就只有Top Layer和Bottom Layer,丝印层即Overlay也可以加上。


    image-20240422166666643-7cvnetv

  • 焊盘在工具栏可以进行放置,点击后按下tab键进行各种设置,一般需要设置的有形状和大小以及涉及到的layer。


    image-20240422155706-40z6pcc


    image-20240422155737-ai9m99t

  • 在菜单栏工具处可以找到元器件向导(即封装向导),在这里可以进行相关封装的创建。


    image-20240422155956-usbjl0d


    image-20240422160022-ozm3ukj


    image-20240422160039-kgfia6e

  • 绘制PCB时按下“Q”键可以进行mil和mm单位的切换,可在左下角看到相关的具体坐标形式和格点大小。

  • 绘制完PCB库之后需要跟原理图库的元器件相互对应上,给每一个元器件选择对应的footprint。

四、绘制PCB

需要新建PcbDoc类型文件并保存,否则无法进行绘制。

  • 将原理图schematic和pcb图进行关联。

    方法一:在SchDoc的文件下点击菜单栏的
    “设计”-->“Update PCB docment……”

    方法二:在PcbDoc文件下点击菜单栏的
    “设计”-->“Import changes from……”

    进行关联后,可以在黑色幕布旁边看到所有相关的器件的封装。

  • 多使用过滤选项,在布局阶段只需要选中
    components
    ,布线阶段选择
    Tracks、Pads、Vias
    ,铺铜阶段选择
    Polygons
    ,根据不同阶段的需求可以灵活选择。


    image-20240422161327-5kbcd0j

  • 在布局阶段,白色连接线很碍事,因此可以按下“N”键,选择隐藏全部连线。有需要的区域,比如供电区域,可以把这部分器件的连线先显示出来进行连接。


    image-20240422161610-1sm0rgg

  • 布局完成之后,可用线条框出一个矩形区域进行PCB板的设计。按住shift选中所有线条,点击“设计”-->“根据板子形状”-->“按照选择对象定义”,即可将“黑幕”按照框选的区域进行“切割”。


    image-20240422164742-uyi3opr

  • 在布线阶段,需要进行规则设置,在菜单栏中设计找到“规则”。搜索width,可进行线宽的相关设置。进行完规则设置后更方便进行连接。通常信号线线宽用10mil,电源连线用15mil。总之,要保证所用到的线宽在最小和最大线宽之间。


    image-20240422162528-rz5nkpd

  • 在布线时按下
    数字键1
    可以进行直线和折线的切换,或者说时实线和虚线的切换。
    数字键2
    是增加过孔,
    数字键3
    是在预设的几个线宽之间相互切换。

    布线需要45°角和135°角。

  • 设置过孔的大小:在规则中搜索“RoutingVias”,可以进行过孔的相关规则设置。


    image-20240422164210-p37ges3

  • 完成PCB的绘制后,在“工具”中找到设计规则检查,点击运行DRC,进行相关的错误检查。


    image-20240422165106-tu7lhbr

  • 最后进行铺泪滴,铺铜,铺缝合孔。


    1. 在“工具”中找到“泪滴”,选择强制铺泪滴。​


      image-20240422165237-mh3lno0

    2. 在工具栏找到铺铜,绘制区域(同样需要45°或135°走线)进行铺铜,在properties界面进行相关的设置(layer和net),通常需要勾选“移除孤岛”(Remove Islands)、“移除死区”(Remove Dead Copper)。
      每次更改之后都需要repour重铺。
      在“工具”处找到“铺铜”-->“铺铜管理器”,是非常好用的工具,当铺铜层叠冲突时,需要
      调整铺铜顺序
      。​


      image-20240422165331-md009y0
      ​​


      image-20240422165518-th8juqv

    3. 在“工具”中找到“给网络添加缝合孔”,可以在“约束区域”进行缝合孔的添加。需要设置的有过孔尺寸、直径和两个过孔间距。​


      image-20240422165915-m838uui
      ​​​


      image-20240422165943-nyfg7sy

  • 进行完上述操作之后还需要再次进行DRC检测确保没有问题。

五、导出相关打板文件

通常需要gerber文件、NC钻孔文件、坐标文件,导出后均在“Project Output for xxxxx”文件夹中。

  • gerber files

    “文件”-->“制造输出”-->“Gerber Files”。

    “通用栏”选择“英寸”和“2:5”。

    “层”除了keep-out layer之外全选。

  • NC钻孔文件

    “文件”-->“制造输出”-->“NC Drill Files”

    设置如图。


    image-20240422171045-2hw5jer

  • 坐标文件

    “文件”-->“装配输出”-->“Generates pick and place files”。

    默认设置确定即可。


    image-20240422171126-ca9b0y4

完成上述步骤就可以交给厂家打板了!

注:本文为笔者的学习笔记,为个人学习复习所使用,水平有限,如有错误请谅解。

FastWiki一分钟本地离线部署本地企业级人工智能客服

介绍

FastWiki是一个开源的企业级人工智能客服系统,它使用了一系列先进的技术和框架来支持其功能。

技术栈

  • 前端框架:React + LobeUI + TypeScript
  • 后端框架:MasaFramework 基于 .NET 8
  • 动态函数:基于JavaScript V8引擎实现
  • 向量搜索引擎:使用PostgreSQL的向量插件,优化搜索性能 | 简单版本支持磁盘向量
  • 深度学习与NLP:微软Semantic Kernel,提升搜索的语义理解能力
  • 许可证:Apache-2.0,鼓励社区贡献和使用

特点

  • 智能搜索:借助Semantic Kernel的深度学习和自然语言处理技术,能够理解复杂查询,提供精准的搜索结果。
  • 高性能:通过PostgreSQL的向量插件优化向量搜索性能,确保即使在大数据量下也能快速响应。
  • 现代化前端:使用React + LobeUI前端框架,提供响应式设计和用户友好的界面。
  • 强大的后端:基于最新的.NET 8和MasaFramework,确保了代码的高效性和可维护性。
  • 开源和社区驱动:采用Apache-2.0许可证,鼓励开发者和企业使用和贡献。
  • 动态JavaScript函数:提供Monaco智能代码提示,使开发更方便。
  • 强大的QA问答拆分模式:让知识库回复更智能。

FastWiki的部署过程已经被极大地简化,只需运行FastWiki服务即可,无需数据库。

对于
FastWiki
,我们不段的更新和优化,现在的版本越来越稳定,功能也更丰富,目前我们又简化了
FastWiki
的部署成本, 您无需数据库即可部署,只需要运行我们的
FastWiki
服务!

创建Docker指令

下面我们创建我们的
FastWiki
的指令,只需要一行代码即可运行。

docker run -d --name fast-wiki-service --user root --restart always \
  -p 8080:8080 \
  -v $(pwd)/wwwroot/uploads:/app/wwwroot/uploads \
  -v $(pwd)/data:/app/data \
  -e OPENAI_CHAT_ENDPOINT=https://api.token-ai.cn/ \
  -e OPENAI_CHAT_EMBEDDING_ENDPOINT=https://api.token-ai.cn/ \
  -e OPENAI_CHAT_TOKEN=您的TokenKey \
  -e ASPNETCORE_ENVIRONMENT=Development \
  registry.cn-shenzhen.aliyuncs.com/fast-wiki/fast-wiki-service

在这里我们需要注意俩个点,第一个您的AI模型地址需要修改,您的
AIToken
也需要修改,确保修改完成,替换参数以后再执行。

运行完成以后我们访问一下容器的端口 如果你是再本地运行的则访问
localhost:8080

点击立即开始

登录系统,系统默认账号:admin 默认密码 Aa123456

登录成功后还会返回页面,再次点击立即开始,点击新增,然后输入您创建的应用名称。

然后点击左边菜单的知识库,然后上传头像,设置我们的模型 (这个模型是用于QA问答解析的时候用到的),设置我们的嵌入模型(嵌入模型是我们用于量化文档的模型)创建完成以后进入知识库,然后点击上传文件。

点击上传文件,

然后吧我们的FastWiki上传上去:

然后点击提交数据:

然后返回到知识库详情:我们看到我们的文档已经量化完成

然后回到应用中然后绑定一下我们刚刚创建的知识库,然后点击保存即可。然后点击左边菜单的对话

提问内容:FastWiki有哪些技术栈?回复效果;

这些内容基本上就是我们的文档的内容!

知识库非常的详细的回复出来了。如果FastWiki对你有帮助的话帮忙再GitHub给一个Star,就是给我们最大的支持!!!

## 技术交流

Github:
https://github.com/AIDotNet/fast-wiki

Gitee
https://gitee.com/hejiale010426/fast-wiki

1.简介

这个系列的文章也讲解和分享了差不多三分之一吧,突然有小伙伴或者童鞋们问道playwright有没有截图的方法。答案当然是:肯定有的。宏哥回过头来看看确实这个非常基础的知识点还没有讲解和分享。那么在这个契机下就把它插队分享和讲解一下。Playwright提供了一个截屏的API:page.screenshot。使用该API,只需要指定截图的图片的保存路径及文件名即可。如果仅指定文件名,默认保存在当前目录。

2.截图语法

截图介绍官方API的文档地址:
https://playwright.dev/python/docs/screenshots

2.1截图参数

screenshot方法可以进行截图,参数如下:

timeout:以毫秒为单位的超时时间,0为禁用超时

path:设置截图的路径

type:图片类型,默认jpg

quality:像素,不适用于jpg

omit_background: 隐藏默认白色背景,并允许捕获具有透明度的屏幕截图。不适用于“jpeg”图像。

full_page:如果为true,则获取完整可滚动页面的屏幕截图,而不是当前可见的视口。默认为

`假`。

clip:指定结果图像剪裁的对象clip={'x': 10 , 'y': 10, 'width': 10, 'height': 10}

3.按照元素截图(截取页面一部分)

有时候,我们可能只想截取页面的一部分,那么,Playwright也支持将想要截取的部分筛选出来,然后调用截图API进行截图。参数同上,只是调用截图方法的对象不同,快速截图是page,按照元素截图是page下的元素,有时截取单个元素的屏幕截图很有用。语法如下:

page.locator(".header").screenshot(path="screenshot.png")

3.1代码设计

使用示例,截图百度页面的form 表单输入框和搜索按钮,如下图所示:

3.2参考代码


# coding=utf-8

本文介绍基于
Python
语言中
ArcPy
模块,实现
ArcMap
自动
批量出图
,并对
地图要素
进行自定义批量设置的方法。

1 任务需求

首先,我们来明确一下本文所需实现的需求。

现有通过
Python基于Excel数据加以反距离加权空间插值并掩膜图层
所绘制的北京市在2019年05月18日00时至23时(其中不含19时)等23个
逐小时PM2.5浓度插值数据栅格图层
,每小时一个图层,因此共23个图层;以当日10时为例,该时刻的栅格图层如下所示。

image

我们希望做到的有两点。首先,我们可以看到前述23个
栅格图层
的符号系统都为灰度拉伸的状态,因此希望按照一个给定的
模板图层
文件
m.lyr
,调整这23个
栅格图层
的样式(即拉伸的颜色),并分别以
.lyr
格式导出这23个栅格图层文件;且希望导出图层文件的文件名包含具体的时刻。如下图所示。

第二点希望做到的是,将每一个栅格图层都设置为彩色后,添加图名、指北针、比例尺等地图要素,并导出为图片格式。以当日10时、20时为例,我们所希望导出的图片如下所示。

且希望导出图片的文件名同样包含具体的时刻。

2 代码实现

了解了需求后,我们就基于
Python
中的
ArcPy
模块,进行详细代码的撰写与介绍。

这里需要说明的是:在编写代码的时候,为了方便执行,所以希望代码后期可以在
ArcMap
中直接通过工具箱运行,即用到
Python程序脚本新建工具箱与自定义工具
的方法;因此,代码中对于一些需要初始定义的变量,都用到了
arcpy.GetParameterAsText()
函数。大家如果只是希望在
IDLE
中运行代码,那么直接对这些变量进行具体赋值即可。关于
Python程序脚本新建工具箱与自定义工具
,大家可以查看
ArcMap将Python写的代码转为工具箱与自定义工具
详细了解。

上面提到需要初始定义的变量一共有七个,其中
arcpy.env.workspace
参数表示当前工作空间;
mxd_file
参数表示后期批量出图时,提供地图要素参考信息的地图文档
.mxd
文件;
lyr_file
参数表示后期批量出图时,提供地图着色参考信息的
模板图层
.lyr
文件;
mask_path
参数表示前述插值栅格图层所保存的路径;
new_lyr_path
参数表示插值栅格图层经过样式修改,并转为图层文件后的保存路径;
png_path
参数表示最终出图结果的保存路径;
dpi
参数表示最终出图结果的图像分辨率,单位为
DPI
(Dots per Inch)。

其中,上述第二个参数,即
提供地图要素参考信息的地图文档
.mxd
文件
需要由用户自行创建,并在其中配置好图名、图例、指北针、比例尺等地图要素的名称、文本、位置、样式等信息。或许这么说有点不清楚,大家看下面这幅图就能比较容易明白了。

没错,这个
提供地图要素参考信息的地图文档
.mxd
文件
其实就是一个在
Layout View
中设置好各种地图要素位置、大小、字体、颜色等的地图文档文件;它就相当于是一个模板,这个模板里各种地图要素长什么样子,后期我们批量出图结果图的地图要素就长什么样子。

此外,不知道为什么,在我的
ArcMap
中似乎偶尔会出现无法有效执行
lyr.visible=False

arcpy.mapping.RemoveLayer(data_frame,new_lyr[0])
等代码情况;因此若直接在上述地图文档文件中配置图例,最终出图结果有时会出现多个图例堆叠,不能保证出图结果百分之百完美。基于此,选择将图例格式元素(
elm.name==”title”
)转换为由一个图片格式元素(
elm.name==”pic”
)与两个文本格式元素(
elm.name==”text”
)组成的新元素,从而实现最终结果图中图例的绘制。

如果大家还是不明白,可以直接下载我的这一
.mxd
文件;下载链接:
https://pan.baidu.com/s/18l0l-kjPfdjV1UYcpkKg-w?pwd=fkxx

具体代码如下。

# -*- coding: utf-8 -*-
# @author: ChuTianjia

import arcpy

arcpy.env.workspace=arcpy.GetParameterAsText(0)
mxd_file=arcpy.GetParameterAsText(1)
lyr_file=arcpy.GetParameterAsText(2)
mask_path=arcpy.GetParameterAsText(3)
new_lyr_path=arcpy.GetParameterAsText(4)
png_path=arcpy.GetParameterAsText(5)
dpi=arcpy.GetParameterAsText(6)

my_mxd=arcpy.mapping.MapDocument(mxd_file)
data_frame=arcpy.mapping.ListDataFrames(my_mxd)[0]
my_lyr=arcpy.mapping.Layer(lyr_file)
layer_list=arcpy.mapping.ListLayers(my_mxd)

my_mxd.activeView="PAGE_LAYOUT"

tif_file_list=arcpy.ListRasters("BJ_hour_*","TIF")
for raster in tif_file_list:
    # Import the mask layer into ArcMap
    raster_file=mask_path+"\\"+raster
    arcpy.MakeRasterLayer_management(raster_file,raster.strip(".tif"))

    # Modify the style of the mask layer according to the reference layer
    arcpy.ApplySymbologyFromLayer_management(raster.strip(".tif"),lyr_file)
    new_lyr_file=new_lyr_path+"\\"+raster.strip(".tif")+".lyr"

    # Save and import the mask layer after modifying the style
    arcpy.SaveToLayerFile_management(raster.strip(".tif"),new_lyr_file)
    arcpy.AddMessage("{0} has been saved.".format(raster.strip(".tif")+".lyr"))
    
    new_lyr=arcpy.mapping.Layer(new_lyr_file)
    arcpy.mapping.AddLayer(data_frame,new_lyr,"TOP")

    # Modify the image name
    for element in arcpy.mapping.ListLayoutElements(my_mxd,"TEXT_ELEMENT"):
        if element.name=="title":
            element.text="Interpolation Map of PM2.5 Concentration\n at {0}:00 on May 18, 2019, Beijing".format(raster[8:10])

    new_lyr.visible=True

    # Modify the legend (see the program usage document for details)
    max_pixel=arcpy.GetRasterProperties_management(new_lyr,"MAXIMUM").getOutput(0)[0:5]
    min_pixel=arcpy.GetRasterProperties_management(new_lyr,"MINIMUM").getOutput(0)[0:5]
    for element in arcpy.mapping.ListLayoutElements(my_mxd,"TEXT_ELEMENT"):
        if element.name=="MAX":
            element.text="{:0>5.2f}".format(float(max_pixel))
        if element.name=="MIN":
            element.text="{:0>5.2f}".format(float(min_pixel))

    # Export to picture format
    png_file=png_path+"\\"+raster.strip(".tif")+".png"
    arcpy.mapping.ExportToPNG(my_mxd,png_file,resolution=dpi)
    arcpy.AddMessage("{0} has been saved.".format(raster.strip(".tif")+".png"))
    
    new_lyr.visible=False
    arcpy.mapping.RemoveLayer(data_frame,new_lyr[0])

3 运行结果

执行上述代码,具体得到的结果其实在本文开头也就和大家讲了,这里就不再赘述。

不过还有一点,就是如果大家是在
ArcMap
中直接通过工具箱运行上述代码,则可以看到代码运行过程中出现的提示——程序运行过程中,对每一个时刻的PM2.5浓度数据分别完成图层格式保存与图片格式保存等2个操作后,均会输出执行结果,方便用户获知程序的执行情况。

至此,大功告成。

前面我们通过两篇文章:
BGE M3-Embedding 模型介绍

Sparse稀疏检索介绍与实践
介绍了sparse 稀疏检索,今天我们来看看如何建立一个工程化的系统来实现sparse vec的检索。

之前提过milvus最新的V2.4支持sparse检索,我们先看看milvus的实现。

milvus的sparse检索实现

milvus 检索底层引擎是knowhere,主要代码在
src/index/sparse
里。

首先,通过数据结构SparseRow,用于表示稀疏向量,支持浮点数(float)类型的数据

class SparseRow {
    static_assert(std::is_same_v<T, fp32>, "SparseRow supports float only");

 public:
    // construct an SparseRow with memory allocated to hold `count` elements.
    SparseRow(size_t count = 0)
        : data_(count ? new uint8_t[count * element_size()] : nullptr), count_(count), own_data_(true) {
    }

    SparseRow(size_t count, uint8_t* data, bool own_data) : data_(data), count_(count), own_data_(own_data) {
    }

    // copy constructor and copy assignment operator perform deep copy
    SparseRow(const SparseRow<T>& other) : SparseRow(other.count_) {
        std::memcpy(data_, other.data_, data_byte_size());
    }

    SparseRow(SparseRow<T>&& other) noexcept : SparseRow() {
        swap(*this, other);
    }

    SparseRow&
    operator=(const SparseRow<T>& other) {
        if (this != &other) {
            SparseRow<T> tmp(other);
            swap(*this, tmp);
        }
        return *this;
    }

    SparseRow&
    operator=(SparseRow<T>&& other) noexcept {
        swap(*this, other);
        return *this;
    }

    ~SparseRow() {
        if (own_data_ && data_ != nullptr) {
            delete[] data_;
            data_ = nullptr;
        }
    }

    size_t
    size() const {
        return count_;
    }

    size_t
    memory_usage() const {
        return data_byte_size() + sizeof(*this);
    }

    // return the number of bytes used by the underlying data array.
    size_t
    data_byte_size() const {
        return count_ * element_size();
    }

    void*
    data() {
        return data_;
    }

    const void*
    data() const {
        return data_;
    }

    // dim of a sparse vector is the max index + 1, or 0 for an empty vector.
    int64_t
    dim() const {
        if (count_ == 0) {
            return 0;
        }
        auto* elem = reinterpret_cast<const ElementProxy*>(data_) + count_ - 1;
        return elem->index + 1;
    }

    SparseIdVal<T>
    operator[](size_t i) const {
        auto* elem = reinterpret_cast<const ElementProxy*>(data_) + i;
        return {elem->index, elem->value};
    }

    void
    set_at(size_t i, table_t index, T value) {
        auto* elem = reinterpret_cast<ElementProxy*>(data_) + i;
        elem->index = index;
        elem->value = value;
    }

    float
    dot(const SparseRow<T>& other) const {
        float product_sum = 0.0f;
        size_t i = 0;
        size_t j = 0;
        // TODO: improve with _mm_cmpistrm or the AVX512 alternative.
        while (i < count_ && j < other.count_) {
            auto* left = reinterpret_cast<const ElementProxy*>(data_) + i;
            auto* right = reinterpret_cast<const ElementProxy*>(other.data_) + j;

            if (left->index < right->index) {
                ++i;
            } else if (left->index > right->index) {
                ++j;
            } else {
                product_sum += left->value * right->value;
                ++i;
                ++j;
            }
        }
        return product_sum;
    }

    friend void
    swap(SparseRow<T>& left, SparseRow<T>& right) {
        using std::swap;
        swap(left.count_, right.count_);
        swap(left.data_, right.data_);
        swap(left.own_data_, right.own_data_);
    }

    static inline size_t
    element_size() {
        return sizeof(table_t) + sizeof(T);
    }

 private:
    // ElementProxy is used to access elements in the data_ array and should
    // never be actually constructed.
    struct __attribute__((packed)) ElementProxy {
        table_t index;
        T value;
        ElementProxy() = delete;
        ElementProxy(const ElementProxy&) = delete;
    };
    // data_ must be sorted by column id. use raw pointer for easy mmap and zero
    // copy.
    uint8_t* data_;
    size_t count_;
    bool own_data_;
};

然后索引具体是在
InvertedIndex
类里, 对应
sparse_inverted_index.h
文件,首先看定义的一些private 字段。

    std::vector<SparseRow<T>> raw_data_;
    mutable std::shared_mutex mu_;

    std::unordered_map<table_t, std::vector<SparseIdVal<T>>> inverted_lut_;
    bool use_wand_ = false;
    // If we want to drop small values during build, we must first train the
    // index with all the data to compute value_threshold_.
    bool drop_during_build_ = false;
    // when drop_during_build_ is true, any value smaller than value_threshold_
    // will not be added to inverted_lut_. value_threshold_ is set to the
    // drop_ratio_build-th percentile of all absolute values in the index.
    T value_threshold_ = 0.0f;
    std::unordered_map<table_t, T> max_in_dim_;
    size_t max_dim_ = 0;
  • raw_data_ 是原始的数据
  • inverted_lut_ 可以理解为一个倒排表
  • use_wand_ 用于控制查询时,是否使用WAND算法,WAND算法是经典的查询优化算法,可以通过类似跳表的方式跳过一些数据,减少计算量,提升查询效率
  • max_in_dim_ 是为wand服务的

索引构建流程

构建,主要是对外提供一个Add数据的方法:

    Status
    Add(const SparseRow<T>* data, size_t rows, int64_t dim) {
        std::unique_lock<std::shared_mutex> lock(mu_);
        auto current_rows = n_rows_internal();
        if (current_rows > 0 && drop_during_build_) {
            LOG_KNOWHERE_ERROR_ << "Not allowed to add data to a built index with drop_ratio_build > 0.";
            return Status::invalid_args;
        }
        if ((size_t)dim > max_dim_) {
            max_dim_ = dim;
        }

        raw_data_.insert(raw_data_.end(), data, data + rows);
        for (size_t i = 0; i < rows; ++i) {
            add_row_to_index(data[i], current_rows + i);
        }
        return Status::success;
    }

这里会更新数据的max_dim,数据追加到raw_data_,然后add_row_to_index,将新的doc放入inverted_lut_, 并更新max_in_dim_,用于记录最大值,方便wand查询时跳过计算。

    inline void
    add_row_to_index(const SparseRow<T>& row, table_t id) {
        for (size_t j = 0; j < row.size(); ++j) {
            auto [idx, val] = row[j];
            // Skip values close enough to zero(which contributes little to
            // the total IP score).
            if (drop_during_build_ && fabs(val) < value_threshold_) {
                continue;
            }
            if (inverted_lut_.find(idx) == inverted_lut_.end()) {
                inverted_lut_[idx];
                if (use_wand_) {
                    max_in_dim_[idx] = 0;
                }
            }
            inverted_lut_[idx].emplace_back(id, val);
            if (use_wand_) {
                max_in_dim_[idx] = std::max(max_in_dim_[idx], val);
            }
        }
    }

索引保存与load

保存时,是自定义的二进制文件:

    Status
    Save(MemoryIOWriter& writer) {
        /**
         * zero copy is not yet implemented, now serializing in a zero copy
         * compatible way while still copying during deserialization.
         *
         * Layout:
         *
         * 1. int32_t rows, sign indicates whether to use wand
         * 2. int32_t cols
         * 3. for each row:
         *     1. int32_t len
         *     2. for each non-zero value:
         *        1. table_t idx
         *        2. T val
         *     With zero copy deserization, each SparseRow object should
         *     reference(not owning) the memory address of the first element.
         *
         * inverted_lut_ and max_in_dim_ not serialized, they will be
         * constructed dynamically during deserialization.
         *
         * Data are densly packed in serialized bytes and no padding is added.
         */
        std::shared_lock<std::shared_mutex> lock(mu_);
        writeBinaryPOD(writer, n_rows_internal() * (use_wand_ ? 1 : -1));
        writeBinaryPOD(writer, n_cols_internal());
        writeBinaryPOD(writer, value_threshold_);
        for (size_t i = 0; i < n_rows_internal(); ++i) {
            auto& row = raw_data_[i];
            writeBinaryPOD(writer, row.size());
            if (row.size() == 0) {
                continue;
            }
            writer.write(row.data(), row.size() * SparseRow<T>::element_size());
        }
        return Status::success;
    }

索引文件格式:

    1. int32_t rows 总记录数,通过±符号来区分是否 use wand
    1. int32_t cols 列数
    1. for each row:
  • 1. int32_t len 长度
    
  • 2. for each non-zero value:
    
  •    1. table_t idx term的id编号
    
  •    2. T val   term的权重
    

注意,这里
inverted_lut_
倒排表是没有存储的,是在加载的时候重建,所以load的过程,就是一个逆过程:

Status
    Load(MemoryIOReader& reader) {
        std::unique_lock<std::shared_mutex> lock(mu_);
        int64_t rows;
        readBinaryPOD(reader, rows);
        use_wand_ = rows > 0;
        rows = std::abs(rows);
        readBinaryPOD(reader, max_dim_);
        readBinaryPOD(reader, value_threshold_);

        raw_data_.reserve(rows);

        for (int64_t i = 0; i < rows; ++i) {
            size_t count;
            readBinaryPOD(reader, count);
            raw_data_.emplace_back(count);
            if (count == 0) {
                continue;
            }
            reader.read(raw_data_[i].data(), count * SparseRow<T>::element_size());
            add_row_to_index(raw_data_[i], i);
        }

        return Status::success;
    }

检索流程

我们来回顾,compute_lexical_matching_score其实就是计算共同term的weight score相乘,然后加起来,所以可以想象下,暴力检索大概就是把所有term对应的doc取并集,然后计算lexical_matching_score,最后取topk。

我们来看milvus的实现,先看暴力检索:

    // find the top-k candidates using brute force search, k as specified by the capacity of the heap.
    // any value in q_vec that is smaller than q_threshold and any value with dimension >= n_cols() will be ignored.
    // TODO: may switch to row-wise brute force if filter rate is high. Benchmark needed.
    void
    search_brute_force(const SparseRow<T>& q_vec, T q_threshold, MaxMinHeap<T>& heap, const BitsetView& bitset) const {
        auto scores = compute_all_distances(q_vec, q_threshold);
        for (size_t i = 0; i < n_rows_internal(); ++i) {
            if ((bitset.empty() || !bitset.test(i)) && scores[i] != 0) {
                heap.push(i, scores[i]);
            }
        }
    }

    std::vector<float>
    compute_all_distances(const SparseRow<T>& q_vec, T q_threshold) const {
        std::vector<float> scores(n_rows_internal(), 0.0f);
        for (size_t idx = 0; idx < q_vec.size(); ++idx) {
            auto [i, v] = q_vec[idx];
            if (v < q_threshold || i >= n_cols_internal()) {
                continue;
            }
            auto lut_it = inverted_lut_.find(i);
            if (lut_it == inverted_lut_.end()) {
                continue;
            }
            // TODO: improve with SIMD
            auto& lut = lut_it->second;
            for (size_t j = 0; j < lut.size(); j++) {
                auto [idx, val] = lut[j];
                scores[idx] += v * float(val);
            }
        }
        return scores;
    }
  • 核心在
    compute_all_distances
    里,先通过q_vec得到每一个term id,然后从inverted_lut_里找到term对应的doc list,然后计算score,相同doc id的score累加
  • 最后用MaxMinHeap堆,来取topk

暴力检索能保准精准性,但是效率比较低。我们来看使用wand优化的检索:

// any value in q_vec that is smaller than q_threshold will be ignored.
    void
    search_wand(const SparseRow<T>& q_vec, T q_threshold, MaxMinHeap<T>& heap, const BitsetView& bitset) const {
        auto q_dim = q_vec.size();
        std::vector<std::shared_ptr<Cursor<std::vector<SparseIdVal<T>>>>> cursors(q_dim);
        auto valid_q_dim = 0;
        // 倒排链
        for (size_t i = 0; i < q_dim; ++i) {
	        // idx(term_id)
            auto [idx, val] = q_vec[i];
            if (std::abs(val) < q_threshold || idx >= n_cols_internal()) {
                continue;
            }
            auto lut_it = inverted_lut_.find(idx);
            if (lut_it == inverted_lut_.end()) {
                continue;
            }
            auto& lut = lut_it->second;
            // max_in_dim_ 记录了term index 的最大score
            cursors[valid_q_dim++] = std::make_shared<Cursor<std::vector<SparseIdVal<T>>>>(
                lut, n_rows_internal(), max_in_dim_.find(idx)->second * val, val, bitset);
        }
        if (valid_q_dim == 0) {
            return;
        }
        cursors.resize(valid_q_dim);
        auto sort_cursors = [&cursors] {
            std::sort(cursors.begin(), cursors.end(),
                      [](auto& x, auto& y) { return x->cur_vec_id() < y->cur_vec_id(); });
        };
        sort_cursors();
        // 堆未满,或者新的score > 堆顶的score
        auto score_above_threshold = [&heap](float x) { return !heap.full() || x > heap.top().val; };
        while (true) {
	        // 上边界
            float upper_bound = 0;
            // pivot 满足条件的倒排链的序号
            size_t pivot;
            bool found_pivot = false;
            for (pivot = 0; pivot < cursors.size(); ++pivot) {
	            // 有倒排结束
                if (cursors[pivot]->is_end()) {
                    break;
                }
                upper_bound += cursors[pivot]->max_score();
                if (score_above_threshold(upper_bound)) {
                    found_pivot = true;
                    break;
                }
            }
            if (!found_pivot) {
                break;
            }
            // 找到满足upper_bound 满足条件的pivot_id
            table_t pivot_id = cursors[pivot]->cur_vec_id();
            // 如果第一个倒排链的当前vec_id (doc_id) 等于pivot_id,可以直接从第0个倒排链开始,计算score
            if (pivot_id == cursors[0]->cur_vec_id()) {
                float score = 0;
                // 遍历所有cursors,累加score
                for (auto& cursor : cursors) {
                    if (cursor->cur_vec_id() != pivot_id) {
                        break;
                    }
                    score += cursor->cur_distance() * cursor->q_value();
                    // 倒排链移到下一位
                    cursor->next();
                }
                // 放入堆
                heap.push(pivot_id, score);
                // 重排cursors,保证最小的vec_id在最前面
                sort_cursors();
            } else {
                // 第一个倒排链的当前vec_id不等于pivot_id, pivot>=1
                // 那么从pivot(满足threshold的倒排链序号)往前找是否有cur_vec_id==pivot_id的
                size_t next_list = pivot;
                for (; cursors[next_list]->cur_vec_id() == pivot_id; --next_list) {
                }
                // 这里的next_list的cur_vec_id 不一定等与pivot_id,将list seek到pivot_id
                // seek后,cursors[next_list].cur_vec_id() >= pivot_id,通过seek,可以跳过一些vec id
                cursors[next_list]->seek(pivot_id);
                // 从next_list + 1开始
                for (size_t i = next_list + 1; i < cursors.size(); ++i) {
                    // 如果当前cur_vec_id >= 上一个则停止
                    if (cursors[i]->cur_vec_id() >= cursors[i - 1]->cur_vec_id()) {
                        break;
                    }
                    // 否则,交换倒排链,可以确保==pivot_id的倒排链交换到前面
                    std::swap(cursors[i], cursors[i - 1]);
                }
            }
        }
    }
  • 首先是倒排链取出来放入cursors,然后对cursors按照vec_id排序,将vec_id较小的排到倒排链的首位
  • 通过score_above_threshold,遍历cursors找符合条件的cursor 索引号pivot,这里通过
    堆未满,或者新的score > 堆顶的score
    来判断,可以跳过一些score小的
  • 然后找到pivot cursor对应的pivot_id,也就是doc id,然后判断第一个倒排链的cur_vec_id 是否等于pivot_id:
    • 如果等于,就可以遍历倒排链,计算pivot_id的score,然后放入小顶堆中排序,然后重排倒排链
    • 如果不等于,那么就需要想办法将cur_vec_id == pivot_id的往前放,同时跳过倒排链中vec_id < cur_vec_id的数据(减枝)

用golang实现轻量级sparse vec检索

用类似milvus的方法,我们简单实现一个golang版本的

package main

import (
	"container/heap"
	"encoding/binary"
	"fmt"
	"io"
	"math/rand"
	"os"
	"sort"
	"time"
)

type Cursor struct {
	docIDs     []int32
	weights    []float64
	maxScore   float64
	termWeight float64
	currentIdx int
}

func NewCursor(docIDs []int32, weights []float64, maxScore float64, weight float64) *Cursor {
	return &Cursor{
		docIDs:     docIDs,
		weights:    weights,
		maxScore:   maxScore,
		termWeight: weight,
		currentIdx: 0,
	}
}

func (c *Cursor) Next() {
	c.currentIdx++
}

func (c *Cursor) Seek(docId int32) {
	for {
		if c.IsEnd() {
			break
		}
		if c.CurrentDocID() < docId {
			c.Next()
		} else {
			break
		}
	}
}

func (c *Cursor) IsEnd() bool {
	return c.currentIdx >= len(c.docIDs)
}

func (c *Cursor) CurrentDocID() int32 {
	return c.docIDs[c.currentIdx]
}

func (c *Cursor) CurrentDocWeight() float64 {
	return c.weights[c.currentIdx]
}

// DocVectors type will map docID to its vector
type DocVectors map[int32]map[int32]float64

// InvertedIndex type will map termID to sorted list of docIDs
type InvertedIndex map[int32][]int32

// TermMaxScore will keep track of maximum scores for terms
type TermMaxScores map[int32]float64

// SparseIndex class struct
type SparseIndex struct {
	docVectors    DocVectors
	invertedIndex InvertedIndex
	termMaxScores TermMaxScores
	dim           int32
}

// NewSparseIndex initializes a new SparseIndex with empty structures
func NewSparseIndex() *SparseIndex {
	return &SparseIndex{
		docVectors:    make(DocVectors),
		invertedIndex: make(InvertedIndex),
		termMaxScores: make(TermMaxScores),
		dim:           0,
	}
}

// Add method for adding documents to the sparse index
func (index *SparseIndex) Add(docID int32, vec map[int32]float64) {
	index.docVectors[docID] = vec

	for termID, score := range vec {
		index.invertedIndex[termID] = append(index.invertedIndex[termID], docID)

		// Track max score for each term
		if maxScore, ok := index.termMaxScores[termID]; !ok || score > maxScore {
			index.termMaxScores[termID] = score
		}
		if termID > index.dim {
			index.dim = termID
		}
	}
}

// Save index to file
func (index *SparseIndex) Save(filename string) error {
	file, err := os.Create(filename)
	if err != nil {
		return err
	}
	defer file.Close()

	// Write the dimension
	binary.Write(file, binary.LittleEndian, index.dim)

	// Write each document vector
	for docID, vec := range index.docVectors {
		binary.Write(file, binary.LittleEndian, docID)
		vecSize := int32(len(vec))
		binary.Write(file, binary.LittleEndian, vecSize)

		for termID, score := range vec {
			binary.Write(file, binary.LittleEndian, termID)
			binary.Write(file, binary.LittleEndian, score)
		}
	}

	return nil
}

// Load index from file
func (index *SparseIndex) Load(filename string) error {
	file, err := os.Open(filename)
	if err != nil {
		return err
	}
	defer file.Close()

	var dim int32
	binary.Read(file, binary.LittleEndian, &dim)
	index.dim = dim

	for {
		var docID int32
		err := binary.Read(file, binary.LittleEndian, &docID)
		if err == io.EOF {
			break // End of file
		} else if err != nil {
			return err // Some other error
		}

		var vecSize int32
		binary.Read(file, binary.LittleEndian, &vecSize)
		vec := make(map[int32]float64)

		for i := int32(0); i < vecSize; i++ {
			var termID int32
			var score float64
			binary.Read(file, binary.LittleEndian, &termID)
			binary.Read(file, binary.LittleEndian, &score)
			vec[termID] = score
		}

		index.Add(docID, vec) // Rebuild the index
	}
	return nil
}

func (index *SparseIndex) bruteSearch(queryVec map[int32]float64, K int) []int32 {

	scores := computeAllDistances(queryVec, index)

	// 取top k
	docHeap := &DocScoreHeap{}
	for docID, score := range scores {
		if docHeap.Len() < K {
			heap.Push(docHeap, &DocScore{docID, score})
		} else if (*docHeap)[0].score < score {
			heap.Pop(docHeap)
			heap.Push(docHeap, &DocScore{docID, score})
		}
	}

	topDocs := make([]int32, 0, K)
	for docHeap.Len() > 0 {
		el := heap.Pop(docHeap).(*DocScore)
		topDocs = append(topDocs, el.docID)
	}

	sort.Slice(topDocs, func(i, j int) bool {
		return topDocs[i] < topDocs[j]
	})

	return topDocs
}

func computeAllDistances(queryVec map[int32]float64, index *SparseIndex) map[int32]float64 {
	scores := make(map[int32]float64)
	for term, qWeight := range queryVec {
		if postingList, exists := index.invertedIndex[term]; exists {
			for _, docID := range postingList {
				docVec := index.docVectors[docID]
				docWeight, exists := docVec[term]
				if !exists {
					continue
				}
				score := qWeight * docWeight

				if _, ok := scores[docID]; !ok {
					scores[docID] = score
				} else {
					scores[docID] += score
				}
			}
		}
	}
	return scores
}

// TopK retrieves the top K documents nearest to the query vector
func (index *SparseIndex) WandSearch(queryVec map[int32]float64, K int) []int32 {
	docHeap := &DocScoreHeap{}

	// 倒排链
	postingLists := make([]*Cursor, len(queryVec))
	idx := 0
	for term, termWeight := range queryVec {
		if postingList, exists := index.invertedIndex[term]; exists {
			// 包含term的doc,term对应的weight
			weights := make([]float64, len(postingList))
			for i, docID := range postingList {
				weights[i] = index.docVectors[docID][term]
			}
			postingLists[idx] = NewCursor(postingList, weights, index.termMaxScores[term]*termWeight, termWeight)
			idx += 1
		}
	}

	sortPostings := func() {
		for i := range postingLists {
			if postingLists[i].IsEnd() {
				return
			}
		}
		// 将postingLists按照首个docid排序
		sort.Slice(postingLists, func(i, j int) bool {
			return postingLists[i].CurrentDocID() < postingLists[j].CurrentDocID()
		})
	}

	sortPostings()

	scoreAboveThreshold := func(value float64) bool {
		return docHeap.Len() < K || (*docHeap)[0].score < value
	}

	for {
		upperBound := 0.0
		foundPivot := false
		pivot := 0
		for idx := range postingLists {
			if postingLists[idx].IsEnd() {
				break
			}

			upperBound += postingLists[idx].maxScore
			if scoreAboveThreshold(upperBound) {
				foundPivot = true
				pivot = idx
				break
			}
		}

		if !foundPivot {
			break
		}

		// 找到满足upper_bound 满足条件的pivot_id
		pivotId := postingLists[pivot].CurrentDocID()
		if pivotId == postingLists[0].CurrentDocID() {
			//	如果第一个倒排链的当前vec_id (doc_id) 等于pivot_id,可以直接从第0个倒排链开始,计算score
			score := 0.0
			// 遍历所有cursors,累加score
			for idx := range postingLists {
				cursor := postingLists[idx]
				if cursor.CurrentDocID() != pivotId {
					break
				}
				score += cursor.CurrentDocWeight() * cursor.termWeight
				// 移到下一个docid
				postingLists[idx].Next()
			}

			// 放入堆s
			if docHeap.Len() < K {
				heap.Push(docHeap, &DocScore{pivotId, score})
			} else if (*docHeap)[0].score < score {
				heap.Pop(docHeap)
				heap.Push(docHeap, &DocScore{pivotId, score})
			}

			// 重排cursors,保证最小的vec_id在最前面
			sortPostings()
		} else {
			// 第一个倒排链的当前vec_id不等于pivot_id, pivot>=1
			// 那么从pivot(满足threshold的倒排链序号)往前找是否有cur_vec_id==pivot_id的
			nextList := pivot
			for ; postingLists[nextList].CurrentDocID() == pivotId; nextList-- {
			}
			// 这里的next_list的cur_vec_id 不一定等与pivot_id,将list seek到pivot_id
			// seek后,cursors[next_list].cur_vec_id() >= pivot_id,通过seek,可以跳过一些vec id
			postingLists[nextList].Seek(pivotId)
			// 从next_list + 1开始

			for i := nextList + 1; i < len(postingLists); i++ {
				// 如果当前cur_vec_id >= 上一个则停止
				if postingLists[i].CurrentDocID() >= postingLists[i-1].CurrentDocID() {
					break
				}
				// 否则,交换倒排链,可以确保==pivot_id的倒排链交换到前面
				temp := postingLists[i]
				postingLists[i] = postingLists[i-1]
				postingLists[i-1] = temp
			}
		}
	}

	topDocs := make([]int32, 0, K)
	for docHeap.Len() > 0 {
		el := heap.Pop(docHeap).(*DocScore)
		topDocs = append(topDocs, el.docID)
	}
	sort.Slice(topDocs, func(i, j int) bool {
		return topDocs[i] < topDocs[j]
	})

	return topDocs
}

// Helper structure to manage the priority queue for the top-K documents
type DocScore struct {
	docID int32
	score float64
}

type DocScoreHeap []*DocScore

func (h DocScoreHeap) Len() int           { return len(h) }
func (h DocScoreHeap) Less(i, j int) bool { return h[i].score < h[j].score }
func (h DocScoreHeap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }

func (h *DocScoreHeap) Push(x interface{}) {
	*h = append(*h, x.(*DocScore))
}

func (h *DocScoreHeap) Pop() interface{} {
	old := *h
	n := len(old)
	x := old[n-1]
	*h = old[0 : n-1]
	return x
}

func main() {
	index := NewSparseIndex()

	rand.Seed(time.Now().UnixNano())
	// Add document vectors as needed
	for i := 1; i <= 1000; i++ {
		// 打印当前i的值
		index.Add(int32(i), map[int32]float64{101: rand.Float64(),
			150: rand.Float64(),
			190: rand.Float64(),
			500: rand.Float64()})
	}
	//index.Save("index.bin")
	//index.Load("index.bin")
	topDocs := index.WandSearch(map[int32]float64{101: rand.Float64(), 150: rand.Float64(), 190: rand.Float64(),
		500: rand.Float64()}, 10)
	fmt.Println("Top Docs:", topDocs)
}

  • 代码实现了索引的构建、保存和加载,检索方面实现了暴力检索和WAND检索
  • 注意,添加doc时,需要保障doc有序,实际应用中,docid可以引擎维护一个真实id到递增docid的映射
  • 代码中已经有注释,这里不再赘述,注意代码未充分调试,可能有bug
  • 代码实现倒排表全放到内存,效率高,但对内存要求高

总结

sparse 检索整体类似传统的文本检索,因此传统的工程优化方法可以运用到sparse检索中,本文分析了milvus的实现,并实现了一个golang版本的sparse检索。