Build

Run the tools on your backend

Implement the search tools against your own index and format results back to SID-1.

The SID-1 retrieval pipeline with the search backend stage highlighted. A question feeds into SID-1; SID-1 sends repeated tool calls (BM25, ANN, and similar) down to a search backend and receives content and metadata back up; SID-1 returns ranked results. A dashed box labeled 'Run the tools on your backend' encloses the search backend box, marking the part this page covers: implementing the search tools against your own index and formatting results back to SID-1.

Let's connect SID-1 to your search backend. In the previous section, we've made a request to SID-1 and have received a list of tool calls in response. These tools describe the action the model wants to make. In this example, SID-1 issues two search calls and one text_search in the same turn:

{
  "role": "assistant",
  "content": "<think>\nI need the cities for both records, so I'll run a few searches in parallel and a lexical search for the exact phrase.\n</think>\n\n",
  "tool_calls": [
    {
      "id": "chatcmpl-tool-1a2b3c4d",
      "type": "function",
      "function": {
        "name": "search",
        "arguments": "{\"query\": \"indoor 4 minute mile record city\", \"limit\": 5}"
      }
    },
    {
      "id": "chatcmpl-tool-5e6f7a8b",
      "type": "function",
      "function": {
        "name": "search",
        "arguments": "{\"query\": \"outdoor 4 minute mile record city\", \"limit\": 5}"
      }
    },
    {
      "id": "chatcmpl-tool-9c0d1e2f",
      "type": "function",
      "function": {
        "name": "text_search",
        "arguments": "{\"query\": \"four-minute mile world record\", \"limit\": 10}"
      }
    }
  ]
}

SID doesn't host or store your search index. We assume that you already have search implemented with a backend of your choosing.

Implement the search tools

Each search tool queries your index and returns a list of results. Use a default limit of 5 and a max around 15 so SID-1 can manage its context window. See tools for the recommended arguments.

def search(query: str, limit: int = 5) -> list[dict]:
    """Semantic search over your corpus."""
    docs = semantic_index.search(query=query, limit=limit)  # turbopuffer ANN, Postgres pgvector, etc.
    return [{"id": d.id, "title": d.title, "content": d.snippet} for d in docs]


def text_search(query: str, limit: int = 5) -> list[dict]:
    """Lexical search over the same corpus."""
    docs = full_text_index.search(query=query, limit=limit)  # BM25, Postgres tsvector, Elastic, etc.
    return [{"id": d.id, "title": d.title, "content": d.snippet} for d in docs]
async function search(query: string, limit = 5): Promise<Record<string, unknown>[]> {
  // Semantic search over your corpus.
  const docs = await semanticIndex.search({ query, limit }); // turbopuffer ANN, Postgres pgvector, etc.
  return docs.map((d) => ({ id: d.id, title: d.title, content: d.snippet }));
}

async function textSearch(query: string, limit = 5): Promise<Record<string, unknown>[]> {
  // Lexical search over the same corpus.
  const docs = await fullTextIndex.search({ query, limit }); // BM25, Postgres tsvector, Elastic, etc.
  return docs.map((d) => ({ id: d.id, title: d.title, content: d.snippet }));
}

The backend behind these functions can be any database or search system you like. SID-1 only needs the tool boundary to distinguish vector-style semantic search (search) from lexical keyword search (text_search). If your backend only supports one of these search types, expose that one and omit the other.

Your backend might support richer filters like date, category, author, etc. You can expose these filters as extra tool arguments, and SID-1 sets them per query when they narrow the search. You should read tools for information on customization. These filters must also be described in the system prompt.

Map results to your data

Our sample result has three fields: id, title, and content. Map them onto whatever your records already hold. Only id is required, and it must be stable (see ID rules); title and content are yours to fill.

def search(query: str, limit: int = 5) -> list[dict]:
    rows = my_index.query(query, limit=limit)
    return [
        {
            "id": row.pk,          # your stable identifier
            "title": row.heading,  # whatever you want SID-1 to see as a title
            "content": row.body,   # the snippet to show (see below)
        }
        for row in rows
    ]
