跳到主要内容

SQL 驱动检索与关系数据库层(content store)

30 秒导读: 光有向量索引,你只能问"哪些东西跟这句话最像",拿回一串 (id, 分数)。txtai 的这一层让你能改问:"跟这句话像、并且 entry > '2021-01-01'、按 score 倒序、只要前 5 条,把 textlength 一起给我"——用几乎标准的 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
Scansimilar() 参数桥接到向量 index,搜出候选 idembeddings/search/scan.py:6
SQL类 SQL 词法 + 语法:tokenize / parse / issqldatabase/sql/base.py:11
Expression逐 token 改写:列名解析、抽 similar() 换占位符database/sql/expression.py:8
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/

主线走一遍(高层)

  1. 判路由。 Search.__call__ 发现有 content store 且不是纯 index 模式,就走 dbsearch(base.py:79-80)。
  2. 解析。 每句查询过 Database.parse 拆成子句 dict;若不是 SQL(纯自然语言),包成 {"similar": [[query]]}(sql/base.py:70)。
  3. 扫描。 Scan 取出所有 similar() 子句,按目标 index 分组,调向量搜索拿候选(scan.py:37)。
  4. 缝合。 Database.search 把候选 id 的 IN 子句替换进 WHERE 里的 __SIMILAR__ 占位符(base.py:150-158)。
  5. 执行取回。 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

sectionsindexid 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)。

关键细节: 一条记录若同时有 textobject,object 会被单独存进 objects 表、section 里存文本(loaddocument,rdbms.py:340-345);若只有 object,section 文本置空、检索时从对象解码取回(见 3.6)。

3.2 判断"这是不是 SQL",以及不是时怎么办

它要解决的小问题: 用户传进来的既可能是 select * from txtai where ...,也可能是裸的一句"气候政策"。得先分清。

思路: SQL.issql 用一个很克制的规则识别:小写去空白后,必须以 select 开头,且包含 from txtai(或以 from txtai 结尾)。

# database/sql/base.py:98-103,真实逻辑
query = query.lower().strip(";").replace("\n", " ").replace("\t", " ").strip()
return query.startswith("select ") and (" from txtai " in query or query.endswith(" from txtai"))

txtai 是固定的虚拟表名——你在 SQL 里写的 from txtai,并不对应任何真实表,只是"这是一句 txtai 查询"的信号。

不是 SQL 时的兜底: SQL.__call__ 直接把整句包成一个单元素 similar 子句:

# database/sql/base.py:70,真实逻辑
return clauses if clauses else {"similar": [[query]]}

也就是说,裸自然语言查询 = 一句只有 similar() 的隐式 SQL。这让下游只需处理一种结构。

还有一层: 若配了 query 翻译模型(自然语言转 SQL),Search.parse 会在解析出的结果里没有 select 时,把查询喂给模型再解析一遍(embeddings/search/base.py:263-270)。

3.3 把类 SQL 翻成后端 SQL:tokenize → 逐 token 改写

它要解决的小问题: 用户写的列名(如 lengthentry、自定义字段)不一定是真实数据库里的列名;similar() 是自造函数,真实数据库不认识。得做一遍翻译。

思路:分两步——先切词,再逐 token 改写。

第一步 tokenize 用 Python 标准库 shlex 搭了个极简 SQL 词法器,把标点当独立 token 好识别运算符,并把 :@# 加进 wordchars(为了绑定参数 :name):

# database/sql/base.py:136-139,真实逻辑
tokens = shlex(StringIO(query), punctuation_chars="=!<>+-*/%|")
tokens.wordchars += ":@#"
tokens.commenters = ""
tokens = list(tokens)

同时记录每个子句关键词(select/where/group/...)的位置,对 group/order 还要求下一个 token 是 by(tokenize,base.py:145-147)。子句边界就靠这些位置切(SQL.parse,base.py:172-181)。

第二步改写 交给 Expression.process 逐 token 走一遍状态机,识别每个 token 是列、函数、similar()、别名、还是复合比较,分别处理(expression.py:52-105)。核心的三件事:

  1. 列名解析 → 调 database.resolve(见 3.4)。
  2. similar() 抽参 + 占位 → 见 3.5。
  3. 别名生成select length(text) length 里的 length 记进 aliases,后续不再当列名解析(alias,expression.py:321)。

