Skip to content

feat: full-text search and schema discovery tools for LangChain agents - #43

Merged
rohan-hotdata merged 4 commits into
mainfrom
feat/bm25-search-tool
Jul 27, 2026
Merged

feat: full-text search and schema discovery tools for LangChain agents#43
rohan-hotdata merged 4 commits into
mainfrom
feat/bm25-search-tool

Conversation

@rohan-hotdata

Copy link
Copy Markdown
Contributor

Exposes RuntimeDB's existing BM25 index as a LangChain tool, adds schema introspection, and rewrites the tool descriptions so a model can plan against the engine's real contract.

hotdata_search_text

Wraps bm25_search(...). The corpus is pinned when the tool is built rather than chosen by the model: nothing in the tool surface lets an agent discover which columns carry a BM25 index, and the engine errors outright instead of falling back to a scan when one is missing. Call make_hotdata_search_tool once per corpus to expose several — the agent then routes on the descriptions.

Three verified engine behaviours are encoded in the SQL it builds:

  • Results arrive unsorted. Rows come back in rowid order, like SQLite FTS5, so ORDER BY score DESC is explicit. Confirmed live — without it the scores came back 8.788, 8.092, 8.034, 8.254, 8.496.
  • k is passed as the fourth argument, not left to the trailing LIMIT. ORDER BY blocks limit pushdown, so a query relying on LIMIT alone lets the scan fall back to the engine's much larger default bound. Correctness is unaffected and the cost was not measurable at this corpus size, so this is a scan-bound difference rather than an observed slowdown — but passing it is free.
  • The projection is narrow by default (searched column + score). The demo table is 85 columns wide; returning all of them would flood the model's context.

hotdata_describe_tables

Reads information_schema, listing tables with their column counts or one table's columns and types, capped so a wide table cannot flood the context. Registered by default, describe_tables=False to omit.

It closes a real gap: without it an agent guesses column names off the shape of data it has already seen. In an early run it produced AVG(review_scores_rating) for a column never shown to it — correct only because the fixture is a well-known public dataset. On proprietary data that guess fails.

Tool descriptions

The substantive change, and not originally on the roadmap. Descriptions now state the dialect, the supported constructs, where to look schema up, and the two constraints that silently produce wrong calls: SQL cannot rank text, and an aggregate query must reference at least one column (COUNT(*) alone is rejected, COUNT(<column>) works).

This is not cosmetic:

SQL tool description System prompt Outcome
12 tokens explicit rules wrote to_tsvector, query rejected, run aborted
160 tokens none search → SQL, correct

Every claim was checked against a live workspace, which corrected two of my own earlier assumptions — LIKE/ILIKE do work (as substring tests, without ranking), and the aggregate rule is "must reference a column", not "ungrouped aggregates fail" (MIN/MAX/AVG ungrouped are fine). tests/test_descriptions.py pins the load-bearing wording.

The search description deliberately names no mechanism, and a test enforces that it never says bm25/vector/hnsw, so hybrid retrieval can land underneath without changing the contract the model was given.

The database tools also steer callers towards ids, since names are non-unique display labels — groundwork for #38.

Also fixes, both already broken on main

  • The README quickstart used create_tool_calling_agent/AgentExecutor, neither of which exists in LangChain v1.
  • The quickstart and examples/langchain_basic.py ran SQL with no database scope, which the API now rejects with a database is required. The example now runs.

Dependency floors

Raises hotdata-framework>=0.9.0 / hotdata>=0.8.0. The lock already carried those after #35, but the floors still allowed framework 0.4.1 — which uploads through POST /v1/files, a route the API no longer serves, so load_managed_table fails with a bare Not Found. Supersedes #31 (a subset of the same bump, since dependabot has already closed it).

Verification

109 tests, ruff and format clean, no new mypy errors (the 18 pre-existing ones are untyped test functions, unchanged).

Verified end to end against a real workspace rather than only in CI: managed database creation, a 7,535-row load, BM25 index build, direct search, and a full agent loop with LangSmith tracing. That live path is how the framework 0.4.1 breakage was found — no test in the repo touches the upload route.

