Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`searchable_columns=` on `make_hotdata_tools`, so the SQL tool can name every indexed
column rather than only the one the search tool ranks.** Takes `(table, column)` pairs written
`catalog.schema.table`, confirms each against the control plane when the tools are built, and
drops with a warning any a ready index does not cover. Every confirmed column gets its own
worked `bm25_search(...)` (or `vector_search(...)`) call in the description. Order carries:
the first is the one a model reaches for most, so lead with the table most questions are
about. `SearchableColumn` and `verify_searchable_columns` are exported for callers assembling
descriptions themselves.

### Fixed

- **The SQL tool no longer claims the registered column is the *only* indexed one.** It said
"the BM25-indexed column is `<column>` on `<table>`" on the strength of what the caller had
wired to the search tool, which is a statement about one tool's configuration and not about
the database. On any database indexing more than one column that sentence was false, and it
was measured being followed in preference to what `hotdata_describe_tables` reports — so the
model searched a table the answer was not about and returned a confident, wrong number. It now
says that column *has* an index, which is what the caller actually told us.

Measured across 84 runs on one model (`gpt-5.1`), one dataset and one question, asking an
aggregate whose numbers lived on a table other than the registered corpus. With the corpus
named alone the model searched the right table in 0 runs of 12; declaring the other column
through `searchable_columns=` took that to 7 of 12 (Fisher exact p = 0.005). Composition rose
7 of 12 to 10 and the `ILIKE` fallback fell 4 of 12 to 2, but neither is significant at this
sample size (p = 0.37 and p = 0.64) — read them as direction, not as result.