产出: 一个子句 dict,形如:

{"select": "s.id, text, length(text) as \"length\", score",
"where": "__SIMILAR__0 and entry >= '2021-01-01'",
"orderby":"score desc",
"limit": "5",
"similar":[["气候政策"]]}

similar() 已被换成占位符 __SIMILAR__0,原始参数被抽到 similar 列表里等着桥接。

3.4 列名解析:一列到底落到哪张表

它要解决的小问题: 你写 entrytextscore、或任意自定义字段 author,它们物理上分散在三张表、临时表、或 JSON 里。得把查询里的名字翻成真实列引用。

思路: RDBMS.resolve 是一张查找表式的分派(rdbms.py:132-163):

你写的列解析成为什么
id/tags/entry/indexids.<name>(sections 表)标准段列,加 s. 前缀
text/score/data/object原样分别来自 section / scores 表 / documents / objects,已定位
自定义列(如 author)json_extract(data, '$.author')落进 documents 的 JSON data
注册的表达式名展开成表达式 SQL见 3.7

JSON 动态列是关键设计: 你 index 时给的任意字段都塞进 documents.data 这个 JSON 列,查询时按需用 json_extract 抽出来。这让 schema 无需预定义——加字段不用改表。不同后端的 JSON 语法不同,由各后端覆写 jsoncolumn:

后端JSON 抽取表达式位置
SQLite/DuckDB(embedded)json_extract(data, '$.name')embedded.py:61-63
DuckDBjson_extract_string(data, '$.name')duckdb.py:71-73
Client(Postgres 等)cast(d.data['name'] as Text)(SQLAlchemy 编译)client.py:77-82

别名场景: resolvealias 参数时构造 name as "alias",但会跳过与标准列同名的情况(rdbms.py:137-144)。

3.5 similar() 桥接向量扫描(本章的核心)

它要解决的小问题: similar('气候政策') 不是真实 SQL,数据库跑不了。它必须先变成"一批向量搜出来的候选 id",再以 IN (...) 的形式进 SQL。这就是 index 腿和 database 腿的接缝

思路分三小步。

① 抽参 + 占位(解析期)。 Expression.similar 读掉 similar(...) 的所有参数,把这个函数调用整体换成占位符 __SIMILAR__N:

# database/sql/expression.py:274-278,真实逻辑
# Add placeholder for embedding similarity results
tokens[x] = f"{Token.SIMILAR_TOKEN}{len(similar)}"
# Save parameters
similar.append(params)

SIMILAR_TOKEN 就是字符串 "__SIMILAR__"(token.py:12)。一句里可以有多个 similar(),分别编号 0、1、2。参数不止查询文本——还能带候选数、权重、目标子索引名(见下)。

② 向量扫描产候选(index 腿)。 Scan 把所有 similar 子句取出,包成 Clause 对象,按目标 index 分组后调用 Search.search 跑真正的向量检索:

# embeddings/search/scan.py:72-74,真实逻辑
for x, result in enumerate(self.search([query.text for query in iqueries], candidates, weights, index)):
# Save query id and results to later join to original query
results[iqueries[x].uid] = (iqueries[x].qid, result)

Clause 负责解析 similar() 的可选参数:纯数字 → 候选数 candidates,带小数点 → 融合权重 weights,其余字符串 → 目标子索引名(scan.py:177-196)。所以 similar('nlp', 100, 0.7, 'subindex') 意为"在 subindex 上,取 100 个候选,融合权重 0.7"。

候选数怎么定? 若没显式给,Scan.default 有个巧思:单条过滤子句就用 limit 个候选;多条 WHERE 条件时取 limit * 10,因为过滤会淘汰一部分,得多捞点才够填满 limit(scan.py:135-151):

# embeddings/search/scan.py:150-151,真实逻辑
multitoken = any(query.get("where") and len(query["where"].split()) > 1 for query in queries)
return self.limit * 10 if multitoken else self.limit

③ 候选注回 WHERE(database 腿)。 回到 Database.search,把每个 __SIMILAR__N 占位符替换成一个真实的 IN 子句:

# database/base.py:150-158,真实逻辑
if "select" in query and similarity:
for x in range(len(similarity)):
token = f"{Token.SIMILAR_TOKEN}{x}"
if where and token in where:
where = where.replace(token, self.embed(similarity, x))
elif similarity:
# Not a SQL query, load similarity results, if any
where = self.embed(similarity, 0)