Repeating the agent step five times: the search tool was called 5/5 and the schema tool 4/5, so the routing behaviour is reliable. The final prose answer was right in 4/5 — one run aggregated over the matched listings instead of all listings in the matched neighbourhoods. That is a model property rather than something these tools control, and demo/README.md says so plainly instead of implying the output is always right.

demo/ and docs/

demo/ runs the whole path against a real workspace and is safe to re-run. It works with any tool-calling model — pass --model '<provider>:<model>'; steps 1–5 need no model at all.

docs/ carries the verified engine contract, the roadmap, and the VectorStore plan, checked in while the repo is small so the reasoning stays with the code. docs/engine-contract.md is the source of truth the descriptions encode — if engine behaviour changes, a description is now lying to the model.

Follow-ups

Filed as #36#42, grouped by code surface. Nothing here blocks on them: #38 (address databases by id only) is the next intended change, and #23 is stale and can be dealt with separately.

The lock already carried 0.9.0/0.8.0 after #35, but the floors stayed at
>=0.3.0/>=0.4.1, so a resolve that ignores the lock could still select
framework 0.4.1. That version uploads through `POST /v1/files`, a route the
API no longer serves, so `load_managed_table` fails with a bare `Not Found`.
0.9.0 uses the upload session/finalize flow.

Also adds a `demo` dependency group (langchain, langchain-openai) for the
end-to-end script; the package itself still depends only on langchain-core.
Exposes the engine's existing BM25 index to LangChain, adds a schema
introspection tool, and rewrites the tool descriptions so the model can plan
against the engine's real contract.

`hotdata_search_text` (hotdata_langchain/search.py) wraps `bm25_search(...)`.
The corpus is pinned when the tool is built rather than chosen by the model:
nothing in the tool surface lets an agent discover which columns carry a BM25
index, and the engine errors outright instead of falling back to a scan when
one is missing. Register the factory per corpus to expose several. Three
engine behaviours are encoded in the SQL it builds — results come back in
rowid order so ranking is requested explicitly; `k` is passed as the
`bm25_search` fourth argument because `ORDER BY` blocks limit pushdown and the
scan would otherwise fall back to a much larger default bound; and the
projection defaults to the searched column so a wide table cannot flood the
context.

`hotdata_describe_tables` (hotdata_langchain/schema.py) reads
`information_schema`, listing tables with their column counts, or one table's
columns and types. Registered by default. Without it an agent guesses column
names from the shape of data it has already seen.

The tool descriptions now state the dialect, the supported constructs, where
to look schema up, and the two constraints that silently produce wrong calls:
SQL cannot rank text, and an aggregate query must reference a column
(`COUNT(*)` alone is rejected). Every claim was checked against a live
workspace and is pinned by tests/test_descriptions.py. This is not cosmetic —
with a one-line description an agent wrote Postgres full-text SQL, which the
engine rejects, and the run aborted; with the contract stated it takes the
search-then-SQL path with no system-prompt guidance at all. The database tools
also steer callers towards ids, since names are non-unique display labels.

The search description deliberately names no mechanism, and a test enforces
that it never says bm25/vector/hnsw, so hybrid retrieval can land underneath
without changing the contract the model was given.

Also fixes two things that were already broken on main: the README quickstart
used `create_tool_calling_agent`/`AgentExecutor`, neither of which exists in
LangChain v1, and both the quickstart and examples/langchain_basic.py ran SQL
with no database scope, which the API now rejects.

