Hister向量搜索实现指南:从Embedder接口到sqlitevec的完整链路
【免费下载链接】histerYour own search engine项目地址: https://gitcode.com/GitHub_Trending/hi/hister
在自建搜索引擎领域,Hister是一个注重隐私的自托管搜索工具,除了关键词全文检索,它还提供了一套完整的向量语义搜索能力:文档入库时由 Embedder 调用嵌入模型生成向量,再通过 sqlitevec 扩展存入 SQLite 向量库,查询时以余弦相似度召回最相关的文档片段。本文将带你完整走一遍这条链路:从Embedder的文本分块与批量嵌入,到VectorStore接口抽象,再到 sqlitevec 的vec0虚拟表落地,每一步都有源码可查。
整体架构:三个角色各司其职
Hister 的向量搜索由三个核心组件协作完成,代码集中在 server/vectorstore/ 目录:
| 组件 | 文件 | 职责 |
|---|---|---|
| Embedder | embedder.go | 文本分块、调用嵌入接口、批量重试 |
| VectorStore 接口 | vectorstore.go | 抽象写入/删除/相似搜索操作 |
| SQLite 后端 | sqlite.go | 基于 sqlitevec 的向量存储与召回 |
入口是一个工厂函数:根据配置中的数据库类型,自动选择 SQLite 或 PostgreSQL 后端(PostgreSQL 实现见 postgres.go):
// New creates a VectorStore implementation based on the database backend in use. func New(cfg *config.Config) (VectorStore, error) { dbType, _ := cfg.DatabaseConnection() if dbType == config.Psql { return newPostgres(cfg) } return newSQLite(cfg) }Embedder:文本如何变成向量
Embedder 是整条链路的"翻译官",它对接任何OpenAI 兼容的/v1/embeddings接口(本地 Ollama、llama.cpp 或云端服务都可以),并负责处理长文档的分块。
1. 分块策略:元数据向量 + 正文向量
调用 embedder.go 的ChunkAndEmbed方法时,一篇文档会产生两类向量:
- 元数据向量:把标题、类型、语言、作者、描述、关键词打包成一段文本单独嵌入,让"这篇文档是关于什么的"成为一个独立的可检索语义;
- 正文向量:正文按
max_context_length切块(带chunk_overlap重叠避免语义断裂),每块加上"标题+语言"前缀再嵌入,给片段提供文档上下文。
这个设计很实用:长文档不会淹没元数据语义,短文档也不会因为正文太薄而难被命中。
2. 批量请求与智能重试
嵌入请求并不是一次一条发送的。EmbedBatch按max_embedding_batch_size(默认 8 条)分批请求,避免长文档独占本地嵌入服务器;并发度则由max_embedding_concurrency信号量控制。
更巧妙的是它的错误自愈机制(见 embedder.go):
- 遇到 429/5xx/网关超时等瞬时错误,自动指数退避重试,最多 3 次;
- 如果接口报"上下文超长",Embedder 会自动对半拆分批,或按接口返回的 token 数动态调低分块长度重新计算,直到塞得进模型上下文。
这意味着你几乎不需要精确计算模型的 token 上限,Hister 会自己试出来。
VectorStore 接口:六行代码的抽象边界
vectorstore.go 定义了存储后端必须实现的全部能力:
type VectorStore interface { Init() error PutChunks(docID string, userID uint, chunks []Chunk) error Delete(docID string) error Search(vector []float32, topK int, threshold float64, userID uint) ([]Result, error) Clear() error Close() error }其中PutChunks的注释点明了文档 ID 与全文索引(Bleve)的 URL 主键保持一致——向量和关键词索引靠同一个 docID 关联,这是两套召回结果能合并打分的前提。
sqlitevec 后端:向量如何落进 SQLite
1. 扩展的自包含打包
sqlitevec 后端没有要求用户安装任何系统库。项目把 sqlite-vec 的 C 源码直接打包进了仓库(见 server/vectorstore/sqlitevec/README.md),通过 CGO 静态编译,并暴露一个 vec.go 里的Auto()函数,让进程内所有新开的 SQLite 连接都自动加载vec0虚拟表支持。跨平台构建、零外部依赖,这也是 Hister 能做成单二进文件发布的关键。
2. 双表设计:向量与文本分离
初始化时(sqlite.go)会创建两张表:
chunk_meta:普通表,保存 chunk 原文、所属 docID、用户 ID 等元数据;embeddings:sqlitevec 的vec0虚拟表,按配置的维度存FLOAT[N]向量,指定distance_metric=cosine,并以user_id作为分区键实现多用户隔离。
写入时用float32ToBlob把向量转成小端字节流,一次事务里先删旧 chunk 再插入新数据,保证文档更新时向量不残留。
3. 搜索:候选放大 + 相似度换算 + 去重打散
sqlite.go 的Search有三层细节:
- 候选放大:先按
topK × 4从vec0表拉候选,给后续去重留足余量; - 余弦距离 → 相似度:
vec0返回的是距离,代码用similarity = 1 - distance换算,再与配置的similarity_threshold比较过滤; - 文档级去重打散:
diversifySearchResults限制每个文档最多贡献 2 个 chunk,避免一篇长文档占满整个语义候选池。
多用户模式下,还会额外查一次userID = 0的公共池并合并两组结果,兼顾个人收藏与共享内容。
端到端流程:一篇文档的向量之旅
把所有环节串起来,一篇文档从入库到可被语义搜索命中,经历如下步骤:
- 索引器接收文档,全文索引写入 Bleve 的同时,向持久化的嵌入任务队列(embedding_queue.go)投递该文档;
- 队列 Worker 认领任务(默认 2 个 worker,失败指数退避重试,5 次后隔离),调用 embedDocumentChunks;
- Embedder 分块嵌入:元数据向量 + 正文分块向量批量请求嵌入接口;
- VectorStore 落库:
PutChunks事务写入chunk_meta与embeddings双表; - 查询时:查询词经
EmbedQuery(可加query_prefix提升召回)生成向量 → sqlitevec 余弦检索 → 结果与全文检索按semantic_weight融合排序。
所有可调参数都集中在semantic_search配置段中,定义见 config.go,包括端点、维度、分块重叠、相似度阈值、语义权重等,文档见 semantic-search 说明 与 configuration.md。
写在最后
Hister 向量存储设计的几个亮点值得借鉴:接口层极薄(六个方法覆盖全部生命周期)、存储层零依赖(sqlitevec 源码打包进仓库)、容错层够厚(批量拆半、上下文自适应、任务持久化重试)。对于想给自托管搜索加上"语义理解"能力的团队,这套从 Embedder 到 sqlitevec 的完整链路,可以直接对照 server/vectorstore/ 逐行研读,按需裁剪移植。
【免费下载链接】histerYour own search engine项目地址: https://gitcode.com/GitHub_Trending/hi/hister
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考