embed 干两件事:把候选 id 灌进临时 batch 表,并返回占位子句(rdbms.py:165-174):

# database/rdbms.py:165-174,真实逻辑
def embed(self, similarity, batch):
# Load similarity results id batch
self.batch(indexids=[i for i, _ in similarity[batch]], batch=batch)
# Average and load all similarity scores with first batch
if not batch:
self.scores(similarity)
# Return ids clause placeholder
return Statement.IDS_CLAUSE % batch

其中 IDS_CLAUSE 就是那个把候选桥接进 SQL 的关键片段:

-- database/schema/statement.py:98
s.indexid in (SELECT indexid from batch WHERE batch=0)

至此接缝闭合: where similar('气候政策') and entry >= '2021-01-01' 最终变成 where s.indexid in (SELECT indexid from batch WHERE batch=0) and entry >= '2021-01-01'。 向量的"模糊"结果,以一个纯 SQL 的 IN 集合出现,数据库只当普通条件处理。

3.6 拼装并执行:JOIN 四张表,取回 content

它要解决的小问题: 子句都齐了,还要拼成一句真实数据库能跑的 SELECT,并把分数、原文、对象一次性缝在一起。

思路: RDBMS.queryTABLE_CLAUSE 起手,它固定 LEFT JOIN 三张附属表:

-- database/schema/statement.py:92-97,TABLE_CLAUSE
SELECT %s FROM sections s
%s documents d ON s.id = d.id -- %s 是 JOIN 或 LEFT JOIN
LEFT JOIN objects o ON s.id = o.id
LEFT JOIN scores sc ON s.indexid = sc.indexid
  • sections 是主表(有 indexidtext)。
  • documents 提供 JSON 字段;objects 提供二进制;scores 提供相似分。
  • 一个优化: 若 WHERE/GROUP/ORDER 里用到了 JSON 列,documents 用内 JOIN(能吃索引),否则 LEFT JOIN(不丢无字段的行)(rdbms.py:190)。

然后逐子句追加 WHERE / GROUP BY / HAVING / ORDER BY,并处理默认排序和 limit:

# database/rdbms.py:203-213,真实逻辑
# Default ORDER BY if not provided and similarity scores are available
if similarity and orderby is None:
query += " ORDER BY score DESC"
if qlimit is not None or limit:
query += f" LIMIT {qlimit if qlimit else limit}"
if offset is not None:
query += f" OFFSET {offset}"

即:只要用了 similar() 又没写 ORDER BY,就默认按相似分倒序——符合"最像的排前面"的直觉。

执行后,把每行游标结果映射成 dict(列名 → 值),遇到 object 列还会用编码器解码(rdbms.py:224-240)。若调用方要的是 indexids(纯 (indexid, score),用于图/子索引场景),则只投影这两列(rdbms.py:186-187243)。

limit 的一处协调: Search.dbsearch 会先把默认 limit 和查询里最大的 LIMIT 取较大值,好让 index 腿捞够候选(base.py:229limit()base.py:276)。

3.7 聚合、自定义函数与表达式