demo/ runs the whole path against a real workspace — managed database, data
load, index build, direct search, then an agent choosing between search and
SQL. docs/ carries the verified engine contract and the roadmap, checked in
while the repo is small so the reasoning stays with the code.
@rohan-hotdata
rohan-hotdata requested a review from a team as a code owner July 27, 2026 13:31
@rohan-hotdata
rohan-hotdata requested review from zfarrell and removed request for a team July 27, 2026 13:31
@rohan-hotdata rohan-hotdata added ai-native-layer Part of the AI-native query layer effort for LangChain enhancement New feature or request labels Jul 27, 2026
Comment thread demo/bm25_search_demo.py Outdated
tools = hl.make_hotdata_tools(
client,
database=DATABASE,
max_rows=args.k,

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: max_rows=args.k also caps the SQL tool's rows (not blocking).

max_rows in make_hotdata_tools is shared by hotdata_execute_sql and hotdata_search_text, so with the default --k 5 the agent's aggregate query in step 6 silently comes back truncated to 5 rows — to_records(max_rows=...) drops the rest while metadata.row_count still reports the true count. That is precisely the failure mode demo/README.md attributes to the model ("one run aggregated over the handful of matched listings instead of all listings in those neighbourhoods"), so the demo may be undercutting its own headline result.

Search hits and SQL rows are different budgets; suggest decoupling them:

Suggested change
max_rows=args.k,
max_rows=100,

(and keep search_k=args.k as-is below).

Comment thread hotdata_langchain/schema.py Outdated
"table": f"{records[0]['table_schema']}.{records[0]['table_name']}",
"columns": [{"name": r["column_name"], "type": r["data_type"]} for r in records],
}
if len(records) == max_columns:

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: truncated_at fires on an exact fit (not blocking).

A table with exactly max_columns columns returns exactly max_columns records and gets flagged as truncated, telling the model part of the schema is missing when it is complete — which is the one case where it might go guess a column name instead of trusting the list.

Fetching one extra row distinguishes the two:

result = client.execute_sql(table_columns_sql(table, limit=max_columns + 1), database=database)
records = result.to_records()
...
truncated = len(records) > max_columns
records = records[:max_columns]

Comment on lines +178 to +189
def hotdata_search_text(query: str, k: int | None = None) -> str:
"""Search indexed text by relevance and return ranked rows as JSON."""
return bm25_search_json(
client,
table=table,
column=column,
query=query,
k=default_k if k is None else k,
columns=columns,
max_rows=max_rows,
database=database,
)

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: the model-supplied k has no ceiling (not blocking).

k goes straight into bm25_search(..., k) and the trailing LIMIT — and per the docstring the fourth argument is exactly what bounds the scan. A model that emits k: 100000 (a plausible hallucination when told "ask for more with 'k'") makes the engine rank and ship 100k rows, which are then thrown away down to max_rows in Python. The construction-time k is caller-controlled; the invocation-time one is not.

Clamping to max_rows costs nothing and keeps the two bounds consistent:

    def hotdata_search_text(query: str, k: int | None = None) -> str:
        """Search indexed text by relevance and return ranked rows as JSON."""
        return bm25_search_json(
            client,
            table=table,
            column=column,
            query=query,
            k=default_k if k is None else min(k, max_rows),
            columns=columns,
            max_rows=max_rows,
            database=database,
        )

Comment thread README.md
Comment on lines +121 to +134

```python
tools = [
*hl.make_hotdata_tools(client, database="sf_airbnb"),
hl.make_hotdata_search_tool(
client, table="default.public.listings", column="description",
name="search_listings", database="sf_airbnb",
),
hl.make_hotdata_search_tool(
client, table="default.public.reviews", column="comments",
name="search_reviews", database="sf_airbnb",
),
]
```

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 recipe silently gives up the SQL description's search steer (not blocking).

make_hotdata_tools(client, database="sf_airbnb") here is called without search_table/search_column, so has_search is false and sql_tool_description falls back to the no-search branch — the SQL tool never mentions search_listings/search_reviews. The multi-corpus path documented here therefore loses exactly the behaviour the PR description shows to be load-bearing (12-token description → to_tsvector → aborted run).

There is currently no way to tell make_hotdata_tools "search tools exist under these names" without also pinning one corpus: search_tool_name is ignored unless search_table/search_column are set. Worth either accepting a search_tool_names: Sequence[str] (or letting search_tool_name stand alone) so sql_tool_description can name them, or at minimum noting in this section that callers taking this path should pass their own description= to the SQL tool.

Comment thread demo/bm25_search_demo.py
ensure_bm25_index(client, db.default_connection_id)

step("4. Tools")
available = table_columns(client)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

super nit: this hand-rolls schema discovery with SELECT * ... LIMIT 1 while the PR ships hl.describe_tables_json for exactly this (not blocking). Using the new helper here would both shorten step 4 and exercise the tool the demo is advertising.

Comment thread hotdata_langchain/schema.py Outdated
#: Cap on columns returned for a single table, so one wide table cannot flood the context.
DEFAULT_MAX_COLUMNS = 200

