数据截至 (上游 commit 059ecec2eeac)
SQL 驱动检索与关系数据库层(content store)
30 秒导读: 光有向量索引,你只能问"哪些东西跟这句话最像",拿回一串
(id, 分数)。txtai 的这一层让你能改问:"跟这句话像、并且entry > '2021-01-01'、按score倒序、只要前 5 条,把text和length一起给我"——用几乎标准的 SQL 写出来。本章讲清这句 SQL 是怎么被拆开、similar()怎么桥接到向量扫描、候选 id 又怎么注回真实数据库完成过滤/排序/聚合并取回原文的。
本章属于 txtai 系列。相邻章节:这是什么/全景 · Embeddings 编排 · 向量化 · 稠密/稀疏索引 · 搜索路由与融合 · 图子系统。分数怎么融合是 04 的活,本章不重复;图关系检索看 06。
1. 这是什么(零基础也能懂)
一句话定义
content store = 一个跟向量索引并肩摆放的关系数据库,存放每条记录的原文和字段;SQL 驱动检索 = 用一段类 SQL 把"向量相似"和"结构化条件"揉进同一句查询。
向量索引(ANN)本身只认整数 indexid 和相似分,它不存原文、不存字段。想在"语义相似"之外再加"日期晚于某天""某标签""按某列排序""统计计数",就得有个地方存这些字段——那就是 content store。
解决什么问题 / 给谁用
假设你把一批新闻灌进 txtai。纯向量检索只能回答"跟'气候政策'最像的 3 条是哪几条"。但你真正想问的往往是:
"跟'气候政策'语义相近、且发表于 2021 年之后、按相似度倒序、只要前 5 条,并且把标题和正文长度一起返回。"
这句话里,"语义相近"归向量索引管,"2021 年之后 / 排序 / 取前 5 / 返回 哪些列"全是关系数据库的强项。txtai 让你把两者写进一句查询:
select id, text, length(text) length, score
from txtai
where similar('气候政策') and entry >= '2021-01-01'
order by score desc
limit 5
similar(...) 是 txtai 自造的函数,代表"这里插一段向量相似检索"。其余部分是普通 SQL。
一句话直觉
把向量索引当"模糊探照灯",把关系库当"精确筛子"。 探照灯先在黑暗里扫出一堆"看着像"的候选(一批 indexid),筛子再用日期、标签、排序、分页这些硬条件把候选精修成最终答案,并顺手把原文捞回来。similar() 就是探照灯留在筛子上的一个挂钩。
用起来什么样
Python 侧就是一句 embeddings.search(...),传入 SQL 字符串,拿回一列 dict:
# 示意,非源码:content 打开后即可用 SQL 检索
from txtai import Embeddings
embeddings = Embeddings(content=True) # content=True 打开 SQLite content store
embeddings.index([(0, "气候政策讨论", None),
(1, "股票市场行情", None)])
# 返回 [{"id": "0", "text": "...", "score": 0.83}, ...]
embeddings.search("select id, text, score from txtai where similar('气候') limit 1")
content=True 这一个开关,就决定了检索返回的是裸的 (id, 分数) 还是带字段的 dict。开关背后,就是本章要拆开的这套机器。
2. 顶层全景(它大概怎么转)
两条腿:index 腿 + database 腿
一次 SQL 检索会同时踩两条腿,再把它们缝起来:
一句类 SQL 查询字符串
│
▼
┌──────────────────────────┐
│ Search.dbsearch() │ 搜索编排(embeddings/search)
│ base.py:210 │
└───────────┬──────────────┘
│
┌────────────┴─────────────┐
│ parse:把 SQL 拆成子句 │ ← Database.parse → SQL 词法/语法
│ 抽出 similar() 参数 │ (database/sql)
└────────────┬─────────────┘
│ 得到 {select, where, ...,
│ similar:[["气候政策"]]}
┌─────────────┴──────────────┐
│ │
▼ index 腿 ▼ database 腿
┌──────────────┐ (等 index 腿产出候选)
│ Scan │ scan.py │
│ 把 similar() │ │
│ 参数喂给向量 │ │
│ index,搜出 │ │
│ 候选 (id,分) │ │
└──────┬───────┘ │
│ 候选 [(indexid, score)...] │
└──────────────┬─────────────┘
▼
┌──────────────────────────┐
│ Database.search() │ database/base.py:116
│ 把候选 id 塞进 WHERE 的 │
│ __SIMILAR__ 占位符 │
│ → 拼成真实后端 SQL │
└───────────┬──────────────┘
▼
┌──────────────────────────┐
│ 后端执行(SQLite/DuckDB/ │ rdbms.py / sqlite.py ...
│ Postgres…):JOIN 三张表 + │
│ WHERE/ORDER/LIMIT 过滤 │
│ → 取回 content 行 │
└───────────┬──────────────┘
▼
list[dict] 最终结果
怎么读这张图: 从上到下是时间顺序。左边"index 腿"先跑,产出一批候选 indexid;右边"database 腿"拿到候选后,把它们当成一个 IN (...) 条件塞回 SQL,交给真实数据库做剩下所有的过滤、排序、取原文。
部件一句话职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
Search.dbsearch | 检索总编排:解析 → 扫描 → 缝合 | embeddings/search/base.py:210 |
Scan | 把 similar() 参数桥接到向量 index,搜出候选 id | embeddings/search/scan.py:6 |
SQL | 类 SQL 词法 + 语法:tokenize / parse / issql | database/sql/base.py:11 |
Expression | 逐 token 改写:列名解析、抽 similar() 换占位符 | database/sql/expression.py:9 |
Database.search | 把候选 id 填回占位符,拼成后端 SQL 并执行 | database/base.py:116 |
RDBMS.query | 组装 SELECT ... JOIN ... WHERE ... 真实语句 | database/rdbms.py:177 |
Statement | 所有建表 / 查询 SQL 模板常量 | database/schema/statement.py:6 |
| 后端 | 具体数据库方言实现 | sqlite.py / duckdb.py / client.py |
Encoder | 二进制/图像对象列的编解码 | database/encoder/ |
主线走一遍(高层)
- 判路由。
Search.__call__发现有 content store 且不是纯 index 模式,就走dbsearch(base.py:79-80)。 - 解析。 每句查询过
Database.parse拆成子句 dict;若不是 SQL(纯自然语言),包成{"similar": [[query]]}(sql/base.py:70)。 - 扫描。
Scan取出所有similar()子句,按目标 index 分组,调向量搜索拿候选(scan.py:37)。 - 缝合。
Database.search把候选 id 的IN子句替换进WHERE里的__SIMILAR__占位符(base.py:150-158)。 - 执行取回。
RDBMS.query拼出SELECT ... FROM sections JOIN documents JOIN objects JOIN scores WHERE ...,后端跑完返回 dict 列表(rdbms.py:177)。
3. 核心原理(逐个机制,由浅入深)
3.1 三张表的 content store:原文存在哪
它要解决的小问题: 一条记录有原文、有 JSON 字段、可能还有二进制对象(图片、pickle),向量索引一个都存不下。得有张(几张)表来放。
思路: txtai 不用一张宽表,而是拆成三张职责单一的表,靠 id 关联:
| 表 | 存什么 | 建表常量 |
|---|---|---|
sections | 每个"段"的文本 + 顺序号 indexid(向量索引对齐的主键) | Statement.CREATE_SECTIONS |
documents | 整条文档的 JSON 字段(data 列) | Statement.CREATE_DOCUMENTS |
objects | 二进制对象(BLOB) | Statement.CREATE_OBJECTS |
sections 的 indexid INTEGER PRIMARY KEY 是关键:它就是向量索引里那个整数 id(schema/statement.py:62-70)。向量腿吐出的候选 indexid,靠这一列跟原文对上号。
还有两张临时表服务于单次查询:
batch——把候选 id 批量塞进来,好在 SQL 里用IN (SELECT indexid FROM batch ...)关联(CREATE_BATCH,statement.py:12)。scores——把候选的相似分批量塞进来,好在 SQL 里选score列、按它排序(CREATE_SCORES,statement.py:25)。
真实实现: 建表在 RDBMS.createtables(rdbms.py:280),临时表在会话级建立(session → createbatch/createscores,rdbms.py:277-278)。
关键细节: 一条记录若同时有 text 和 object,object 会被单独存进 objects 表、section 里存文本(loaddocument,rdbms.py:340-345);若只有 object,section 文本置空、检索时从对象解码取回(见 3.6)。