Three things this does *not* fix. Naming a column without giving it its own worked call moved
almost nothing (2 of 12). The gain is much weaker without a prior `hotdata_describe_tables`
call in the thread, which is the likelier shape of a single-question session (2 of 6 cold
against 5 of 6 warm). And the cohort size a model asks for is still an arbitrary `k`, so the
answers themselves remain wrong for a different reason — 0 of 84 runs across every wording
produced the correct figure ([#62](https://github.com/hotdata-dev/hotdata-langchain/issues/62)).

## [0.13.0] - 2026-08-26

### Added
Expand Down
42 changes: 39 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,9 +264,12 @@ Rows come back ranked, each with a `score`. The agent supplies only `query` and
`k`; the table and column are fixed when you build the tool. That is deliberate: the engine
errors outright rather than falling back to a scan when a column has no BM25 index, so a
model choosing its own corpus can pick one that cannot answer. `hotdata_describe_tables` now
reports which columns are searchable, which is what a model would need to choose from
something it read rather than guessed — the search tool has not been changed to accept that
choice yet.
reports which columns are searchable, which is what a model would need to choose from something
it read rather than guessed. The search tool has not been changed to accept that choice, and the
annotation on its own was measured not to change what a model searches: over 24 runs it never
moved the table chosen, including runs where the model had already read it and then matched with
`ILIKE` on that same column. What moved the choice was naming the column in a worked call, which
is what `searchable_columns=` below does.

### Text or meaning, decided by the index

Expand Down Expand Up @@ -359,6 +362,39 @@ same rows, but the engine matches its index lookup on the reference as written,
form can quietly forfeit an index. `HotdataVectorStore` and the search tool emit the full form
themselves.

### Telling the model about the other indexed columns

`search_table`/`search_column` describe the corpus the *search tool* ranks over. A database
usually indexes more than one column, and the SQL tool's description is the only place a model
learns the others exist — so name them:

```python
tools = hl.make_hotdata_tools(
client,
database_id="dbid...",
search_table="default.public.listing_corpus", # what the search tool ranks
search_column="content",
searchable_columns=[ # what SQL can also rank
("default.public.listings", "description"),
],
)
```

Each pair is confirmed against the control plane when the tools are built, and one no ready
index covers is dropped with a warning rather than named — BM25 has no brute-force fallback, so
a column offered wrongly is a hard error at the point the model has already committed to the
route. Every confirmed column gets its own worked call in the description.

**Order carries.** A model writes the call it is shown and largely ignores columns it is only
told about: over 84 runs on one model, one dataset and one question, naming a second indexed
column moved the table it searched in 2 runs of 12, while giving that column its own worked call
moved it in 8. So put the table most questions are about first.

This narrows the failure rather than removing it. The right table depends on the question and a
description is written once, so declaring the fact table took the right-table rate from 0 of 12
to 7 of 12 (Fisher exact p = 0.005) rather than to 12 of 12 — and most of that gain needs a
`hotdata_describe_tables` call earlier in the thread (5 of 6 with one, 2 of 6 without).

For more than one searchable corpus, build the tools yourself and give each a distinct name
and description — the agent then routes on the descriptions:

Expand Down
13 changes: 11 additions & 2 deletions docs/ai-native-layer-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,17 @@ Grouped by code surface:
provider-backed index generated; no engine change was needed. `make_hotdata_tools` takes
`tool_name_suffix=` so two tool sets no longer both register `hotdata_execute_sql`, and the
database-scoped descriptions name their database. What this unlocks is not itself done: the
search corpus is still pinned at construction, and un-pinning it is now safe because an agent
can read what is searchable rather than guess.
search corpus is still pinned at construction, and un-pinning it is now *expressible* because
an agent can read what is searchable rather than guess.

Whether an agent would act on that is a separate question, and it has since been measured
answering no. Across 24 runs on one model, `searchable_by` never once moved which table the
agent searched, including runs where the annotation was already in the thread from an earlier
describe call and the agent then wrote `ILIKE` against the very column it said was searchable.
What moved the choice was a worked call in the SQL tool's description naming that column, so
the discovery surface is true information that nothing consumes on its own. Read the finding
before designing anything that assumes an agent will look it up:
[#73](https://github.com/hotdata-dev/hotdata-langchain/issues/73).
- ~~**Tool-layer robustness**~~ ([#41](https://github.com/hotdata-dev/hotdata-langchain/issues/41)) — **done.** `with_error_feedback` and `engine_error_message` are
package API, `make_hotdata_tools(handle_errors=True)` turns the wrapping on, and both the
sync and async callables are wrapped — the async one is what LangChain actually calls in a
Expand Down
9 changes: 9 additions & 0 deletions docs/engine-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ Returns the table's columns plus a trailing `score` (Float32). Four properties s
default. Correctness is unaffected (explicit-`k` and trailing-`LIMIT` returned identical
top-3), and at 7.5k rows the cost was not measurable (40 ms vs 38 ms median), so this is a
scan-bound difference rather than an observed slowdown. Passing `k` explicitly is free, so we do.
- **Omitting the limit caps the result at 1,000, silently** (verified 2026-08-29). The fourth
argument is optional, and leaving it off is not "give me every match": on
`default.public.listings.description`, `'quiet garden'` matched 1,426 rows and the unbounded
call returned exactly 1,000. Below that ceiling the two agree — `'garden'` returned 555 either
way, and the 500-row corpus returned its whole 171-row pool — so the truncation shows up only
on the pools large enough to matter, and nothing in the result marks it. There is therefore no
way to ask for a whole match pool without already knowing its size, which is what makes an
aggregate over a relevance-defined cohort a two-step operation rather than one query. Tracked
as [#62](https://github.com/hotdata-dev/hotdata-langchain/issues/62).
- **The index is a hard prerequisite.** No brute-force fallback: a column without a BM25 index
gives `No BM25 index found on column 'name' for <conn>.public.listings`. This differs from
vector search, where the *explicit-vector* scalar UDFs (`cosine_distance(col, ARRAY[...])`)
Expand Down
4 changes: 4 additions & 0 deletions hotdata_langchain/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@
SEARCH_NOUNS,
SEMANTIC,
TEXT,
SearchableColumn,
SearchIndex,
capabilities_by_column,
fusable_vector_indexes,
generated_vector_columns,
indexes_for_column,
list_search_indexes,
search_nouns_by_column,
verify_searchable_columns,
)
from hotdata_langchain.results import (
CLIENT_WARNING_KEY,
Expand Down Expand Up @@ -109,6 +111,7 @@
"SearchIndex",
"SearchRoute",
"SearchStrategy",
"SearchableColumn",
"__version__",
"bm25_search_json",
"bm25_search_sql",
Expand Down Expand Up @@ -142,5 +145,6 @@
"suffixed_tool_name",
"vector_distance_sql",
"vector_search_sql",
"verify_searchable_columns",
"with_error_feedback",
]
86 changes: 86 additions & 0 deletions hotdata_langchain/indexes.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,92 @@ def generated_vector_columns(indexes: Sequence[SearchIndex]) -> Iterator[str]:
yield index.vector_column


@dataclass(frozen=True)
class SearchableColumn:
"""One column a ready index covers, carrying the table reference to name it by.

:class:`SearchIndex` describes an index within a table already known to the caller.
This pairs one with the three-part reference a query has to write, which is what a
tool description needs and what the index record does not carry.
"""

table: str
index: SearchIndex

@property
def column(self) -> str:
return self.index.column

@property
def kind(self) -> SearchKind:
return self.index.kind

@property
def function(self) -> str:
"""Return the table function this column is searched through."""
return "vector_search" if self.index.kind == SEMANTIC else "bm25_search"

@property
def composable(self) -> bool:
"""Report whether a query against this column can be written in SQL.

False for a plain vector index, whose query has to arrive as a vector.
"""
return self.index.kind == TEXT or self.index.embeds_query


def verify_searchable_columns(
client: HotdataClient,
*,
columns: Sequence[tuple[str, str]],
database: ManagedDatabase | None,
) -> list[SearchableColumn]:
"""Return the declared ``(table, column)`` pairs a ready index actually covers.

Declared rather than discovered, and then confirmed rather than trusted. Naming a
column a model can search is a claim about the database, and this package states one
only after reading it back — the same stance :func:`query_catalogs` takes towards the
catalog name. A pair no index covers is dropped with a warning rather than named,
because BM25 has no brute-force fallback and a search against an unindexed column is
a hard error at the point the model has already committed to the route.

One control-plane call per distinct table, not per declared column. Order is the
caller's, and it is preserved: a description that names several columns leads with
the first, which is the one a model was measured reaching for most.

Returns an empty list without ``database``, which is the scope every index listing
needs. Duplicated pairs are named once.
"""
if database is None:
return []
listed: dict[str, list[SearchIndex]] = {}
found: list[SearchableColumn] = []
seen: set[tuple[str, str]] = set()
for table, column in columns:
if (table, column) in seen:
continue
seen.add((table, column))
parts = table.split(".")
if len(parts) != 3:
raise ValueError(
f"a searchable column's table must be written catalog.schema.table, got {table!r}"
)
if table not in listed:
listed[table] = list_search_indexes(
client, table=parts[2], schema=parts[1], database=database
)
covering = indexes_for_column(listed[table], column)
if not covering:
logger.warning(
"no ready search index was found covering %r on %s; not naming it as searchable",
column,
table,
)
continue
Comment on lines +323 to +329

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this warning asserts something we don't know when the listing failed (not blocking).

list_search_indexes fails open — an unreachable control plane or a token that can't list indexes returns [] plus its own warning. Every declared column on that table then lands here and logs "no ready search index covers 'description' on d.public.listings", which is a statement about the database made on the strength of a call that didn't complete. The index may well be there.

Dropping is the right conservative behaviour either way; only the message is wrong. Distinguishing "listing returned nothing" from "listing failed" would need list_search_indexes to signal the difference, so the cheap version is softening the wording to what is actually known — e.g. "no ready search index was found covering …".

found.append(SearchableColumn(table, covering[0]))
return found


def fusable_vector_indexes(indexes: Sequence[SearchIndex]) -> list[SearchIndex]:
"""Return the plain vector indexes among ``indexes``, in listing order.

Expand Down
Loading
Loading