Skip to content

LLM Tool Calling#

Expose NLQL as a function-calling tool: engine.function_tool() produces an OpenAI-style tool definition to hand to the LLM, the LLM returns a structured Query IR, and engine.search_ir() executes it directly. This is the recommended retrieval path for agent and RAG applications — having the model output a structured IR is more reliable than generating a query string.

The following example uses FakeEmbedder, which requires no network access or model downloads and runs directly.

import json
import nlql

engine = nlql.Engine(nlql.embed.FakeEmbedder())
engine.add_text("AI agents use planning, memory, and tool use.",
                metadata={"status": "published"})
engine.add_text("Vector databases store embeddings for similarity search.",
                metadata={"status": "published"})

1. Produce the tool definition#

function_tool() returns a dictionary conforming to the OpenAI tool-calling spec; its parameters field is a JSON Schema that constrains the legal structure of the IR and the available function names.

tool = engine.function_tool(name="nlql_query")
print(json.dumps({
    "type": tool["type"],
    "function": {"name": tool["function"]["name"]},
}, indent=2))

The $defs in parameters lists every IR node type (select / let / where / order_by / limit, etc.). The schema is generated dynamically from the functions actually registered in the engine, so you never have to maintain the tool definition by hand after extending functions.

2. Hand the tool to the LLM#

Pass tool as the tools argument to any OpenAI-compatible chat endpoint:

from openai import OpenAI

client = OpenAI(base_url="https://your-gateway/v1", api_key="sk-...")
resp = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "how do agents use tools?"}],
    tools=[tool],
    tool_choice="auto",
)
args = json.loads(resp.choices[0].message.tool_calls[0].function.arguments)

3. Execute the IR#

The arguments returned by the LLM are a Query IR document; pass it directly to search_ir() for execution, with no string parsing. The IR below specifies: return sentence granularity, order by the similarity between content and the query text, keep only published content, and take the top 3.

llm_output = {
    "select": {"unit": "sentence"},
    "let": [
        {
            "name": "relevance",
            "expr": {
                "node": "call",
                "name": "SIMILARITY",
                "args": [
                    {"node": "path", "root": "content", "segments": []},
                    {"node": "literal", "value": "how do agents work"},
                ],
            },
        }
    ],
    "where": {
        "node": "compare",
        "op": "==",
        "left": {"node": "path", "root": "meta", "segments": ["status"]},
        "right": {"node": "literal", "value": "published"},
    },
    "order_by": [{"expr": {"node": "ref", "name": "relevance"}, "desc": True}],
    "limit": 3,
}

for unit in engine.search_ir(llm_output):
    print(f"  [{unit.scores['relevance']:+.3f}] {unit.content}")

JSON structure of the IR#

Each IR node uses a node field to mark its type:

node Meaning
path Field path; root is content / meta, etc., and segments is the sub-path
literal Literal value
ref Reference to an alias declared in let
call Function call, name + args
compare Comparison expression, op + left + right

The five top-level fields select / let / where / order_by / limit correspond to a complete query. engine.search_ir() accepts this JSON and is equivalent to passing the corresponding NLQL string or Query Builder result to engine.search().

Three-entry equivalence

The NLQL string, Query Builder, and the IR returned by the LLM all compile to the same IR and return identical results. search_ir suits scenarios that produce IR programmatically; function calling is the most typical use case.

Next steps#


Full source: examples/llm_function_calling.py