聚合(COUNT/SUM/AVG…)。 单库时聚合直接由后端 SQL 完成。但当索引被分片(多个 shard 各出一份局部结果)时,得在 Python 侧合并——这是 Aggregate 的活(sql/aggregate.py:11)。它识别 count(/sum(/max(/min(/avg( 前缀列,按 group by 分组后合并,再按 order by 排序(aggregate.py:51-113)。avgsum(x)/len(x)(aggregate.py:72)。

自定义函数。 你可以在配置里注册 Python 函数,在 SQL 里当函数用。基类只记录函数元信息(名字/参数个数/是否确定性),真正注册交给后端:

# database/base.py:284-288,真实逻辑
# Store function details
functions.append((name, argcount, fn, deterministic))
self.functions = functions
  • SQLite 用 connection.create_function(sqlite.py:38-39)。
  • DuckDB 需要类型提示、且先查重不重复建(duckdb.py:158-177)。
  • Client(SQLAlchemy)则是 no-op,函数得在数据库侧自备(client.py:155-156)。

自定义表达式。 给一段 SQL 片段起个名字,查询里当列用。注册时先用 SQL.snippet 把片段解析成规范表达式,并可选建索引(base.py:290-308):

# database/base.py:305,真实逻辑
expressions[name] = {"expression": self.sql.snippet(expression), "index": entry.get("index", False)}

resolve 命中表达式名时直接展开成那段 SQL(rdbms.py:147-148);若标了 index:True,建表时会为它建一个表达式索引(createindexes,rdbms.py:290-305)。


4. 深入实现(后端与编解码)

4.1 四种后端,一套骨架

继承链把"关系数据库的通用逻辑"和"具体方言"分开:

Database (base.py:15 抽象:search/parse/embed/query/configure)
└── RDBMS (rdbms.py:13 通用 SQL 组装、临时表、增删改)
├── Embedded (embedded.py:8 本地文件库:save/copy、json_extract)
│ ├── SQLite (sqlite.py:11 sqlite3 连接、create_function)
│ └── DuckDB (duckdb.py:23 duckdb 连接、parquet 导出、命名参数改写)
└── Client (client.py:23 SQLAlchemy 接外部 Postgres/MariaDB…)

选谁由 DatabaseFactory.createcontent 配置分派:True/"sqlite" → SQLite,"duckdb" → DuckDB,URL 或 "client" → Client,其余字符串当自定义后端解析(factory.py:37-54)。

后端存储连接方式特别处
SQLite单文件sqlite3.connect可选 WAL 模式;backupiterdump 拷库(sqlite.py:41-58)
DuckDB单文件duckdb.connect列存,适合分析型聚合;拷库走 parquet 导入导出(duckdb.py:86-124)
Client外部服务器SQLAlchemy engine支持任何有 JSON 的库;自造 Cursor 兜 DB-API(client.py:172)

4.2 DuckDB 的命名参数改写(一个真实坑)

DuckDB 不支持 :name 命名参数,只认 ?DuckDB.formatargs 用正则把 :name 逐个换成 ?,并按它们在 SQL 里的出现位置排好值的顺序(duckdb.py:126-156):

# database/duckdb.py:147-154,真实逻辑
pattern = rf"\:{key}(?=\s|$)"
match = re.search(pattern, query)
if match:
query = re.sub(pattern, "?", query, count=1)
params.append((match.start(), value))
args = (query, [value for _, value in sorted(params, key=lambda x: x[0])])

这是"同一份类 SQL 要适配多种方言"必然要付的税。

4.3 对象列的编解码

objects 表存 BLOB,进出都要过编码器(database/encoder/),由配置 objects 选类型(base.py:236-237,EncoderFactory):

编码器处理什么怎么做位置
Encoder(基类)裸字节原样进,decode 包成 BytesIOencoder/base.py:8
ImageEncoderPIL 图像按原格式压回字节;decodeImage.openencoder/image.py:18
SerializeEncoder任意 Python 对象走内部序列化(pickle 等)encoder/serialize.py:10

RDBMS.query 映射结果时,遇到 object 列即调 encoder.decode 把 BLOB 还原成对象(rdbms.py:235-236)。

4.4 SQLAlchemy ORM 骨架(Client 专用)

schema/orm.py 用 SQLAlchemy 声明式映射把那几张表定义成类:Document/Object/Section,以及临时表 Batch/Score(orm.py:33-99)。id 列用 String(512),在 sqlite/postgresql 上变体成无界 Text(idcolumn,orm.py:18-26)。Client 后端靠这套 ORM 在外部数据库里建表、插数据(client.py:84-123)。SectionBase 是抽象基类,好在 reindex 时动态造多张 section 表(orm.py:81-92client.py:59-67)。


5. 巧妙之处(可借鉴的技术)

  • 虚拟表名 txtai 当路由信号。 不解析真实 schema,仅凭 from txtai 就判定"这是给我的查询",极省事(sql/base.py:103)。
  • 占位符 + 临时表桥接两套系统。 向量结果不硬编码进 SQL 字符串,而是灌进临时 batch 表、用 IN (SELECT ... FROM batch) 引用——既避开注入,又让数据库能用索引(base.py:150-158statement.py:98)。
  • JSON 动态列 = 免 schema。 任意字段塞进 data JSON 列,查询时 json_extract 按需抽取,加字段零迁移(rdbms.py:163embedded.py:61)。
  • 多条件时多捞候选。 limit*10 的启发式,预判过滤会淘汰候选,避免"过滤完不够 limit 条"(scan.py:150-151)。
  • 默认按相似分排序。 用了 similar() 又没写 ORDER BY 就自动 score DESC,贴合直觉(rdbms.py:204-205)。
  • 一条 similar() 可带候选数/权重/子索引。 位置参数按类型自动归类,无需关键字(scan.py:177-196)。

6. 边界与局限

  • issql 很朴素。 仅认 select ... from txtai;没有 INSERT/UPDATE/DELETE 的用户 SQL,写数据只能走 embeddings.index/upsert(sql/base.py:87)。
  • 只支持单条 from txtai、忽略多语句。 ; 后的部分被丢弃(sql/base.py:46)。
  • Client 后端不托管自定义函数。 addfunctions 是 no-op,函数得在目标数据库侧预先存在(client.py:155)。
  • 分片聚合在 Python 侧合并,能力有限。 Aggregate 只覆盖 count/sum/avg/min/max 与 group by/order by,复杂 HAVING/嵌套聚合不在其列(aggregate.py:51-72)。
  • similar() 的候选上限即天花板。 数据库只能在向量腿捞回的候选集内过滤;若真正命中的记录没进候选,WHERE 再精确也找不回来——调大候选数是唯一补救。
  • 不涉及图检索。 关系遍历/主题建模是另一套子系统,见 06-graph.md

7. 横向对比

同 shelf 里,把"向量相似"与"结构化过滤"结合的做法各不相同:

方案结合方式与 txtai 的差异
txtai content store类 SQL + similar() 桥接到临时表 IN向量腿先产候选,SQL 后过滤;免 schema JSON 列
专用向量库(如 Qdrant/Weaviate 风格)payload filter 在向量搜索内部同时施加过滤与搜索同层,不是"先搜后 SQL 过滤"
pgvector 类向量作为 Postgres 一列,纯 SQL无桥接层,ORDER BY 距离即检索;txtai 反过来把候选注回 SQL

txtai 的取舍是复用成熟关系数据库的一切(过滤、排序、聚合、JOIN、事务),向量索引只做"产候选"这一件事。代价是过滤只能在候选集内进行(见第 6 节)。分数如何在稠密/稀疏间融合,属另一层,见 04-search-and-fusion.md


8. 代码地图(导航索引)

主题文件路径符号
检索总编排(解析→扫描→缝合)src/python/txtai/embeddings/search/base.pySearch.dbsearch Search.parse Search.limit
similar() 桥接向量 indexsrc/python/txtai/embeddings/search/scan.pyScan Scan.default Clause
SQL 词法/语法/识别src/python/txtai/database/sql/base.pySQL.issql SQL.tokenize SQL.parse SQL.snippet
逐 token 改写/抽 similar/列解析src/python/txtai/database/sql/expression.pyExpression.process Expression.similar
token 类型与 SIMILAR_TOKENsrc/python/txtai/database/sql/token.pyToken.SIMILAR_TOKEN Token.issimilar
分片聚合合并src/python/txtai/database/sql/aggregate.pyAggregate.aggcolumns Aggregate.aggregate
候选注回 WHERE / 配置 / 注册函数src/python/txtai/database/base.pyDatabase.search Database.configure Database.registerfunctions Database.registerexpressions
组装真实 SQL / 列解析 / embedsrc/python/txtai/database/rdbms.pyRDBMS.query RDBMS.resolve RDBMS.embed RDBMS.scores
SQL 模板常量src/python/txtai/database/schema/statement.pyStatement.TABLE_CLAUSE Statement.IDS_CLAUSE Statement.CREATE_SECTIONS
ORM 表映射(Client)src/python/txtai/database/schema/orm.pyDocument Object Section SectionBase
后端:本地文件src/python/txtai/database/embedded.pyEmbedded.jsoncolumn Embedded.save
后端:SQLitesrc/python/txtai/database/sqlite.pySQLite.connect SQLite.addfunctions
后端:DuckDBsrc/python/txtai/database/duckdb.pyDuckDB.formatargs DuckDB.loadfunctions
后端:外部服务器src/python/txtai/database/client.pyClient.jsoncolumn Cursor
后端选择src/python/txtai/database/factory.pyDatabaseFactory.create
对象列编解码src/python/txtai/database/encoder/Encoder ImageEncoder SerializeEncoder