async function search(query: string, limit = 5): Promise<Record<string, unknown>[]> {
  const rows = await myIndex.query(query, limit);
  return rows.map((row) => ({
    id: row.pk,         // your stable identifier
    title: row.heading, // whatever you want SID-1 to see as a title
    content: row.body,  // the snippet to show (see below)
  }));
}

Return snippets

search and text_search should return snippets, not full documents. A snippet is a short excerpt (about 50 words) centered on the match. If the match starts or ends mid-document, mark that with .... TF-IDF over the matched document works well for choosing the excerpt; simple truncation also works.

Implement read

read lets SID-1 inspect the full content behind a promising search snippet. It fetches one full document by id. Cap it at about 3000 tokens (roughly 12000 characters) so a single long document cannot exhaust the context window. Accept only IDs that came from an earlier search or text_search in the same run.

def read(id: str) -> dict:
    doc = document_store.get(id)
    return {
        "id": doc.id,
        "title": doc.title,
        "content": truncate_to_tokens(doc.content, max_tokens=3000),
    }
async function read(id: string): Promise<Record<string, unknown>> {
  const doc = await documentStore.get(id);
  return { id: doc.id, title: doc.title, content: truncateToTokens(doc.content, 3000) };
}

Format results back to SID-1

Wrap each result in a <doc id="..." title="..."> block, newline-concatenated, and append it to the message list. This gives SID-1 stable IDs to cite across turns. The result formatter shows the exact code. Append results verbatim; never summarize, rewrite, or re-rank them before putting them back.

Pass tool results back as a list of tool messages, one message for each tool call in the assistant turn. Each message uses the matching tool_call_id, and its content contains the formatted XML documents.

import json

NO_RESULTS = "No documents matched this query. Try different keywords, synonyms, or a broader search."
SEARCH_FAILED = "This search timed out before returning results. Try again or use a simpler query."


def run_search(fn, query: str, limit: int) -> str:
    try:
        docs = fn(query, limit)  # set a timeout on your backend call
    except Exception:  # your backend's timeout or connection error
        return SEARCH_FAILED
    return format_docs_xml(docs) if docs else NO_RESULTS


tool_messages = []

for tc in assistant_msg.tool_calls:
    name = tc.function.name
    args = json.loads(tc.function.arguments)

    if name == "search":
        content = run_search(search, args["query"], args.get("limit", 5))
    elif name == "text_search":
        content = run_search(text_search, args["query"], args.get("limit", 5))
    elif name == "read":
        content = format_docs_xml([read(args["id"])])
    else:
        content = f"Unknown tool: {name}"

    tool_messages.append(
        {
            "role": "tool",
            "tool_call_id": tc.id,
            "content": content,
        }
    )

messages.extend(tool_messages)
const NO_RESULTS = "No documents matched this query. Try different keywords, synonyms, or a broader search.";
const SEARCH_FAILED = "This search timed out before returning results. Try again or use a simpler query.";

async function runSearch(
  fn: (query: string, limit: number) => Promise<Record<string, unknown>[]>,
  query: string,
  limit: number,
): Promise<string> {
  let docs: Record<string, unknown>[];
  try {
    docs = await fn(query, limit); // set a timeout on your backend call
  } catch {
    return SEARCH_FAILED; // your backend's timeout or connection error
  }
  return docs.length ? formatDocsXml(docs) : NO_RESULTS;
}

const toolMessages: OpenAI.Chat.Completions.ChatCompletionToolMessageParam[] = [];

for (const tc of assistantMsg.tool_calls ?? []) {
  const name = tc.function.name;
  const args = JSON.parse(tc.function.arguments);

  let content: string;
  if (name === "search") {
    content = await runSearch(search, args.query, args.limit ?? 5);
  } else if (name === "text_search") {
    content = await runSearch(textSearch, args.query, args.limit ?? 5);
  } else if (name === "read") {
    content = formatDocsXml([await read(args.id)]);
  } else {
    content = `Unknown tool: ${name}`;
  }

  toolMessages.push({
    role: "tool",
    tool_call_id: tc.id,
    content,
  });
}

