一个 supabase 风格的数据端口,query 是一根 SPL 式管道——stage | stage,SQL 是一等公民、也是缺省:一段首 token 命中注册算子(search/scan/grep/insert…)才走算子,否则整段就是一条 DuckDB SQL;纯 SQL 查询零管道、原样执行。检索是管道的一个 source 段(类 SQL:search <列> from <表> where <列> match "文本" | SELECT … FROM inputs),hybrid = 向量语义 + BM25 全文、中文 jieba 分词、RRF 融合,唯一落在 LanceDB;整条管道编译成一条 DuckDB WITH SQL 执行,不自建运行时。三份存储(canonical 文件 / DuckDB 结构化 / LanceDB 检索),各存完整列、各自自足。一个端口、零运维。
唯一数据入口:query。 读、写、流都传一根管道,恒返回一个 task id;结果用 read(no-wait 快照)/ stream_read(阻塞)取。
两种使用形态,一套完全相同的 API:
| 形态 | 怎么拿到 db | 跑在哪 | 什么时候用 |
|---|---|---|---|
| 嵌入(embedded) | Seekbase.open(dir, schema=…, settings=…) |
进程内、DuckDB | 单进程、本地优先、零运维 |
| 服务(server) | Seekbase.connect(url) → 连一个运行中的 server |
HTTP | 多客户端 / 多进程共享同一个实例 |
两种形态的调用代码逐字节相同,变的只有你怎么拿到 db 句柄。
设计与实现一致:完整设计见 docs/v2/(
works/设计 ·sdk/Python 参考 ·api/HTTP 契约)。delete 只软删deleted_ds墓碑、历史永久保留,没有物理删 / vacuum。
pip install seekbase # 嵌入 + HTTP 客户端 + server(seekbase_server),全部开箱即用嵌入、HTTP 客户端(Seekbase.connect)、把端口暴露成 HTTP 服务的 seekbase_server(db)(零依赖的手写 ASGI app)都是标配。跑 app 的 ASGI runner 由你从外部注入(uvicorn / hypercorn / 挂进已有应用),seekbase 不绑定 runner。
from seekbase import Seekbase
SCHEMA = [
{
"table": "cards",
"columns": [
{"name": "card_id", "type": "str"},
{"name": "issue", "type": "str"},
{"name": "kind", "type": "str"},
],
"primary": "card_id",
"searchable": ["issue"], # 可检索列(hybrid:向量 + BM25,落 LanceDB)
},
]
# 有可搜列 → 注入 embedder(settings 的内建键;协议见 docs/v2/api/setup.md §4)
db = await Seekbase.open("./data", schema=SCHEMA, settings={"embedder": my_embedder})
# 写:query 以 insert/delete 收尾。同步落定(三份存储),恒返回一个 task id
await db.wait(await db.query("insert cards", rows={"card_id": "c1", "issue": "pty vs tmux", "kind": "issue"}))
# 读:query 返回 task id,结果用 read 取
tid = await db.query("SELECT card_id, issue FROM cards WHERE kind = ? ORDER BY created_at DESC LIMIT 20",
params=["issue"])
rows = await db.read(tid)
# 检索:search 源段(类 SQL) + 一条 SQL,一个接口全包
hits = await db.read(await db.query(
"search card_id, issue from cards where issue match 'pty 终端' "
"| SELECT card_id, _score FROM inputs ORDER BY _score DESC LIMIT 10"))
# 时光机同参:query(..., ds_end='20260601') 连 search 候选一起回溯
# 软删:打墓碑,永不物理删
await db.wait(await db.query("delete cards where card_id = ?", params=["c1"]))
await db.close()起一个 server——它持有 schema 与 embedder、拥有数据目录。seekbase_server(db) 给你一个裸 ASGI app,用你自己的 runner 跑:
import uvicorn
from seekbase import Seekbase
from seekbase.server import seekbase_server
db = await Seekbase.open("./data", schema=SCHEMA, settings={"embedder": my_embedder})
uvicorn.run(seekbase_server(db, api_key="secret"), host="0.0.0.0", port=8000)便捷函数 serve(db, host=…, port=…, api_key=…, runner=…):runner 是任意 runner(app, host=…, port=…) 可调用(默认 uvicorn),始终外部提供。
从任何地方连它——调用代码和嵌入形态一模一样:
db = await Seekbase.connect("http://localhost:8000", api_key="secret")
await db.wait(await db.query("insert cards", rows={"card_id": "c1", "issue": "pty vs tmux", "kind": "issue"}))
rows = await db.read(await db.query("SELECT card_id, issue FROM cards WHERE kind = ?", params=["issue"]))
await db.close()唯一数据端点 POST /v1/query(恒返 task);结果 GET /v1/tasks/{id}/read。错误过线保型(server 侧抛的 ReadOnlyError,client 侧还是 ReadOnlyError)。鉴权是一个可选的 bearer token。
- 一个数据入口:
query提交任意管道(读/写/流),恒返 task id;结果read/stream_read。每次执行都是一个 task(可观测、有生命周期)。 - 只增、引擎强制:没有
update/upsert;写唯一是insert/delete算子(裸 SQL DML 被拒);delete只写deleted_ds墓碑。历史诚实——时光机对所有行严谨。 - 准入 = 注册:能用哪些算子 = server 端注册了哪些(没注册
sh就没有sh);没有权限/能力层,真沙箱是另一层。 - 调用方永远不见向量:声明
searchable列;search … match "文本"自动 embed + jieba 分词 + hybrid 检索,产inputs表交给下一段 SQL。 - 接缝才切:
|只标 DuckDB 跨不过去的接缝;一条 SQL 能干完的绝不拆段——整条管道编译成一条WITHSQL,优化器看穿全链。
- docs/v2/works/ —— 设计:管道模型 / 算子 / 检索 / 写入 / 配置 / task / 存储 / 时光机
- docs/v2/sdk/ —— SDK 参考(
open/connect·query(唯一入口)·read/stream_read·task·operator·errors) - docs/v2/api/ —— HTTP 契约(
query(唯一端点)/tasks/admin/setup) - docs/v1/ · DESIGN.md —— 历史(v1 设计,已被 docs/v2 取代)
Apache-2.0。