_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

super nit: _IDENTIFIER_RE is defined identically in search.py:20 (not blocking). Two copies of the validator that stands between LLM-authored strings and a SQL literal is the kind of duplication that drifts — worth a shared _sql.py with _IDENTIFIER_RE / _validate_identifier.

claude[bot]
claude Bot previously approved these changes Jul 27, 2026
- Clamp a model-supplied `k` to `max_rows`. It fed straight into
  `bm25_search(..., k)`, which is what bounds the scan, so a hallucinated
  `k: 100000` had the engine rank and ship rows that Python then discarded. The
  caller's own `k` stays untouched; only the model's is clamped.
- `describe_tables` no longer reports a complete schema as truncated. A table
  with exactly `max_columns` columns returned exactly that many rows and got
  flagged, telling the model part of the schema was missing — the one thing
  likely to send it back to guessing column names. It now reads one row past
  the cap to tell exact-fit from truncated.
- The demo passed `max_rows=args.k`, which caps the SQL tool as well as search.
  With the default `--k 5` the agent's aggregate silently came back truncated to
  five rows while `metadata.row_count` still reported the true count. Search
  hits and SQL rows are different budgets; the SQL one is now separate. Verified
  against the live engine: the aggregate returns ten neighbourhood rows where it
  previously returned five.
- The demo hand-rolled schema discovery with `SELECT * ... LIMIT 1`; it now uses
  `describe_tables_json`, exercising the tool it advertises.
- The README's multi-corpus recipe called `make_hotdata_tools` with no
  `search_table`/`search_column`, so `sql_tool_description` fell back to its
  no-search branch and never named the search tools — losing exactly the steer
  the descriptions exist to provide. It now configures the first corpus through
  the factory and adds further ones alongside.
- `IDENTIFIER_RE`/`validate_identifier`/`quote_literal` move to `_sql.py`. Two
  copies of the validator standing between model-authored strings and a SQL
  literal is the kind of duplication that drifts.
Comment on lines +83 to +91
result = client.execute_sql(table_columns_sql(table, limit=max_columns + 1), database=database)
records = result.to_records()
if not records:
return json.dumps(
{"table": table, "columns": [], "error": f"no table named {table!r} in this database"},
indent=2,
)
truncated = len(records) > max_columns
records = records[:max_columns]

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: the exact-fit fix opens an IndexError path for max_columns < 1 (not blocking).

make_hotdata_describe_tables_tool(client, max_columns=0) now queries LIMIT 1, gets one row, passes the if not records guard, sets truncated = 1 > 0, then slices records[:0] — and line 93's records[0]['table_schema'] raises IndexError. Before the max_columns + 1 change the same call emitted LIMIT 0, returned no rows and fell into the "no table named" branch: wrong, but not an exception.

make_hotdata_search_tool validates its analogous bound (max_rows must be >= 1); max_columns has no equivalent guard. Cheapest fix is to mirror it in the factory:

    if max_columns < 1:
        raise ValueError(f"max_columns must be >= 1, got {max_columns}")

(and/or clamp inside describe_tables_json, since it is also public API).