messages.push(...toolMessages);

The raw messages look like this:

[
  {
    "role": "tool",
    "tool_call_id": "chatcmpl-tool-1a2b3c4d",
    "content": "<doc id=\"Ild8\" title=\"Title1\">\ncontent ...\n</doc>\n<doc id=\"wKZbNzWuB\" title=\"Title2\">\ncontent ...\n</doc>"
  },
  {
    "role": "tool",
    "tool_call_id": "chatcmpl-tool-5e6f7a8b",
    "content": "<doc id=\"nzNBrlaRM\" title=\"Title3\">\n... content ...\n</doc>\n<doc id=\"MV48rO\" title=\"Title4\">\n... content ...\n</doc>"
  },
  {
    "role": "tool",
    "tool_call_id": "chatcmpl-tool-9c0d1e2f",
    "content": "<doc id=\"pX2sL0\" title=\"Title5\">\n... content ...\n</doc>"
  }
]

Handle empty results and timeouts

A search can match nothing, and a backend call can time out or raise. The run_search helper above guards both cases: it returns a short natural-language note in the tool content instead of an empty message or an exception. SID-1 reads tool content to choose its next query, so tell it what happened in plain words.

  • No matches: No documents matched this query. Try different keywords, synonyms, or a broader search.
  • Timeout or error: This search timed out before returning results. Try again or use a simpler query.

Return these notes as plain text, not inside a <doc> block. SID-1 reads them the same way it reads documents and will broaden, rephrase, or retry on its own. The wording is yours to adapt: keep it short and say what to do next.

Tell SID-1 how many turns remain

Before you send the next request, append one more thing to the last tool message of the turn: a short notice telling SID-1 how many turns it has left to search before it must report.

<doc id="pX2sL0">
... content ...
</doc>

You have 9 out of 10 turns left.

When a single turn remains, the notice also tells SID-1 to report next turn, so the run does not reach the cap empty-handed:

... </doc>

You have 1 out of 10 turns left. You must call `report_helpful_ids` in the next turn.

This notice is added in the loop, not by format_docs_xml, because the count depends on the turn number. The loop page has the helper and shows where it goes.

Custom response fields

You can add extra fields like dates or authors to your tool response when they help SID-1 judge relevance, but keep the output focused: extra fields consume context and make the loop noisier.

# Add focused metadata fields when they help SID-1 judge relevance.
def text_search(query: str, limit: int = 5) -> list[dict]:
    rows = my_index.query(query, limit=limit)
    return [
        {
            "id": r.pk,
            "title": r.heading,
            "content": r.body,
            "published_at": r.published_at,
            "author": r.author,
        }
        for r in rows
    ]


def format_docs_xml(docs: list[dict]) -> str:
    parts = []
    for doc in docs:
        parts.append(
            f'<doc id="{doc["id"]}" title="{doc["title"]}" '
            f'published_at="{doc["published_at"]}" author="{doc["author"]}">\n'
            f'{doc["content"]}\n'
            "</doc>"
        )
    return "\n".join(parts)
// Add focused metadata fields when they help SID-1 judge relevance.
async function textSearch(query: string, limit = 5): Promise<Record<string, unknown>[]> {
  const rows = await myIndex.query(query, limit);
  return rows.map((r) => ({
    id: r.pk,
    title: r.heading,
    content: r.body,
    published_at: r.publishedAt,
    author: r.author,
  }));
}

function formatDocsXml(docs: Record<string, unknown>[]): string {
  return docs
    .map(
      (doc) =>
        `<doc id="${doc.id ?? ""}" title="${doc.title ?? ""}" published_at="${doc.published_at ?? ""}" author="${doc.author ?? ""}">\n${doc.content ?? ""}\n</doc>`,
    )
    .join("\n");
}

Content