ADR-006: Trino Vector and Graph Pushdown via Table Functions¶
Status: Implemented — promoted from
plans/trino_vector_pushdown.mdTarget: Trino 482-483 (band
d) and 484+ (bande); seeTRINO_VERSION_BANDS.mdfor the supported version policy. The 468-era discussion below predates the two-band split and is retained as rationale.Owner surface:
trino-benostreamdb/,src/core/ffi.rs,docs/integrations/TRINO.md
1. Decision¶
The Trino connector is not blocked by a Trino limitation. It is missing three SPI surfaces that Trino already supports:
Index DDL via
Connector.getProcedures()andCALL ...system.add_index(...).Vector search via
Connector.getTableFunctions()and asystem.vector_search(...)table function.Graph table functions via the same table-function surface, with a multi-table exception for
graph_rag_search.
applyTopN is not the right mechanism: Trino’s
io.trino.spi.connector.SortItem exposes only a column name and sort order, so
it cannot carry dist_cosine(embedding, ...). The table-function route is the
correct one.
2. What the code does today¶
BenoStreamDBSplitManager.java:35builds onlySELECT * FROM tplus an optionalWHEREfromapplyFilter.AbstractBenoStreamDBMetadata.java:268is the only pushdown hook implemented.BenoStreamDBJNIBridge.java:30declaresvectorSearch, but no Java code calls it.There is no
getProcedures,getTableFunctions, orgetFunctionProvider.TRINO.md:87marks vector pushdown as planned, whileTRINO.md:39claims graph table functions that the connector does not expose. The docs are ahead of the code in both directions.
3. Target SQL surface¶
-- One-time index build (blocking for the duration of the build)
CALL benostreamdb.system.add_index('default.docs', 'embedding', 'hnsw');
CALL benostreamdb.system.add_index('default.docs', 'text', 'bm25');
CALL benostreamdb.system.build_index('default.docs', 'embedding');
-- Vector search
SELECT *
FROM TABLE(benostreamdb.system.vector_search(
table => 'default.docs',
column => 'embedding',
query => '[0.10, 0.50, 0.85]',
k => 10,
filter => 'category = ''science'''
));
-- Graph traversal (single edge table; Phase 3)
SELECT * FROM TABLE(benostreamdb.system.graph_neighbors(
table => 'default.doc_links',
node => '101',
hops => 2
));
query is a JSON array string for the MVP. A typed ARRAY(DOUBLE) argument is
a follow-up once the table-function plumbing is proven.
4. Architecture¶
flowchart LR
A[Trino SQL] --> B[ConnectorTableFunction.analyze]
B --> C[VectorSearchHandle]
C --> D[ConnectorMetadata.applyTableFunction]
D --> E[BenoStreamDBTableHandle with pushdownSql]
E --> F[SplitManager]
F --> G[openQuery tableUri sql]
G --> H[DataFusion KNN plan]
H --> I[Arrow batches]
I --> J[PageSource]
The engine already plans KNN from ORDER BY dist_cosine(...) LIMIT k; the
connector only has to produce that SQL and carry it to the split.
5. Phase 0: truth-in-docs (0.5 day)¶
Files: docs/integrations/TRINO.md
Remove the graph traversal section until Phase 3 ships.
Replace “Vector Indexes: (Planned)” with a supported/roadmap split.
Document the actual current surface: scans, scalar predicate pushdown, writes, MERGE, schema/table listing.
Correct the stale comments in
BenoStreamDBSplitManager.java:13andBenoStreamDBPageSource.java:21that claim vector pushdown.
This is a standalone fix and should land before any article or connector claim.
6. Phase 1: index DDL (2–3 days)¶
6.1 Java native method¶
Add to BenoStreamDBJNIBridge.java:
public static native boolean addIndex(String tableUri, String column, String algorithm);
public static native boolean buildIndex(String tableUri, String segmentId);
6.2 Rust exports¶
Add to src/core/ffi.rs, copying the Spark implementation at
ffi.rs:468 and changing the symbol to the Trino
class:
#[no_mangle]
pub extern "system" fn Java_com_benostreamdb_trino_BenoStreamDBJNIBridge_addIndex(
mut env: JNIEnv,
_class: JClass,
table_uri: JString,
column: JString,
index_category: JString,
) -> jboolean { /* Table::new_async + table.add_index */ }
build_index mirrors
Java_com_benostreamdb_spark_jni_BenoStreamJNIBridge_buildIndex.
6.3 Procedure class¶
New BenoStreamProcedures.java:
public final class BenoStreamProcedures {
private final String warehouse;
public void addIndex(ConnectorSession session, String table, String column, String algorithm) {
String uri = BenoStreamDBTableUri.resolve(warehouse, table);
if (!BenoStreamDBJNIBridge.addIndex(uri, column, algorithm)) {
throw new TrinoException(GENERIC_INTERNAL_ERROR,
"BenoStreamDB add_index failed for " + table + "." + column);
}
}
}
Add BenoStreamDBTableUri.resolve(warehouse, "schema.table"); pass through
values that already contain ://.
6.4 Connector registration¶
In the inner BenoStreamDBConnector in
BenoStreamDBConnectorFactory.java:29,
implement:
@Override
public Set<Procedure> getProcedures() {
MethodHandle add = MethodHandles.lookup().findVirtual(
BenoStreamProcedures.class, "addIndex",
MethodType.methodType(void.class, ConnectorSession.class,
String.class, String.class, String.class))
.bindTo(procedures);
return Set.of(new Procedure("system", "add_index",
List.of(new Procedure.Argument("table", VARCHAR),
new Procedure.Argument("column", VARCHAR),
new Procedure.Argument("algorithm", VARCHAR, false, "vector")),
add));
}
Verified SPI: Connector.getProcedures() returns
Set<io.trino.spi.procedure.Procedure>; the constructor is
(String schema, String name, List<Argument>, MethodHandle). Confirm the
4-argument Procedure.Argument parameter order against the 468 source before
coding.
6.5 Operational note¶
add_index is synchronous and can block on a large table. Document that large
builds should use the Python/CLI batch path or build_index with a maintenance
window. Consider a system.show_indexes procedure in the same phase.
7. Phase 2: vector search table function (4–6 days)¶
7.1 Handle¶
public final class VectorSearchHandle implements ConnectorTableFunctionHandle {
private final String table;
private final String column;
private final String queryJson;
private final long k;
private final Optional<String> filter;
// @JsonCreator / @JsonProperty, equals, hashCode
}
7.2 Function¶
New VectorSearchFunction implements ConnectorTableFunction:
getSchema()→"system"getName()→"vector_search"Arguments:
tableVARCHAR,columnVARCHAR,queryVARCHAR,kBIGINT,filterVARCHAR (optional)getReturnTypeSpecification()→DescribedTableanalyze(...):Validate identifiers against the table schema (no raw SQL identifiers).
Parse
queryas a bounded JSON array of doubles.Resolve the table schema and build a
Descriptorof all table columns plusdistance DOUBLE.Return
TableFunctionAnalysis.builder().returnedType(descriptor).handle(handle).build().
Verified SPI: Descriptor.descriptor(List<String>, List<Type>),
TableFunctionAnalysis.builder().returnedType(...).handle(...).build().
7.3 Metadata¶
Implement in BenoStreamDBMetadata:
@Override
public Optional<TableFunctionApplicationResult<ConnectorTableHandle>> applyTableFunction(
ConnectorSession session, ConnectorTableFunctionHandle functionHandle) {
VectorSearchHandle h = (VectorSearchHandle) functionHandle;
List<ColumnHandle> columns = tableColumns(h.table());
columns.add(new BenoStreamDBColumnHandle("distance", DOUBLE));
String sql = VectorSearchSql.build("t", h);
return Optional.of(new TableFunctionApplicationResult<>(
new BenoStreamDBTableHandle(schema, table, Optional.empty(), Optional.of(sql)),
columns));
}
7.4 Table handle and split¶
Add an optional pushdownSql to BenoStreamDBTableHandle, including JSON
creator, equals, and hashCode.
getSplits chooses:
String sql = handle.getPushdownSql()
.orElseGet(() -> filter.isEmpty() ? "SELECT * FROM t" : "SELECT * FROM t WHERE " + filter);
applyFilter must compose with pushdownSql: either append the new predicate to
the handle’s filterString and let getSplits build the final SQL, or skip
filter pushdown once a KNN query is present. Do not silently overwrite the KNN
SQL.
7.5 SQL builder¶
SELECT *,
dist_cosine(embedding, ARRAY[0.10, 0.50, 0.85]) AS distance
FROM t
WHERE category = 'science'
ORDER BY dist_cosine(embedding, ARRAY[0.10, 0.50, 0.85])
LIMIT 10
Rules:
Column and table identifiers are validated against the resolved schema.
filteris passed as a raw SQL predicate because Trino already produced it; for the table-function argument, reject;and comments.kis clamped (for example, 1–10,000).Use the distance function directly in
ORDER BY; do not rely on thedistancealias for the KNN optimizer.Verify
Table::sqlregisters the table ast(current split SQL assumes it).
7.6 Column mapping¶
BenoStreamDBPageSource maps Arrow fields by name. The synthetic distance
column must be present in both the handle’s column list and the Arrow result.
Add a test that projects id, title, and distance.
7.7 Registration¶
Implement Connector.getTableFunctions() on BenoStreamDBConnector and return
the vector and (later) graph functions.
8. Phase 3: graph table functions (3–5 days)¶
Single-edge-table functions are feasible with the existing openQuery path:
graph_neighbors,graph_shortest_path,graph_subgraphThe table function receives the edge-table URI.
getSplitsopensSELECT * FROM graph_neighbors('t', ...), using the aliastthatopenQueryregisters.
Gate: confirm that Table::sql registers the table as t for all query shapes,
and that the graph UDF resolves 't' from the session catalog. If not, add a
JNI variant that registers the table under its basename.
graph_rag_search needs both the edge table and the document table. Landed as
the first option: Table::sql_multi(tables, sql) in the core plus
openQueryMulti(tablesJson, sql) over JNI, with the alias map carried in the
split. The connector’s system.graph_rag_search builds
SELECT d.*, g.hop FROM t d JOIN graph_neighbors('e', ...) g ON d.<id> = g.node ORDER BY dist_cosine(...) LIMIT k.
Covered by tests/integration_test_sql_multi.rs (two tables in one statement)
and the in-engine graph_rag_search test.
9. Phase 4: hybrid RRF (8–12 days)¶
hybrid_search exists as a Python/table API, not as a SQL table function. The
correct layering is:
Add a core SQL table function
hybrid_search(...)insrc/core/sql/, registered next to the graph UDFs insession.rs:149, backed byTable::hybrid_search.Expose it as a Trino table function that opens
SELECT * FROM hybrid_search('t', ...).
This also benefits Flight SQL, Spark, and dbt. Implementing RRF in Java by running two queries and fusing ranks would duplicate core logic and lose snapshot consistency; reject that option.
10. File-level change list¶
New
trino-benostreamdb/src/main/java/com/benostreamdb/trino/BenoStreamProcedures.javatrino-benostreamdb/src/main/java/com/benostreamdb/trino/VectorSearchFunction.javatrino-benostreamdb/src/main/java/com/benostreamdb/trino/VectorSearchHandle.javatrino-benostreamdb/src/main/java/com/benostreamdb/trino/VectorSearchSql.javatrino-benostreamdb/src/test/java/...(SQL builder, handle JSON, arguments)
Modified
BenoStreamDBConnectorFactory.java—getProcedures,getTableFunctionsAbstractBenoStreamDBMetadata.java—applyTableFunction, shared schema resolverBenoStreamDBTableHandle.java—pushdownSqlBenoStreamDBSplitManager.java— preferpushdownSqlBenoStreamDBJNIBridge.java—addIndex,buildIndexsrc/core/ffi.rs— Trino JNI exportsdocs/integrations/TRINO.md— supported surface and roadmap
11. Test matrix¶
Test |
Type |
Assertion |
|---|---|---|
SQL builder |
unit |
correct identifiers, escaped filter, clamped k |
Handle JSON |
unit |
round-trip across worker serialization |
Procedure args |
unit |
required/optional names and defaults |
Descriptor |
unit |
table columns plus |
KNN pushdown |
integration |
split SQL contains |
End-to-end |
integration |
|
Filter + KNN |
integration |
predicate appears before |
Graph |
integration |
|
Failure path |
integration |
missing native lib, invalid column, bad vector JSON |
Integration harness: build the native library, package the plugin with
mvn -DskipTests package, run Trino 468 in Docker with the plugin and catalog,
create a small table via the Python SDK, then run the SQL above. Capture
EXPLAIN output to prove pushdown.
12. Risks and open questions¶
SPI drift 435 → 468. Only 435 is cached locally. Verify
Procedure.Argumentconstructor order,ScalarArgumentSpecification.Buildermethods, and table-function syntax against 468 before coding.Table::sqlalias. Confirm it registers the table ast; the current split SQL depends on it.Descriptor typing. The returned type must match the Arrow schema exactly, including
DOUBLEfordistance.Serialization. The table-function handle travels to workers; it must be Jackson-serializable with a stable shape.
Native packaging. New JNI exports require rebuilding
libbenostreamdband shipping it with the plugin; symbol names must matchcom.benostreamdb.trino.BenoStreamDBJNIBridge.Procedure blocking. Index builds on large tables can exceed Trino call timeouts; document the out-of-band path.
SQL injection. Table, column, filter, and vector inputs need validation; never concatenate unvalidated identifiers.
Docs claims. The current graph and vector statements are ahead of the code; Phase 0 must land first.
13. Estimates¶
Phase |
Scope |
Estimate |
|---|---|---|
0 |
Correct docs and stale comments |
0.5 day |
1 |
|
2–3 days |
2 |
|
4–6 days |
3 |
Single-table graph functions |
3–5 days |
3b |
|
3–5 days |
4 |
Core |
8–12 days |
MVP (Phases 0–2): roughly 1.5–2 weeks. Full parity: roughly 4–5 weeks.
14. Article implications¶
Article 3 stays on the Python SDK. It does not depend on this work.
The Trino deep dive should publish only after Phase 2, and should be titled around what ships: vector search in SQL, not Graph RAG.
Until then, the honest Trino piece is “Query Iceberg from Trino” covering scans, filters, writes, and MERGE.
Phase 0 can land immediately and removes the current false claims.