Comment on lines +31 to +38
def table_overview_sql() -> str:
"""Return SQL listing every table in the scoped database with its column count."""
return (
"SELECT table_schema, table_name, COUNT(column_name) AS column_count "
"FROM information_schema.columns "
"GROUP BY table_schema, table_name "
"ORDER BY table_schema, table_name"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

super nit: the overview has no row cap while the column list does (not blocking).

DEFAULT_MAX_COLUMNS exists "so one wide table cannot flood the context", but the no-argument path — the one an agent calls first, unprompted — returns a row per table with no bound. A database with a few thousand tables floods exactly the context the other branch protects. A LIMIT here plus a truncated_at in the payload would make the two paths consistent.

Comment thread demo/README.md Outdated
Comment on lines +86 to +92
**What this step reliably demonstrates is the routing**, not the arithmetic. Across
five runs the agent called the search tool every time and reached for the schema tool
in four, which is the behaviour the demo exists to show. The final table was right in
four of five — one run aggregated over the handful of matched listings instead of all
listings in those neighbourhoods. Answering a compound question correctly is a property
of the model, not of these tools, so read the printed tool calls as the result and treat
the prose answer as illustrative.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

super nit: this five-run measurement predates the max_rows fix (not blocking).

The 5/5, 4/5 and "right in four of five" numbers were gathered while the demo still passed max_rows=args.k, so the SQL tool's aggregate was capped at five rows during those runs. The failure described here (aggregating over matched listings rather than the neighbourhoods) is a distinct, query-construction mistake, so the attribution to the model still reads as correct — but the numbers themselves are stale, and the fix commit only re-verified the row count, not the five-run pass rate. Either re-run the five and update, or note that the figures were taken before the row budget was decoupled.

claude[bot]
claude Bot previously approved these changes Jul 27, 2026
The exact-fit fix in the previous commit introduced a crash: reading one row
past the cap means `max_columns=0` queries `LIMIT 1`, gets a row, passes the
empty-result guard, slices the list to empty and then indexes it —
`IndexError`. Before that change the same call emitted `LIMIT 0` and fell into
the "no table named" branch, which was wrong but did not raise. `max_columns`
is now validated in both `describe_tables_json` and the tool factory, mirroring
how `make_hotdata_search_tool` already guards `max_rows`.

demo/README.md's five-run figures were gathered before the search and SQL row
budgets were decoupled, so they described a configuration that no longer
exists. Re-measured against the current one: the search and schema tools are
called in 5/5 runs and the aggregate matches the whole-neighbourhood figures in
5/5, up from 4/5. Two runs recovered from a bad query after the error was fed
back. The text now reports those numbers and is explicit that five runs are not
a guarantee, since answering a compound question is a property of the model.

The unbounded table overview raised in the same review is recorded on #40
rather than fixed here: the no-argument path returns a row per table with no
cap, while the per-table path caps columns.
Comment on lines +44 to +46
where = f"WHERE table_name = '{name}'"
if schema is not None:
where += f" AND table_schema = '{schema}'"

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: a bare table name silently merges same-named tables across schemas (not blocking).

With no schema part the filter is WHERE table_name = 'listings' only, so if two schemas each hold a listings, describe_tables_json gets both schemas' rows back (ordered table_schema, table_name, ordinal_position) and flattens them into a single payload: line 97 labels it from records[0]public.listings — while line 98 lists the union of both tables' columns.

The bare form is the one the tool description advertises first ("a table name ('listings' or 'public.listings')"), so the model will use it. The result is that it's told public.listings has columns that only exist in staging.listings — the exact "guess a column that isn't there" failure this tool was added to prevent, except now it looks authoritative. The max_columns + 1 cap makes it worse at the margin: with two 150-column listings tables and the default cap, the payload reads public.listings, 200 columns, truncated_at: 200.

Cheapest fix is to detect it rather than merge — the query already selects table_schema, so the ambiguity is visible in the records:

    schemas = {r["table_schema"] for r in records}
    if len(schemas) > 1:
        return json.dumps(
            {
                "table": table,
                "columns": [],
                "error": f"{table!r} is ambiguous: it exists in {sorted(schemas)}; "
                "qualify it as 'schema.table'",
            },
            indent=2,
        )

(placed after the if not records guard, before truncated is computed). Alternatively keep only records[0]'s schema and note the others.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Prior findings all addressed: _sql.py shares the identifier/literal validators, describe_tables_json reads one row past the cap and max_columns < 1 is rejected in both the helper and the factory, model-supplied k is clamped to max_rows, the demo decouples SQL_MAX_ROWS from --k and discovers columns through describe_tables_json, the README multi-corpus recipe pins the first corpus with the reason inline, and demo/README.md re-measures five runs on the fixed budgets. One non-blocking nit left inline.

Note: I could not run pytest/ruff in this sandbox, so the "109 tests, clean" claim is unverified here — CI covers it.

@rohan-hotdata
rohan-hotdata merged commit 98f28b6 into main Jul 27, 2026
4 checks passed
@rohan-hotdata
rohan-hotdata deleted the feat/bm25-search-tool branch July 27, 2026 14:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-native-layer Part of the AI-native query layer effort for LangChain enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant