# Indx Documentation — Complete Reference C# API version: 5.0.2 Source: https://docs.indx.co · Index: https://docs.indx.co/llms.txt --- # Build with an AI Agent The fastest way to integrate Indx in 2026 is often not to read these docs yourself — it is to hand them to your coding agent. Indx ships three pieces that make agent-driven integration work end to end: an **agent skill** that teaches your agent how to build with Indx, **agent-readable docs** for everything else, and a **built-in MCP server** so the agent can search real data and verify its own work. Point your agent at the right piece below, then ask it to integrate Indx into your project. --- ## 1. Install the Indx skill (Claude Code) The [indx-search skill](https://github.com/indxSearch/skill-indx-search) packages what an agent needs to build correct Indx integrations: version detection (v4 vs v5), the C# and HTTP API surfaces, field configuration, filters, boosts, coverage tuning, and the mistakes to avoid. Install from [skills.sh](https://skills.sh/indxsearch/skill-indx-search/indx-search), or manually: ```bash # from your project root — the skill loads automatically when Indx work comes up git clone https://github.com/indxSearch/skill-indx-search.git .claude/skills/indx-search ``` (User-wide instead: clone into `~/.claude/skills/` to have it in every project.) With the skill in place, prompts like these produce working integrations: - *"Add Indx search to this project. Products are in `products.json`; name and brand should be searchable, category and price filterable."* - *"Set up an Indx dataset for this data over the REST API, with facets on genre."* ## 2. Give any other agent the docs For agents without skill support (Cursor, Copilot, custom frameworks), this entire documentation site is published as one agent-readable file: ``` https://docs.indx.co/llms-full.txt ``` Paste the URL into your agent's context, or reference it from your project's agent instructions (`AGENTS.md`, `.cursorrules`, or similar). ## 3. Connect the agent to a running server (MCP) Every [Indx](https://github.com/indxSearch/Indx) ships an **MCP server** at `/mcp`. Connected, your agent does not just write the integration — it **searches your live datasets to verify what it built**: check that the field configuration behaves, that filters use real values, that a typo still finds the right document. Create an API key in the console (Account → API keys) with the access level **Read only**, limited to the team and datasets the agent should see — it can then search, fetch documents and describe datasets, but not change anything. (**Search only** also works, without `describe_dataset` and `get_synonyms`.) Then: ```bash # Claude Code claude mcp add --transport http indx https:///mcp \ --header "Authorization: Bearer " ``` Or in any MCP client's JSON configuration: ```json { "mcpServers": { "indx": { "type": "http", "url": "https:///mcp", "headers": { "Authorization": "Bearer " } } } } ``` The server is **read-only** — agents can search, never mutate — and exposes five tools: `list_datasets`, `describe_dataset` (fields, capabilities, and value hints, so agents filter with real values instead of guessing), `search`, `get_document`, and `get_synonyms`. Full tool reference: [Using Indx for AI Agents](/howto/agent-search). ## A workflow that uses all three 1. Install the skill (or feed the docs URL) — the agent knows *how* to build. 2. Ask it to integrate Indx with your data — field config, load, index, queries. 3. Connect MCP — the agent searches what it built and corrects itself against real results. That last step is the difference between generated code that *looks* right and an integration verified against a running engine. --- ## Why Indx suits agents Beyond integration-time help, Indx is a good runtime fit for agent systems themselves: embedded via NuGet it runs in the agent's own process with sub-millisecond queries — no network round-trip, no provisioning, no cold starts. See [Using Indx for AI Agents](/howto/agent-search) for the full picture, including boost rules over MCP. --- # Core Concepts ## How Search Works Every search runs in two phases. **Phase 1 — Pattern Matching.** The engine scans all documents using an inverted index with BM25 scoring. This phase has strong recall: it tolerates typos, partial words, and messy input without any preprocessing. The result is a ranked list of candidates. **Phase 2 — Coverage.** The top 500 candidates (configurable) are re-evaluated for exact and near-exact token matches: whole words, fuzzy words, joined/split words, prefixes, and suffixes. Documents with confirmed token matches are rescored on a 16-bit scale and promoted above pure pattern-match results. Coverage is enabled by default and fast enough that it doesn't add significant latency. Leave it on. A search for *"Wirless hedphones"* will still surface *"Wireless Headphones"* — the pattern phase catches it despite the typos, and the coverage phase confirms the near-exact match (edit distance 1 on each word). A heavily garbled query like *"Wirlss edphons"* will still return results — the pattern phase finds candidates — but coverage won't confirm any near-exact matches. In that case you get ranked results without a truncation point and without facet counts. The results are useful but less precise. ## Language Handling — Inflections, Compound Words, Synonyms Indx has no per-language tokenizer, stemmer or stop-word list, and does not need one for Norwegian, English or mixed-language data. The character-level matcher covers what a stemmer usually does: - **Typos and spelling variants** — one-edit tolerance on every word (`CoverFuzzyWords`). - **Inflections and prefixes** — a query word that is a prefix of a stored word matches (`CoverPrefixSuffix`): *bok* finds *bokhandel* and *bøker*; *run* finds *running*. The reverse direction (query *running*, stored *run*) is matched by the pattern phase but scores lower under coverage, since the query is not fully covered. When that direction matters for a term, add a one-way synonym (*running → run*). - **Compound words (sammensatte ord)** — *spesialpedagogikk* matches *spesial pedagogikk* and vice versa without any rules (`CoverJoinedWords`). Both decomposition and recomposition work, and no dictionary is involved. Mark title-like fields `HighResolution` for the sharpest compound matching. - **Domain vocabulary** — [synonyms](/howto/synonyms) expand the query before scoring and are editable by editors without code. On a feature checklist this reads as: no stemmer to configure, because the matcher handles the common cases; the remaining case is a synonym entry. --- ## Field Roles Fields don't do anything until you explicitly assign them a role. You configure this after calling `Init` (which discovers the field names) and before calling `Index`. | Role | What it does | |------|-------------| | **Searchable** | The field is included in matching and scoring. At least one field must be searchable. | | **Filterable** | The field can be used to filter results before or during a search. | | **Facetable** | The field returns value counts in search results — useful for sidebar filters. Values whose count drops to 0 under the current filter are omitted from the response; keep them in the UI greyed out so the user can still deselect them. | | **Sortable** | Results can be ordered by this field's value. Works on both numbers and strings. | A field can have multiple roles — a `category` field can be both filterable and facetable at the same time. ### Weight Searchable fields have a `weight` (default `1.25`) that controls how much influence that field has on the final score. If `name` should matter more than `description`, give it a higher weight. ```csharp engine.GetField("name")!.Weight = 2.0f; engine.GetField("description")!.Weight = 1.0f; ``` Weight is a relative value — it only matters in proportion to the weights of the other searchable fields. --- ## Filters Are Server-Side A search returns only a limited slice of results (`maxNumberOfRecordsToReturn`). If you filter that slice yourself after receiving it, you will miss matching documents that weren't in the top results. Always pass filters to the engine so they are applied during search — the engine evaluates them against the full dataset, not just the returned page. ```csharp // Wrong: filter the array you got back var results = engine.Search(query).Entries.Where(e => ...); // Right: create a filter and give it to the query var filter = engine.CreateValueFilter("category", "audio", out _); var query = new Query("wireless", 20) { Filter = filter }; var result = engine.Search(query); ``` The same applies to the HTTP API: create a filter server-side first, then pass its `hashString` in the search request. --- ## Scoring Results have a 16-bit `score` (0–65535). Coverage hits (exact and near-exact matches) always score higher than pure pattern-match results, which appear below them. When sorting is applied alongside a text search, the score remains the primary sort — the sort field is only used as a tiebreaker. When searching with no query text (empty search), sorting becomes the primary ordering. --- ## Synonyms A dataset can carry a synonym list that expands queries at search time: when the query matches an entry, the entry's terms are appended to the query text before scoring. Multidirectional entries make a group of terms equivalent (inflections, spelling variants); one-way entries let a trigger like an acronym pull in its expansion without the reverse. Nothing is re-indexed and changes apply on the next search. See [Set Up Synonyms](/howto/synonyms). --- ## The Engine Lifecycle The engine must go through a fixed sequence before it can search: ``` Init() → configure fields → Load() → Index() → Search() ``` - **Init** — reads the JSON structure and discovers field names - **Configure** — set roles and weights on the fields you care about - **Load** — streams the JSON documents into the engine - **Index** — builds the inverted index; engine enters the Ready state - **Search** — available once the engine is Ready After indexing you can also insert, update, and delete individual documents without rebuilding the index — see [Dynamic Operations](/csharp/SearchEngine). --- ## Empty Search An empty or null query string is valid, but requires `EnableFacets = true` — without it, no documents are returned. The result contains up to `maxNumberOfRecordsToReturn` documents, while facets are calculated across the entire dataset. Results are ordered by sort field if one is specified, making empty search the natural companion to sorting and faceted navigation. --- # Getting Started Indx is a high-performance full-text search engine for JSON data. It uses pattern recognition and BM25 scoring — no tokenizers, stemmers, or analyzers to configure. It handles typos and messy input out of the box. ## Two Ways to Run Indx **Embed it** — the [IndxSearchLib NuGet package](https://www.nuget.org/packages/IndxSearchLib/). Search runs inside your .NET application; nothing else to deploy. Follow the [embedded walkthrough](#embedded-walkthrough-c) below. **Host it** — the [Indx server](https://github.com/indxSearch/Indx): an HTTP API, a dashboard for teams and datasets, API keys, and an MCP endpoint. For any language (Node.js, Python, Java, …), for several applications sharing one index, or when a .NET team wants the dashboard and team management too. Follow the [server walkthrough](#server-walkthrough-http) below. --- ## Embedded Walkthrough (C#) ### 1. Install ```bash dotnet add package IndxSearchLib ``` ### 2. Prepare Your Data Indx accepts a JSON array of objects. Nested objects are supported; nested fields use dot notation. ```json [ { "id": 1, "name": "Wireless Headphones", "brand": "Acme", "price": 79.99, "category": "audio" }, { "id": 2, "name": "Bluetooth Speaker", "brand": "Zync", "price": 49.99, "category": "audio" }, { "id": 3, "name": "USB-C Hub", "brand": "Acme", "price": 29.99, "category": "accessories" } ] ``` ### 3. Init — Discover Fields `Init` parses the JSON structure and returns the list of fields it discovers, along with each field's inferred type. The fields start unconfigured — you assign roles (searchable, filterable, sortable, facetable) in the next step. ```csharp using Indx.Api; var engine = new SearchEngine(); using var stream = File.OpenRead("products.json"); engine.Init(stream); ``` After `Init`, call `GetFieldConfiguration()` to inspect the discovered fields: ```csharp foreach (var field in engine.GetFieldConfiguration()) Console.WriteLine($"{field.FieldName} type={field.FieldType}"); // id type=Number // name type=String // brand type=String // price type=Number // category type=String ``` ### 4. Configure Fields Nothing is searchable by default. Mark each field's role explicitly: ```csharp var name = engine.GetField("name")!; var brand = engine.GetField("brand")!; var price = engine.GetField("price")!; var category = engine.GetField("category")!; name.Searchable = true; name.Weight = 2.0f; // higher weight = more influence on score brand.Searchable = true; brand.Weight = 1.0f; price.Filterable = true; price.Sortable = true; category.Filterable = true; category.Facetable = true; ``` `Weight` is a `float` (default `1.0`, i.e. neutral). Higher means stronger influence on BM25 scoring relative to other searchable fields; below `1.0` de-emphasizes the field. ### 5. Load and Index Stream the same JSON again for loading, then build the index: ```csharp stream.Position = 0; engine.Load(stream); engine.Index(); ``` The engine is now in **Ready** state. ### 6. Search ```csharp var result = engine.Search(new Query("wireless", 20)); foreach (var entry in result.Entries) Console.WriteLine($"key={entry.DocumentKey} score={entry.Score}"); ``` To retrieve the full JSON of the matching documents: ```csharp var keys = result.Entries.Select(e => e.DocumentKey).ToArray(); var docs = engine.GetJsonDataOfKeys(keys); ``` ### 7. Add a Filter Filters are evaluated server-side against the full dataset — never filter the result array yourself. ```csharp // Match only the "audio" category var audioFilter = engine.CreateValueFilter("category", "audio", out _); var query = new Query("wireless", 20) { Filter = audioFilter }; var result = engine.Search(query); ``` For numeric ranges: ```csharp var affordable = engine.CreateRangeFilter("price", 0, 50, out _); ``` Combine with AND or OR: ```csharp var combined = audioFilter & affordable; // AND; use | for OR, ! to negate ``` ### 8. Sort Results Sorting is secondary to score. However, an empty search (Query.Text = "", see [Empty Search](/howto/empty-search)) results in all scores being 0, in which case the sort order behaves as primary. ```csharp var query = new Query("", 20) { SortBy = "price", SortAscending = true }; ``` ### 9. Facets Facets return distinct value counts for a field — useful for sidebar filters. ```csharp var query = new Query("wireless", 20) { EnableFacets = true }; var result = engine.Search(query); foreach (var (value, count) in result.Facets["category"]) Console.WriteLine($"{value}: {count}"); // audio: 2 // accessories: 0 ``` --- ## Server Walkthrough (HTTP) Run the server: ```bash git clone https://github.com/indxSearch/Indx cd Indx dotnet run ``` Requires **.NET 10 SDK**. See [Server Setup](/guides/server-setup) for registration, API keys, and deployment. ### Load Data ```bash # Analyze structure curl -X POST https://localhost:5001/api/teams/acme/datasets/products/analyze \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ --data-binary @products.json # Configure fields (204 No Content) curl -X PUT https://localhost:5001/api/teams/acme/datasets/products/fields/configuration \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '[ { "fieldName": "name", "searchable": true, "weight": 2.0 }, { "fieldName": "brand", "searchable": true, "weight": 1.0 }, { "fieldName": "price", "filterable": true, "sortable": true }, { "fieldName": "category", "filterable": true, "facetable": true } ]' # Load documents (204 No Content) curl -X POST https://localhost:5001/api/teams/acme/datasets/products/load \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ --data-binary @products.json # Build index — returns 202 Accepted; the build runs in the background curl -X POST https://localhost:5001/api/teams/acme/datasets/products/index \ -H "Authorization: Bearer " # Poll status until systemState is 4 (Ready) curl https://localhost:5001/api/teams/acme/datasets/products/status \ -H "Authorization: Bearer " ``` ### Search ```bash curl -X POST https://localhost:5001/api/teams/acme/datasets/products/search \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "text": "wireless", "maxNumberOfRecordsToReturn": 20 }' ``` ### Filter and Search ```bash # Create a filter curl -X POST https://localhost:5001/api/teams/acme/datasets/products/filters/value \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "fieldName": "category", "value": "audio" }' # → { "hashString": "abc123..." } # Use it in search curl -X POST https://localhost:5001/api/teams/acme/datasets/products/search \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "text": "wireless", "maxNumberOfRecordsToReturn": 20, "filter": { "hashString": "abc123..." } }' ``` --- ## Next Steps - [Core Concepts](/guides/concepts) — Pattern matching, coverage, and scoring explained - [C# API Reference](/csharp/overview) — Full method and type reference - [HTTP API Reference](/http/overview) — All endpoints, request/response schemas - [How To guides](/howto/field-configuration) — Specific recipes: facets, boosts, deep coverage, and more --- # Licensing ## License Tiers | Tier | Document Limit | Cost | |------|---------------|------| | **No license** | 100,000 documents | Free | | **Extended license** | Unlimited | Free — get one at [license.indx.co](https://license.indx.co) | | **Company license** | Unlimited + SLA and support | Paid | ## Using a License ### C# NuGet Pass the license path to the `SearchEngine` constructor: ```csharp var engine = new SearchEngine("indx-developer.license"); ``` Or place the `.license` file in the working directory — it will be detected automatically. ### HTTP API (Indx) Place the `.license` file in the `./IndxData/` directory. The server auto-detects it on startup. For Azure deployments, place the file in `D:\home\data\`. ## Getting a License If you self-host Indx — or use the C# NuGet library — obtain a license at [license.indx.co](https://license.indx.co). The free extended license file removes the 100,000 document limit. For company licenses with SLA and dedicated support, contact [support@indx.co](mailto:support@indx.co). --- # Server Setup Indx is a self-hosted server that wraps the Indx search engine and exposes it over HTTP. It has three interfaces: - **Web UI** — a Blazor dashboard where you can register users, upload and configure datasets, preview search results, manage teams, and check engine health. No code required. - **REST API** — the full HTTP API, used from any language or framework. All operations available in the UI are also available programmatically. - **Monitor** — a live view of the running instance: what each dataset is doing, what the process is using, and what just happened. In the console under **Admin → Monitor**, and in the terminal you started the server from. See [Monitor](#monitor). Swagger UI is available at `/swagger` for interactive API exploration. --- ## Quick Start ```bash git clone https://github.com/indxSearch/Indx cd Indx dotnet run ``` Requires **.NET 10 SDK**. The server starts at `https://localhost:5001`. --- ## Register a User 1. Open `https://localhost:5001/Account/Register` in a browser 2. Enter your email and password 3. Submit — you're now logged in as the first user > By default, registration is open. See [Registration Control](#registration-control) below to restrict who can sign up before deploying to production. --- ## Get an API Key To call the REST API you need an API key. 1. Log in at `https://localhost:5001/Account/Login` 2. Go to **Account → API keys** (`https://localhost:5001/account/api-key`) and click **New API key** 3. Choose the **team**, the **access level** and, optionally, the **datasets** the key may reach: - **Search only** — for websites and apps. The key is visible in the browser, and this is all it can do. - **Read only** — for exports, reporting and agents. Keep it on a server. - **Full access** — for your own servers and pipelines: everything your team role allows. 4. Choose how long it lasts (30, 90, 180 or 365 days), click **Create key** and copy it — it is shown only once (Search only and Read only keys can be shown again from the list) A key never exceeds your role in the team, and cannot be changed later: create a new one and revoke the old. See [Authentication](/http/endpoints#authentication) for what each level can call. **For a production website or a shared server, use a team key.** A team Admin creates it under **Manage team → API keys**. It belongs to the team, so it keeps working when the person who created it leaves, and every Admin of the team can see and revoke it. A team Search only key can be set to never expire. See [Personal keys and team keys](/http/endpoints#personal-keys-and-team-keys). Send the key as a Bearer token on every request: ```bash curl -H "Authorization: Bearer " https://localhost:5001/api/... ``` --- ## What You Can Do in the UI | Feature | Description | |---------|-------------| | **Upload data** | Stream a JSON file into a dataset | | **Configure fields** | Set searchable, filterable, facetable, sortable, and weight for each field | | **Index** | Trigger an index build and monitor progress | | **Search preview** | Run searches directly in the browser and inspect results | | **Health & status** | See engine state, document count, index time, and whether a shadow build is in progress | | **Teams** | Manage team membership and roles (Admin / Editor / Viewer) | | **Transfer dataset** | Move a dataset to another team | --- ## Teams Indx organises access around **teams**. A team owns datasets, and every member of the team has the same role on all of the team's datasets. A user can belong to multiple teams, with a different role on each. Membership is managed from the account portal's team pages. ### Roles | Role | Can search | Can modify data & fields | Can delete / transfer | |------|-----------|--------------------------|----------------------| | `Admin` | ✓ | ✓ | ✓ | | `Editor` | ✓ | ✓ | — | | `Viewer` | ✓ | — | — | ### Transferring a dataset between teams An Admin of both the source and target team can move a dataset: ```bash curl -X POST https://localhost:5001/api/teams/acme/datasets/products/transfer \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "targetTeamName": "research" }' ``` --- ## Deploy to Azure App Service 1. Create an App Service with **Windows** OS and **.NET 10** runtime 2. Publish with `dotnet publish -c Release` and deploy 3. Set environment variables (use `__` as the section separator): - `Jwt__Key` — required - `Registration__Mode` — recommended: `EmailDomain` or `Closed` 4. Configure CORS with your frontend origins 5. Place the `.license` file in `D:\home\data\` for unlimited documents ### Production Checklist 1. Change the JWT signing key 2. Set registration mode 3. Configure CORS 4. Set up an email provider if you want email confirmation 5. Place the license file 6. Set up OAuth if needed --- ## Monitor A live view of the instance: what each dataset is doing right now, what the process is using, and what just happened. Two ways in, reading the same thing. **In the console**, under **Admin → Monitor**: the event stream and the instance figures, with no terminal needed. This is the one to use on a hosted deployment. It is admin only, because an event names the team whose dataset changed, and the events are held in memory, so they start again when the server restarts. **In the terminal**, if you started the server from one, drawn there automatically. The dataset table gives state, document count, records on disk, time since last use and the idle-eviction countdown, coloured by state to match the chips in the web UI. Below it an event stream carries dataset state changes, idle sweeps and background jobs as they occur. `Ctrl+Q` detaches the monitor and leaves the server running. `Ctrl+C` stops the server as usual, and `F10` shuts it down after a confirmation. The divider between the two panes can be dragged, and where you leave it is remembered. ### Piping it somewhere else Azure log stream, `docker logs` and the systemd journal are pipes rather than terminals, so they cannot show a full-screen application. There the monitor prints a compact status block instead, appended every 30 seconds, with events as they happen. It is off by default, since log volume is usually billed: ```json { "Indx": { "Monitor": { "Enabled": true, "StatusSeconds": 30 } } } ``` Set `Indx__Monitor__Enabled` to `true` in App Service application settings to get the same thing in the portal's log stream. To turn the monitor off everywhere, set `Indx:Monitor:Enabled` to `false` or start with `--no-monitor`. A startup that fails never draws it, so an error such as a port already in use is printed the way it always was. --- ## Configuration Reference All settings live in `appsettings.json` or as environment variables. ### JWT Security A strong signing key is generated on first run and stored in `./IndxData/jwt.key`, so a single instance needs nothing. Set `Jwt:Key` explicitly (Key Vault, user secrets or the `Jwt__Key` environment variable) when several instances must accept each other's API keys: ```bash dotnet user-secrets set "Jwt:Key" "your-secret-key-minimum-32-characters" ``` ### Rate limits Three limits, all under `RateLimits` in `appsettings.json` or as environment variables (`RateLimits__Api__RequestsPerSecond` and so on). Every one answers `429` with `code: rateLimited`, `retryAfterSeconds` and a `Retry-After` header, so clients need a single handler. Set `Enabled: false` to switch one off. | Section | Default | Protects | Settings | |---------|---------|----------|----------| | `Auth` | on | Login and password forms from guessing, per client address | `PermitLimit` 10 attempts per `WindowSeconds` 60 | | `Api` | **off** | Other users of the instance from one runaway integration; one token bucket per API key | `RequestsPerSecond` 50, `Burst` 200 | | `Anon` | on | Scanners hammering the API without a valid token, per client address | `PermitLimit` 30 per `WindowSeconds` 60 | ```json { "RateLimits": { "Auth": { "Enabled": true, "PermitLimit": 10, "WindowSeconds": 60 }, "Api": { "Enabled": false, "RequestsPerSecond": 50, "Burst": 200 }, "Anon": { "Enabled": true, "PermitLimit": 30, "WindowSeconds": 60 } } } ``` Sizing notes: - `Api` is off on self-host because on your own hardware the ceiling is the hardware. Turn it on when several teams share an instance and one of them could monopolise it. - `Anon` covers requests whose bearer token is missing or does not validate, which all end in `401` anyway. Raise it only if a client of yours legitimately polls while unauthenticated; CORS preflight is already exempt. - Rejections are logged under `IndxServer.RateLimit` and counted as `indx.ratelimit.rejections`, tagged by which limit fired. - There is no instance-wide cap on concurrent work. A dataset that is already rebuilding answers `409` rather than queueing another rebuild behind it, and the engine bounds its own searches per dataset. - Behind a reverse proxy or Azure App Service, set `ASPNETCORE_FORWARDEDHEADERS_ENABLED=true` so the per-address limit sees the client's address and not the proxy's; otherwise every caller shares one window. ### Registration Control Controls who can create an account: ```json { "Registration": { "Mode": "EmailDomain", "AllowedDomains": ["yourcompany.com"] } } ``` | Mode | Behavior | |------|----------| | `Open` | Anyone can register (default) | | `EmailDomain` | Only emails from the listed domains | | `Closed` | No new registrations | ### Email Provider Indx uses Azure Communication Services for email (password reset, confirmation). If not configured, all emails are printed to the console instead — fine for development. ```json { "AzureCommunicationServices": { "ConnectionString": "endpoint=https://...", "SenderAddress": "noreply@yourcompany.com" } } ``` ### OAuth (Optional) Google and Microsoft login are supported. Configure via user-secrets or environment variables: ```json { "Authentication": { "Google": { "ClientId": "...", "ClientSecret": "..." }, "Microsoft": { "ClientId": "...", "ClientSecret": "..." } } } ``` ### Database Three SQLite databases are created automatically on first run — no setup required: - `identity.db` — user accounts and authentication - `indx.db` — your documents, field configurations, synonyms and boost rules - `stats.db` — search statistics, clicks and conversions (`Statistics:DbFile` moves it) The search index itself lives in memory. Documents are kept in `indx.db`, so after a restart the server rebuilds every dataset's index on its own, in the background; a search that arrives first loads its dataset on demand. Nothing has to be loaded again by hand. ### License Place the `.license` file in `./IndxData/` (local) or `D:\home\data\` (Azure) to remove the 100,000 document limit. See [Licensing](/guides/licensing). --- # Vector & Hybrid Search Standard text search matches words and patterns. Vector search matches meaning. The two approaches complement each other — hybrid search lets you blend them. ## When to Use It Text search is precise and fast. It excels at names, codes, partial words, and typo-tolerant lookups — the things Indx is already very good at. But it can miss results when the user's phrasing doesn't match the document's wording. Vector search closes that gap. A query like *"something comfortable for long flights"* can surface *"noise cancelling over-ear headphones"* even though none of those words overlap. This is especially valuable when: - Your data is **content-rich** — product descriptions, articles, documentation, support tickets, legal or medical text - Users **don't know the exact terminology** and search with natural language - You want a **"more like this"** or **"related items"** feature - Your data is **multilingual** and users may search in a different language than the documents Vector search is less useful for short structured fields, product codes, or cases where exact matching is the goal — text search handles those better. ### Downsides of Pure Vector Search - **No truncation point.** Text search with coverage produces a clear boundary between confirmed matches and loose pattern hits — results below the truncation index are lower confidence. Vector search returns a ranked list by similarity with no such boundary; every result has a score, but there is no reliable threshold that separates "relevant" from "not relevant". - **No facets.** Facet counts are computed from coverage hits. Vector search does not produce coverage results, so facet aggregations are not available. These limitations make pure vector search a poor fit for faceted navigation or UIs that rely on the truncation point for quality signals. **Hybrid search** is the recommended default — it runs the text search path alongside vector search and merges the results, so you retain truncation points, facet support, and typo tolerance while gaining semantic reach. --- ## How Vector Search Works Instead of indexing words, vector search stores each document as a high-dimensional float array (an *embedding*) produced by an embedding model. At search time, the query is converted to a vector by the same model, and the engine finds the documents whose vectors are closest to the query vector using approximate nearest-neighbour (HNSW) search. Similarity is measured as cosine similarity — a score in `[0, 1]` where `1` means identical. **Indx stores and searches vectors. It does not generate them.** You are responsible for producing the float arrays using an embedding model of your choice — OpenAI, sentence-transformers, Cohere, or any other. Indx accepts vectors as a JSON array field on each document. --- ## Choosing a Mode | Mode | When to use | |------|-------------| | **Text search** | Precise lookups, product codes, names, exact phrases, typo-tolerant queries | | **Vector search** | Semantic similarity — "find products like this one", concept search, multilingual queries | | **Hybrid search** | Most search UIs — combines the precision of text with the semantic reach of embeddings | Hybrid search is the recommended default when you have embeddings. Pure vector search is better suited to "more like this" features or cases where the query is itself a document rather than a text string. --- ## Choosing an Embedding Model **The most important rule: use the same model for documents and queries.** Vectors from different models are not compatible. | Model | Dims | Notes | |-------|------|-------| | OpenAI `text-embedding-3-small` | 1536 | Good starting point — low cost, strong quality | | OpenAI `text-embedding-3-large` | 3072 | Higher quality, higher cost | For most applications, **`text-embedding-3-small`** is the right choice. ### Token Limits All models have a maximum input length. OpenAI's models support up to 8191 tokens per input. If your documents are longer, split them into chunks before embedding — each chunk becomes a separate document in Indx. ### Vector Length Vectors of any length are accepted. Indx brings each vector to unit length when it is stored and when it is searched for, so a score is always the cosine similarity. No normalising step is needed on your side, whichever model you use. --- ## Getting Vectors — C# NuGet Options **OpenAI** — official .NET SDK ```bash dotnet add package OpenAI ``` ```csharp using OpenAI.Embeddings; var client = new EmbeddingClient("text-embedding-3-small", Environment.GetEnvironmentVariable("OPENAI_API_KEY")); Embedding result = await client.GenerateEmbeddingAsync("comfortable wireless headphones"); float[] vector = result.ToFloats().ToArray(); ``` --- ## Setup Overview ### 1. Mark the Field as Embeddable ```csharp engine.GetField("embedding")!.Embeddable = true; ``` Embeddable fields are excluded from the text search index. The field must contain a JSON array of floats. ### 2. Store Vectors in Your Documents Each document must include the embedding as a JSON array: ```json { "id": 1, "title": "Wireless Headphones", "description": "Over-ear noise cancelling headphones", "embedding": [0.12, -0.45, 0.33, 0.08, ...] } ``` The dimensionality is detected from the first document loaded and must be consistent across all documents. A document without the field, or with `null`, simply has no vector. ### 3. Generate Embeddings at Query Time When a user searches, embed the query text with the same model you used for the documents: ```csharp // Example using any embedding client float[] queryVector = await myEmbeddingClient.EmbedAsync("comfortable wireless headphones"); ``` --- ## Vector Search (C#) After `Load()` and `Index()`, access the embedding index via `EmbeddingFields`: ```csharp var index = engine.EmbeddingFields["embedding"]; var results = index.Search(queryVector, maxResults: 10); foreach (var (documentKey, score) in results) Console.WriteLine($"key={documentKey} similarity={score:F3}"); ``` Returns `IReadOnlyList<(long documentKey, float score)>` ordered by descending cosine similarity. --- ## Keeping the Index Up to Date (C#) Insert, update and delete work on a dataset with vector fields, as on any other. An update that leaves the vector unchanged costs the vector index nothing. Removed vectors leave room behind in the search graph, and the graph is rebuilt in the background once enough of them have gathered. Set `EmbeddingSetup.AutomaticRebuild` to `false` to decide yourself: `IEmbeddingIndex.RebuildDue` then tells you when, and `Index()` rebuilds. ### Saving the Graph Between Runs Building the search graph is the slow part of loading vectors. Save it, and the next load reads it instead of building it: ```csharp engine.SaveEmbeddings("products.graphs", out string error); // Next run: after the field configuration is set, before Load engine.LoadEmbeddings("products.graphs", out error); engine.Load(stream); ``` The saved file does not have to match the documents exactly. The graph is brought in line with the vectors that are loaded, and is built as usual when too much has changed. `IEmbeddingIndex.RestoredFromSaved` tells you which happened. On 43 000 products with vectors of 1 536 numbers, the load took 25 s from a saved graph against 41 s building it. The Indx server does this for you: a dataset that goes to sleep keeps its graph on disk and wakes without rebuilding it. ### Memory `ConfigurationParameters.EmbeddingSetup` decides how vectors are held in memory: - `GraphVectors`: the search graph's copy, as 16-bit integers (`VectorStorage.Int16`, the default) or `Float32`. 16-bit halves the memory and makes search and build about a third faster, with no measurable loss in recall. - `DocumentVectors`: the document's own copy, as `Float32` (the default), `Int16`, `Text` or `NotKept`. With `Float32` a number may come back spelled differently (`0.10` as `0.1`); choose `Text` if you compare documents as text. `NotKept` keeps no copy in the document at all. ```csharp var config = ConfigurationParameters.Default.With( embeddingSetup: EmbeddingSetup.Default.With(documentVectors: DocumentVectorStorage.Text)); ``` --- ## Hybrid Search (C#) Run text search and vector search separately, then merge: ```csharp var textResult = engine.Search(new Query("comfortable headphones", 50)); var embeddingIndex = engine.EmbeddingFields["embedding"]; var vectorResults = embeddingIndex.Search(queryVector, maxResults: 50); var merged = IEmbeddingIndex.MergeHybrid( textResults: textResult.Records, embeddingResults: vectorResults, alpha: 0.6f // 0 = pure text, 1 = pure embedding ); ``` The combined score is: ``` score = alpha × embeddingScore + (1 − alpha) × (textScore / 65535) ``` Documents that appear in only one result set receive a zero contribution from the missing side. `alpha` around `0.5–0.7` works well in most cases. --- ## HTTP API See [Search](/http/Search) for the `search/vector` and `search/hybrid` endpoint reference. --- ## See Also - [How To: Set Up Vector and Hybrid Search](/howto/vector-search) — complete walkthrough with both C# and HTTP examples - [Field Configuration](/csharp/Field) — `Embeddable`, `EmbeddingDimensions` --- # How To Guides --- # :pixl-Ai_agent: Using Indx for AI Agents > Getting started with an agent instead? The onboarding path — skill install, agent-readable docs, MCP connection — is in [Build with an AI Agent](/guides/ai-agents). This page is the runtime and MCP tool reference. Indx is a good fit for AI agents. It is a lightweight embedded library with no external service dependencies — when used via the C# NuGet package, it runs inside the agent's own process with sub-millisecond query times. There is no network round-trip, no managed service to provision, and no cold-start latency. Agents can fire as many searches as needed without debouncing or batching. When used via the HTTP API, Indx is equally fast — search is in-memory and designed for repeated high-frequency queries. --- ## Model Context Protocol (MCP) Indx ships a built-in **MCP server**, so any MCP client — Claude Desktop, Claude Code, or your own agent framework — can search your datasets with no glue code. It exposes the same in-memory engine, and your saved **boost rules**, over the Model Context Protocol. **Endpoint:** `https:///mcp` — authenticate with an API key (Account → API keys) as a `Bearer` token. Admins can turn the server on or off under Admin → Settings. The agent sees exactly what the key reaches: its team, its datasets if you limited it, and its access level. A **Read only** key gives the agent all five tools; a **Search only** key allows `list_datasets`, `search` and `get_document`, and refuses `describe_dataset` and `get_synonyms` with a message saying why. No level lets an agent change data. The server is **read-only** and exposes five tools: - **`list_datasets`** — datasets the token can reach, with document counts and state. - **`describe_dataset`** — the queryable surface: configured fields with their capabilities (searchable / filterable / facetable / sortable) plus **value hints** — the distinct values for facetable fields and numeric ranges — an owner-authored description, and a sample document. Call this first so the agent filters with real values instead of guessing. - **`search`** — ranked results with relevance scores. Declarative filters on filterable fields (`{field, value}` or `{field, min, max}`), optional facets, and full documents (or a projected subset). - **`get_document`** — the full JSON document for a key. - **`get_synonyms`** — the dataset's [synonym list](/howto/synonyms), or null when it has none. Lets an agent see why a search matched more than its literal words. ### Precision by default `search` uses the same precision-first matching described below — near-exact only (`IncludePatternMatches = false`), with typo tolerance retained. An empty result is a trustworthy *"nothing matches"*, so the agent can stop or ask rather than hallucinate. Pass `broaden: true` to opt into broad pattern matching for a single call. Saved boost rules are applied automatically, so agent searches inherit your ranking policy. ### Connecting a client Add the server with the endpoint URL and your API key as a Bearer token. Clients without a custom-header field can use the `mcp-remote` bridge. Pin its version, and pass the key through `env` so it does not show up in the process list: ```json { "mcpServers": { "indx": { "command": "npx", "args": ["mcp-remote@0.14.3", "https:///mcp", "--header", "Authorization:${AUTH_HEADER}"], "env": { "AUTH_HEADER": "Bearer " } } } } ``` There are no spaces around the colon in `Authorization:${AUTH_HEADER}`; the space belongs inside the variable. The tools return your dataset's content as it is, and an agent reads it. If documents hold text from outside sources, such as reviews or scraped pages, keep in mind that some of it could be written to look like instructions to the agent. A Search key limits what such text could lead to: it can only list, search and fetch. A typical agent flow: `describe_dataset` to learn the fields and valid filter values → `search` with text and filters → `get_document` for full detail on a hit. --- ## Precision Over Recall Human search benefits from typo tolerance and fuzzy matching. Agents query with well-formed text and need a clear signal: *did something match, or not?* Configure coverage to reflect that. ### Recommended Coverage Setup ```csharp var cov = new CoverageSetup { CoverFuzzyWords = false, // agents don't make typos CoverWholeWords = true, CoverPrefixSuffix = true, CoverJoinedWords = true, IncludePatternMatches = false, // only return confirmed hits — no soft pattern results }; var query = new Query("invoice payment overdue", 20) { CoverageSetup = cov }; var result = engine.Search(query); ``` With `IncludePatternMatches = false`, the result contains only coverage-confirmed matches. If nothing was confirmed, the result is empty — a clean no-match signal the agent can act on. ### HTTP API ```json { "text": "invoice payment overdue", "maxNumberOfRecordsToReturn": 20, "coverageSetup": { "coverFuzzyWords": false, "coverWholeWords": true, "coverPrefixSuffix": true, "coverJoinedWords": true, "includePatternMatches": false } } ``` --- ## Using the Truncation Point When `IncludePatternMatches` is `true` (or not set), results above `truncationIndex` are coverage-confirmed and results below are softer pattern matches. An agent can use this boundary as a quality gate: ```csharp var result = engine.Search(query); var confirmed = result.TruncationIndex >= 0 ? result.Records[..result.TruncationIndex] : result.Records; ``` --- ## Multiple Queries for Better Recall Agents can compensate for turning off fuzzy matching by issuing multiple searches with reformulated queries — synonyms, related terms, or decomposed phrases. Merge results by document key, keeping the highest score per document: ```csharp var queries = new[] { "payment overdue", "outstanding invoice", "unpaid bill" }; var merged = new Dictionary(); foreach (var text in queries) { var r = engine.Search(new Query(text, 20) { CoverageSetup = cov }); foreach (var entry in r.Records) { if (!merged.TryGetValue(entry.DocumentKey, out var existing) || entry.Score > existing) merged[entry.DocumentKey] = entry.Score; } } var ranked = merged.OrderByDescending(kv => kv.Value).ToList(); ``` Because Indx queries are fast, firing three to five searches adds negligible latency compared to a single LLM call. --- ## Structured Lookups with Filters When the agent knows field values, use filters instead of relying on text search alone: ```csharp var statusFilter = engine.CreateValueFilter("status", "overdue", out _); var query = new Query("payment", 20) { Filter = statusFilter, CoverageSetup = cov }; ``` Combine filters for multi-condition lookups: ```csharp var overdueFilter = engine.CreateValueFilter("status", "overdue", out _); var highValueFilter = engine.CreateRangeFilter("amount", 10000, int.MaxValue, out _); var combined = overdueFilter & highValueFilter; // AND; use | for OR, ! to negate ``` --- ## Letting the Agent Build the Index An agent doesn't need a pre-existing dataset. It can spin up a fresh in-memory index from scratch — useful when the agent is handed a large JSON file, a collection of PDFs, or any content it discovers at runtime. The engine is initialised from the first batch of documents and made searchable immediately. ```csharp var engine = new SearchEngine(); // Build the schema from your first batch of documents using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(firstBatchJson)); engine.Init(stream); // Configure which fields to search engine.GetField("title")!.Searchable = true; engine.GetField("content")!.Searchable = true; // Load the first batch and build the initial index stream.Position = 0; engine.Load(stream); engine.Index(); // Insert additional documents as the agent discovers them — no rebuild needed engine.InsertJsonRecords(additionalDocuments, monitor: null, error: out _); ``` This pattern works well when an agent processes a large document set at task start: parse the content into JSON chunks, load them all, then search freely. Because the engine is embedded and in-memory, the full pipeline — from raw files to searchable index — completes in seconds even for tens of thousands of documents. ### Incremental Updates Agents that discover or generate data over time can continue inserting without rebuilding: ```csharp // Single document engine.InsertJsonRecord( documentKey: 42, json: """{"id":42,"title":"Q2 Report","content":"Revenue up 12%..."}""", monitor: null, error: out _ ); // Batch engine.InsertJsonRecords(newDocuments, monitor: null, error: out _); ``` Updates and deletes work the same way — the index stays in sync after every operation. A full `Index()` rebuild is never required after mutations, though calling it periodically re-normalises scoring for better result quality over time. --- ## Empty Search for Enumeration An agent that needs to enumerate or scan all documents can use an empty search with sorting and filters: ```csharp var query = new Query("", 100) { EnableFacets = true, SortBy = engine.GetField("date")!, SortAscending = false, Filter = statusFilter }; ``` Facets are calculated across the full dataset regardless of `maxNumberOfRecordsToReturn`, giving the agent a complete picture of value distributions without retrieving all documents. --- # :pixl-Json_query: Analyze JSON Field Structure with Init Call `Init` to discover the field structure from your JSON, then use `GetFieldConfiguration()` to inspect the result. ## C# ```csharp using var stream = File.OpenRead("products.json"); engine.Init(stream); foreach (var field in engine.GetFieldConfiguration()) { Console.WriteLine($"{field.FieldName} ({field.FieldType}){(field.IsArray == true ? " IsArray" : "")}"); } ``` ## HTTP API ```bash # Analyze structure curl -X POST https://your-host/api/teams/acme/datasets/products/analyze \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ --data-binary @products.json # Retrieve discovered fields curl https://your-host/api/teams/acme/datasets/products/fields/configuration \ -H "Authorization: Bearer " ``` --- # :pixl-Clock: Schedule Boost Rules for Campaigns Saved **boost rules** lift matching documents in every search that runs with `enableBoost: true`, and they live on the dataset, so editors manage them in the web console's **Boost rules** tab without touching code. Rules can carry a date window — the tool for campaigns, seasonal promotion and new-release pushes. ## A rule ```json { "name": "Autumn crime campaign", "enabled": true, "strength": "High", "join": "And", "conditions": [ { "field": "genre", "value": "crime" }, { "field": "publishedYear", "min": 2025 } ], "activeFrom": "2026-09-15", "activeUntil": "2026-10-31" } ``` - **conditions** — on **filterable** fields: `{ field, value }` for an exact value (strings, numbers, booleans) or `{ field, min?, max? }` for a numeric range. `join` is `And` or `Or`. - **strength** — `Low`, `Med` or `High`. Rules stack: a document matching two rules is lifted by both. - **activeFrom / activeUntil** — optional dates (inclusive). Outside the window the rule is ignored; leave both out for an always-on rule. `enabled: false` switches a rule off without deleting it. ## When a rule expires A rule past its `activeUntil` stops applying the next day. Nothing is deleted, so it is easy to miss — the tab makes it visible three ways: an **Expired** chip on the rule (and a signal-coloured border), a summary line above the list ("3 of 5 rules applying today · 1 expired"), and a count badge on the **Boost rules** tab button so it shows from any tab. Rules that have not started yet show **Starts <date>**; switched-off rules show **Off**. Editors and admins of the team also receive an in-app **Boost rule expired** notification the day after a rule ends (once per rule; mute or route it to email under Account → Manage → Notifications). Extend the dates, switch the rule off, or delete it. ## Import and export The **Boost rules** tab has *Import from json* and *Export as json*, using the same array format as the HTTP endpoints below. Import replaces the working list and leaves it unsaved, so you can review it and Save, or Cancel to revert. Rules that reference fields this dataset does not have are kept and stay dormant (flagged in the tab) until such a field appears — handy for copying a campaign set between datasets that share a schema. ## Over HTTP ``` GET …/boosts → BoostRule[] PUT …/boosts ← BoostRule[] (replaces the whole list; editor role) DELETE …/boosts ``` Boosts also apply to agent searches over MCP and to the web console's search preview, so what an editor sees is what the site serves. ## Popularity and sales ranking Indx records clicks and conversions when your front-end sends them (see [Statistics](/http/Statistics)), but it does not rank on them by itself. To rank by popularity, store a number on each document, for example the conversion counts from Statistics, (`"popularity": 87`) — updated as often as you like through the [real-time document endpoints](/http/DataLoading) — and boost on ranges of it: ```json { "name": "Bestsellers", "strength": "Med", "conditions": [ { "field": "popularity", "min": 80 } ] } ``` ## Per-user personalisation For boosts that differ per request rather than per dataset, build an ad-hoc boost from a filter and pass it with the query — see [Set Up Personalized Boosting](/howto/personalized-boosting). ## What boosts do not do There is no negative boost ("bury") and no pinned position: relevance stays the primary order and a boost only lifts. `High` is the strongest lift available. --- # :pixl-Filter: Set Up Filter or Boost with Multiple Conditions A filter, or a boost that uses a filter, can be a combination of conditions. Imagine a scenario where you want to boost all Documentaries by medium strength if produced after 1980, and low strength if produced earlier. ## C# ```csharp // Assuming fields "year" and "genre" have been set to Filterable = true Filter yearOlderFilter = engine.CreateRangeFilter("year", 1900, 1979, out _)!; Filter yearNewerFilter = engine.CreateRangeFilter("year", 1980, 2025, out _)!; Filter genreFilter = engine.CreateValueFilter("genre", "Documentary", out _)!; var boosts = new List(); boosts.Add(engine.CreateBoost(yearNewerFilter! & genreFilter, BoostStrength.Med)); boosts.Add(engine.CreateBoost(yearOlderFilter! & genreFilter, BoostStrength.Low)); query.Boosts = boosts.ToArray(); query.EnableBoost = true; ``` ## HTTP API ```bash # Dataset endpoints are team-scoped; set BASE once: BASE=".../api/teams//datasets/movies" # Create filters curl -X POST "$BASE/filters/range" -d '{"fieldName":"year","lowerLimit":1980,"upperLimit":2025}' # → {"hashString":"newer123"} curl -X POST "$BASE/filters/range" -d '{"fieldName":"year","lowerLimit":1900,"upperLimit":1979}' # → {"hashString":"older456"} curl -X POST "$BASE/filters/value" -d '{"fieldName":"genre","value":"Documentary"}' # → {"hashString":"genre789"} # Combine: newer AND documentary curl -X POST "$BASE/filters/combine" \ -d '{"a":{"hashString":"newer123"},"b":{"hashString":"genre789"},"useAndOperation":true}' # → {"hashString":"combined1"} # Search with boosts curl -X POST "$BASE/search" \ -d '{"text":"film","maxNumberOfRecordsToReturn":20,"enableBoost":true,"boosts":[{"boostStrength":2,"filterProxy":{"hashString":"combined1"}}]}' ``` --- # :pixl-Coverage: Control Coverage Settings Coverage detects whole or split words, prefixes, suffixes and near-exact hits with minor typos. It returns an index number to truncate the result list. Coverage has default values for all properties, with most functions enabled. In this example we turn off prefix, suffix, and fuzzy words — a mode to only look for whole words with a sharp cutoff. ## C# ```csharp CoverageSetup cov = new CoverageSetup(); // Disable functions cov.CoverFuzzyWords = false; cov.CoverPrefixSuffix = false; cov.CoverWholeQuery = false; // Enable functions and set word size cov.CoverJoinedWords = true; cov.CoverWholeWords = true; cov.MinWordSize = 3; // Pass the settings to the query query.CoverageSetup = cov; ``` ## HTTP API ```json { "text": "search terms", "maxNumberOfRecordsToReturn": 30, "enableCoverage": true, "coverageSetup": { "coverFuzzyWords": false, "coverPrefixSuffix": false, "coverWholeQuery": false, "coverJoinedWords": true, "coverWholeWords": true, "minWordSize": 3 } } ``` ## Settings for a whole dataset On Indx server, a dataset can set its own coverage values in the console's **Query parameters** tab, or with `PUT /api/teams/{team}/datasets/{dataset}/query-parameters`. A search that leaves a value out uses the dataset's. Where the dataset sets nothing, the default applies. A value the search sends always wins, one value at a time. In the example above the search decides six values itself, and the dataset still decides the rest, such as `truncate`. So send only the values a search needs to decide, and leave the others to whoever runs the dataset. A search that used one of the dataset's values names it in the `Indx-Query-Parameters` response header. --- # :pixl-Diving_mask: Perform a Deep Coverage Search The coverage function defaults to processing the top 500 results from pattern search. To retrieve the full number of exact hits, increase the coverage depth to your entire dataset. **The default shows up as a wrong count, not a short page.** Coverage only evaluates the first `coverageDepth` candidates, so on a broad query the hits beyond that depth are never confirmed. The tell is a total that lands exactly on the depth: **a count equal to `coverageDepth` is the cap talking, not your data.** Raising it to the document count turns that 500 into the real number. Coverage cost is linear in depth, so on small and mid-sized datasets the full-dataset depth is effectively free — one production catalogue of ~1100 documents measured no difference between 500 and 1000. Benchmark before raising it on millions of documents. **Use the same depth everywhere a number is shown.** If the result list and the facet counts run at different depths, the two describe different universes, and the facet rail will disagree with the list beside it. ## C# ```csharp query.CoverageDepth = engine.Status.DocumentCount; ``` ## HTTP API ```json { "text": "search terms", "maxNumberOfRecordsToReturn": 1000, "coverageDepth": 50000 } ``` --- # :pixl-Empty: Perform an Empty Search with Sorting and Facets Sometimes users don't want to enter a keyword but just browse using filters and facets. Indx supports this as long as you have at least one field set to facetable. **`enableFacets` is what makes an empty query legal.** Without it, empty text is refused rather than treated as "match everything": the search returns `200` with no records and a `reason` of `Search called with empty string and EnableFacets=false`. Since a refusal and an honest no-match look alike apart from that `reason`, a browse page that forgets the flag shows an empty catalogue and no error. Read `reason` on every search and fold it in with a timeout: a populated `reason` means the query was refused, which is never the same thing as "found nothing". You also need at least one **facetable** field. With facets enabled but nothing facetable, the empty query is served but comes back empty. An empty search goes through all documents and ignores CoverageDepth settings. Empty queries typically take more time than queries with input text. Without a `SortBy` field, results are returned in indexing order (the order documents were loaded). To get meaningful ordering, set at least one field to `Sortable = true` and specify it in the query. ## C# ```csharp query.Text = ""; // or null query.SortBy = engine.GetField("Movie_Rating")!; // default descending, so 8.0 before 7.0 query.EnableFacets = true; ``` ## HTTP API ```json { "text": "", "maxNumberOfRecordsToReturn": 50, "sortBy": "Movie_Rating", "enableFacets": true } ``` --- # :pixl-Coverage_index: Just Return Exact Hits Set `IncludePatternMatches = false` on the `CoverageSetup` to return only coverage-confirmed hits — documents where the query matched as a whole word, fuzzy word, joined word, or prefix/suffix. Pure pattern-match results are excluded. ## C# ```csharp var query = new Query("wireless headphones", 20) { CoverageSetup = new CoverageSetup { IncludePatternMatches = false } }; var result = engine.Search(query); foreach (var entry in result.Records) { Console.WriteLine($"key={entry.DocumentKey} score={entry.Score}"); } ``` ## HTTP API ```json { "text": "wireless headphones", "maxNumberOfRecordsToReturn": 20, "coverageSetup": { "includePatternMatches": false } } ``` If no documents match the coverage criteria, the result will be empty. To control which coverage algorithms are used, see [Coverage Settings](/howto/coverage-settings). --- # :pixl-Linear: Get Min and Max Values from Facets To set up a range filter that corresponds with a facetable number field (e.g. price), use facet data to get the actual min/max values. ## C# ```csharp query.EnableFacets = true; var result = engine.Search(query); if (result.Facets != null && result.Facets.TryGetValue("Price", out var histogram)) { var values = histogram.Select(item => int.Parse(item.Key)).ToList(); int minPrice = values.Min(); int maxPrice = values.Max(); Filter priceRange = engine.CreateRangeFilter("Price", minPrice, maxPrice, out _)!; } ``` ## HTTP API ```bash # Search with facets enabled curl -X POST https://localhost:5001/api/teams/acme/datasets/products/search \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"text": "", "maxNumberOfRecordsToReturn": 1, "enableFacets": true}' ``` ```json // Response includes facet histograms { "facets": { "Price": [ { "key": "10", "value": 45 }, { "key": "25", "value": 120 }, { "key": "99", "value": 30 } ] } } ``` Extract min/max from the facet keys (10 and 99 in this example), then create a range filter: ```bash curl -X POST https://localhost:5001/api/teams/acme/datasets/products/filters/range \ -H "Content-Type: application/json" \ -d '{"fieldName": "Price", "lowerLimit": 10, "upperLimit": 99}' ``` --- # :pixl-Fields: Return the Field Configuration `GetFieldConfiguration()` returns all fields and their current settings as a `FieldProxy[]`. This is the same shape used by `TrySetFieldConfiguration` and the HTTP API. ## C# ```csharp FieldProxy[] fields = engine.GetFieldConfiguration(); foreach (var field in fields) { Console.WriteLine($"{field.FieldName} ({field.FieldType})" + $" searchable={field.Searchable}" + $" filterable={field.Filterable}" + $" facetable={field.Facetable}" + $" sortable={field.Sortable}" + $" weight={field.Weight}"); } ``` To filter by role: ```csharp var searchable = fields.Where(f => f.Searchable == true).ToList(); var filterable = fields.Where(f => f.Filterable == true).ToList(); var facetable = fields.Where(f => f.Facetable == true).ToList(); ``` ## Changing it on a loaded engine `RequiresReload` and `RequiresReindex` say what a change costs before you apply it: a reload, an `Index()`, or nothing. See [Field](/csharp/Field). ## HTTP API ```bash curl https://your-host/api/teams/acme/datasets/products/fields/configuration \ -H "Authorization: Bearer " ``` --- # :pixl-Book: Search Several Content Types in One Index A site search usually spans different kinds of content — books, articles, events, authors, pages, series — and users expect one search box, one ranked list, and counts per type. Indx handles this with **one dataset and a type field**. Documents in a dataset do not need the same shape; fields that only some documents have are simply absent on the others. ## Load everything into one dataset ```json [ { "id": "book-4711", "contentType": "book", "title": "Sult", "author": "Knut Hamsun", "isbn": "978…" }, { "id": "article-88", "contentType": "article","title": "Hamsun i dag", "body": "…", "publishedAt": "2026-03-01" }, { "id": "event-17", "contentType": "event", "title": "Forfattermøte", "startsAt": "2026-10-02", "venue": "Litteraturhuset" }, { "id": "author-9", "contentType": "author", "name": "Knut Hamsun", "bio": "…" } ] ``` Configure the fields once: - `contentType` — **Filterable + Facetable**. This gives per-type counts in every search response and lets a tab or chip narrow to one type. - `title`, `name`, `body`, `bio` — **Searchable**, with weights that express priority (`title` 2.0, `author`/`name` 1.5, `body` 1.0). - Type-specific fields (`isbn`, `startsAt`, `venue`) — Filterable/Sortable as needed. A filter on `startsAt` simply never matches books. ## One request, ranked across types, with counts ```json POST …/search { "text": "hamsun", "maxNumberOfRecordsToReturn": 20, "enableFacets": true } ``` The response is one ranked list mixing books, articles, events and the author, and `facets.contentType` carries the count per type for the same query — the numbers you show on "Books (12) · Articles (4) · Events (1) · Authors (1)". Add a value filter on `contentType` to switch to a single-type view; the facet counts still reflect the full query so the tabs keep their numbers. ## Key field Give every document a key that is unique across types (`"book-4711"`, `"event-17"`), or declare an auto-generated key. Numeric ids that collide across source systems must be prefixed. ## When to use separate datasets instead Only when the types have separate access rules, wildly different update cadences, or must be searched independently by different clients. Each dataset is its own index and its own search call; there is no cross-dataset search, so "one box, one list" needs one dataset. --- # :pixl-Pattern_recognition: Pattern Recognition Only Coverage should stay enabled for nearly all use cases. Only disable it in edge cases where you search a single field and only care about top-K fuzzy matches (e.g. name lookup). With coverage disabled, truncation is unreliable and results degrade when searching across multiple fields. ## Disable Coverage ```csharp query.EnableCoverage = false; ``` ```json { "text": "search terms", "maxNumberOfRecordsToReturn": 50, "enableCoverage": false } ``` ## Alternative: Coverage On, No Truncation A better option is to run coverage but keep all results, including hits that coverage did not mark as near-exact: ```csharp CoverageSetup cov = new CoverageSetup(); cov.Truncate = false; query.CoverageSetup = cov; ``` --- # :pixl-Boost: Set Up Personalized Boosting Personalization can be a powerful benefit to your users. You can set up boost lists per user or user group. Applying a boost costs the same however many documents it covers; building a very large per-user list has its own cost, since each item in it is a filter. **Scenario:** An online grocery store with two active users. - **User1** regularly buys broccoli and tomatoes - **User2** often buys cheese, especially discounted items We boost on a single item if a user has bought it once. If they've purchased more than 10 times, we increase the boost strength. We also boost all discounted products globally. ## C# ```csharp // Set up global boost for all discounted items Filter discountFilter = engine.CreateValueFilter("discounted", true, out _)!; Boost globalDiscountBoost = engine.CreateBoost(discountFilter, BoostStrength.Low); // PERSONALISATION PER USER var userBoosts = new List(); // Lists of purchased items by item number List highPurchases = new List{2, 3}; // Broccoli and cherry tomatoes List lowPurchases = new List{1}; // Strawberry jam // Build a combined filter for each boost level by OR-ing per-item filters. // item_number holds numbers, so each one is a range with equal limits rather than a // value filter: a value filter compares text, and the engine refuses it on a numeric // field. Equal limits compare as numbers and read the field's index. Filter? highPurchaseFilter = null; Filter? lowPurchaseFilter = null; foreach (long itemNumber in highPurchases) { Filter fil = engine.CreateRangeFilter("item_number", itemNumber, itemNumber, out _)!; highPurchaseFilter = highPurchaseFilter is null ? fil : highPurchaseFilter | fil; } foreach (long itemNumber in lowPurchases) { Filter fil = engine.CreateRangeFilter("item_number", itemNumber, itemNumber, out _)!; lowPurchaseFilter = lowPurchaseFilter is null ? fil : lowPurchaseFilter | fil; } userBoosts.Add(engine.CreateBoost(highPurchaseFilter!, BoostStrength.Med)); userBoosts.Add(engine.CreateBoost(lowPurchaseFilter!, BoostStrength.Low)); userBoosts.Add(globalDiscountBoost); // Use in query query.Boosts = userBoosts.ToArray(); query.EnableBoost = true; ``` ## HTTP API ```bash # Calls are under the team-scoped dataset path; set BASE once # (token comes from the portal's API Key page): BASE="https://localhost:5001/api/teams//datasets/groceries" AUTH="Authorization: Bearer " # 1. Create global discount filter curl -X POST "$BASE/filters/value" -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{"fieldName": "discounted", "value": "true"}' # → {"hashString":"discount123"} # 2. Create boost for discounted items (Low strength) curl -X POST "$BASE/boosts/from-filter" -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{"boostStrength": 1, "filterProxy": {"hashString": "discount123"}}' # 3. Create per-user filters for frequently purchased items. # item_number holds numbers, so these go to filters/range with equal limits. # filters/value on a numeric field is answered 400, naming this as the fix. curl -X POST "$BASE/filters/range" -H "$AUTH" \ -d '{"fieldName": "item_number", "lowerLimit": 2, "upperLimit": 2}' # → {"hashString":"item2"} curl -X POST "$BASE/filters/range" -H "$AUTH" \ -d '{"fieldName": "item_number", "lowerLimit": 3, "upperLimit": 3}' # → {"hashString":"item3"} # 4. Combine user item filters (OR) curl -X POST "$BASE/filters/combine" -H "$AUTH" \ -d '{"a": {"hashString": "item2"}, "b": {"hashString": "item3"}, "useAndOperation": false}' # → {"hashString":"userItems123"} # 5. Create user boost (Med strength) curl -X POST "$BASE/boosts/from-filter" -H "$AUTH" \ -d '{"boostStrength": 2, "filterProxy": {"hashString": "userItems123"}}' # 6. Search with boosts curl -X POST "$BASE/search" -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "text": "vegetables", "maxNumberOfRecordsToReturn": 20, "enableBoost": true, "boosts": [ {"boostStrength": 1, "filterProxy": {"hashString": "discount123"}}, {"boostStrength": 2, "filterProxy": {"hashString": "userItems123"}} ] }' ``` --- # :pixl-Save: Save the Field Configuration After running `Init()` at least once, you can save the field configuration to a file. This lets you skip `Init` on subsequent loads. ## C# **First run** — analyze, configure fields, save: ```csharp var engine = new SearchEngine(); using var initStream = File.OpenRead("data/imdb_top10k.json"); engine.Init(initStream); engine.TrySetFieldConfiguration([ new FieldProxy { FieldName = "Movie_Name", Searchable = true, Weight = 2.0f }, new FieldProxy { FieldName = "Stars", Searchable = true, Facetable = true, Weight = 0.75f }, new FieldProxy { FieldName = "Description", Searchable = true, Weight = 1.0f }, new FieldProxy { FieldName = "Year_of_Release", Facetable = true }, ], out string error); engine.SaveFieldConfiguration("fieldconfig.json"); // ... load and index ``` **Subsequent loads** — skip Init, load config from file: ```csharp var engine = new SearchEngine(); engine.LoadFieldConfiguration("fieldconfig.json"); // ... load and index ``` ## HTTP API Field configuration is stored server-side per dataset. There is no save/load file step — configuration persists automatically after each `PUT fields/configuration` call. **First time** — analyze and configure: ```bash # 1. Analyze JSON structure curl -X POST https://your-host/api/teams//datasets/movies/analyze \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ --data-binary @imdb_top10k.json # 2. Configure fields (204 No Content) curl -X PUT https://your-host/api/teams//datasets/movies/fields/configuration \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '[ { "fieldName": "Movie_Name", "searchable": true, "weight": 2.0 }, { "fieldName": "Stars", "searchable": true, "facetable": true, "weight": 0.75 }, { "fieldName": "Description", "searchable": true, "weight": 1.0 }, { "fieldName": "Year_of_Release", "facetable": true } ]' ``` **Subsequent loads** — reload documents from the persisted database (`204 No Content`): ```bash curl -X POST https://your-host/api/teams//datasets/movies/load/from-database \ -H "Authorization: Bearer " ``` --- # :pixl-Cloud: Use Indx from the Server (Next.js, SSR, any backend) The HTTP API is plain REST over `fetch`. Nothing about it needs a browser, so it works from a Node server, a Next.js Route Handler or Server Component, an edge function, or any backend. The API key stays on the server and never reaches the client. ## Packages - **`@indxsearch/indx-types`** — TypeScript types for every request and response. Types only, no runtime: safe in server components, edge runtimes and client bundles alike. - **`@indxsearch/intrface`** — React *client* components for the interactive search UI (input, results, facets, sorting). Use it for the parts that respond to typing. ## Server-rendered search page (Next.js App Router) ```ts // app/search/page.tsx — a Server Component import type { QueryProxy, Result } from '@indxsearch/indx-types'; const BASE = `${process.env.INDX_URL}/api/teams/${process.env.INDX_TEAM}/datasets/${process.env.INDX_DATASET}`; const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.INDX_TOKEN}` }; export default async function SearchPage({ searchParams }: { searchParams: { q?: string } }) { const query: QueryProxy = { text: searchParams.q ?? '', maxNumberOfRecordsToReturn: 20, enableFacets: true }; const result: Result = await fetch(`${BASE}/search`, { method: 'POST', headers, body: JSON.stringify(query), cache: 'no-store' }) .then(r => r.json()); const docs = await fetch(`${BASE}/documents/lookup`, { method: 'POST', headers, body: JSON.stringify(result.records.map(r => r.documentKey)) }) .then(r => r.json()); return ; } ``` The first page renders on the server with the full result and facet counts, so it is crawlable and needs no client JavaScript to show results. Hand over to `@indxsearch/intrface` on the client for search-as-you-type; it calls the same endpoints with a token you issue for the browser — a separate key with the access level **Search only**, limited to the datasets the page searches. Every visitor can read that key, so it must not be able to do anything else. ## Caching Responses are deterministic for a given request body and index build. Cache them in your framework's data cache or a CDN as you would any API response — the empty (browse) search is the usual candidate — and invalidate when documents change or the dataset is reloaded. ## Timeouts and cancellation Set `timeOutLimitMilliseconds` in the query to bound scoring time (the response then carries `didTimeOut`), and pass an `AbortSignal` to `fetch` for a hard client-side cancel. --- # :pixl-Synonym: Set Up Synonyms A synonym list widens searches: when a query matches an entry, the entry's terms are appended to the query text before scoring. Expansion happens at search time — nothing is re-indexed, and changes apply on the very next search. There are two kinds of entries: - **Multidirectional** — all terms are equivalent. Matching any of them pulls in the whole group. Use for inflected forms and spelling variants: *geriatri / geriatrisk / geriatriske*. - **OneWay** — only the `source` term expands, into `terms`. Matching a term does **not** pull in the source or its siblings. Use for acronyms: *hms* should bring in *helse, miljø og sikkerhet*, but a search for *helse* must not become a search for *hms*. Multi-word terms are matched as whole phrases, and matching splits on the same delimiters as indexing, so punctuation like `geriatri,` still triggers the entry. > Widening a query lengthens its text, which lowers Coverage scores proportionally — measure > the effect on your own data before deploying a large list. ## The file format (C# and portal import/export) ```json { "Name": "medical-terms", "Description": "Inflections and acronyms", "Entries": [ { "Direction": "Multidirectional", "Terms": ["geriatri", "geriatrisk", "geriatriske"] }, { "Direction": "OneWay", "Source": "hms", "Terms": ["helse, miljø og sikkerhet"] } ] } ``` ## C# Assign a list to the engine — every subsequent search expands through it. The engine works on its own copy of the query, so your `Query` object is never modified. ```csharp engine.SynonymList = new SynonymList { Entries = [ new SynonymEntry { Direction = SynonymDirection.Multidirectional, Terms = ["geriatri", "geriatrisk", "geriatriske"] }, new SynonymEntry { Direction = SynonymDirection.OneWay, Source = "hms", Terms = ["helse, miljø og sikkerhet"] }, ] }; ``` File in/out follows the field-configuration pattern: ```csharp engine.SaveSynonyms("synonyms.json"); // serialize the current list engine.LoadSynonyms("synonyms.json"); // load and apply a saved list engine.SynonymList = null; // search without synonyms again ``` ## HTTP API The list is stored per dataset and survives restarts. `GET` requires read access; `PUT` requires the editor role. ```bash # Read the dataset's list — 200 with the list, or a literal null when it has none curl https://your-host/api/teams//datasets//synonyms \ -H "Authorization: Bearer " # Set (replace) the list — 204 No Content curl -X PUT https://your-host/api/teams//datasets//synonyms \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ --data-binary @synonyms.json # Remove the list — send a null body curl -X PUT https://your-host/api/teams//datasets//synonyms \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d 'null' ``` Over the HTTP API, `direction` is numeric: `0` = Multidirectional, `1` = OneWay. ```json { "entries": [ { "direction": 0, "terms": ["geriatri", "geriatrisk", "geriatriske"] }, { "direction": 1, "source": "hms", "terms": ["helse, miljø og sikkerhet"] } ] } ``` The dataset can stay in `Ready` the whole time — no state transition, no re-index. In the web console, the **Synonyms** tab on a dataset offers the same operations: create and edit entries in a dialog, filter them (the filter box is itself an Indx search), and import/export the list as JSON. --- # :pixl-Duplicate: Model Variants and Editions A catalogue often has one *work* with several *editions* or *variants*: a book in hardback, paperback and e-book; a product in three colours and two sizes. Users want **one result card per work**, but they want to **filter at variant level** ("paperback under 200 kr"). Indx does this without a group-by step: put the variants inside the document as an array. ## One document per work ```json { "id": 4711, "title": "Sult", "author": "Knut Hamsun", "editions": [ { "isbn": "9788205001954", "format": "paperback", "price": 149, "inStock": true }, { "isbn": "9788205001961", "format": "hardback", "price": 349, "inStock": false }, { "isbn": "9788205001978", "format": "ebook", "price": 99, "inStock": true } ] } ``` `Init` names the nested fields `editions.isbn`, `editions.format`, `editions.price` and `editions.inStock`, each holding **one value per array element**. Mark them Filterable and Facetable as usual. ## Filtering at variant level A value or range filter on an array field matches the document if **any** element matches. Combine filters with AND to require the same document to satisfy all of them: ```json // paperbacks under 200 POST …/filters/value { "fieldName": "editions.format", "value": "paperback" } → A POST …/filters/range { "fieldName": "editions.price", "lowerLimit": 0, "upperLimit": 200 } → B POST …/filters/combine { "a": A, "b": B, "useAndOperation": true } ``` Note the semantics: the combined filter matches documents that have *a* paperback edition *and* *an* edition under 200. For most catalogues those are the same edition; when they must be, denormalise a combined field (`"formatPrice": "paperback:149"`) or keep one document per edition and accept multiple cards. Facets on `editions.format` count documents, not editions — "paperback (312)" means 312 works have a paperback edition. ## What `RemoveDuplicates` does — and does not — do `Query.RemoveDuplicates` (default `true`) keeps only the best-scoring document among those that share the same **foreign key**. It is not a group-by on arbitrary fields: it will not collapse three separate edition documents into one card. If your source gives you one row per edition, merge them into one document per work before loading, as above. ## Showing the right edition on the card Search results return the whole document, so the card can pick the edition to display — the cheapest in stock, or the one matching the active format filter — client-side from the `editions` array. No second request is needed. --- # :pixl-Rag_search: Set Up Vector and Hybrid Search This guide assumes you have an embedding model that produces float arrays. See [Vector & Hybrid Search](/guides/vector-search) for concept background and mode selection. ## C# ### Prepare Your Data Each document must include the embedding as a JSON float array: ```json [ { "id": 1, "title": "Wireless Headphones", "embedding": [0.12, -0.45, 0.33, ...] }, { "id": 2, "title": "Bluetooth Speaker", "embedding": [0.08, 0.21, -0.14, ...] } ] ``` ### Configure and Load ```csharp var engine = new SearchEngine(); using var stream = File.OpenRead("products.json"); engine.Init(stream); engine.GetField("title")!.Searchable = true; engine.GetField("embedding")!.Embeddable = true; // excluded from text index stream.Position = 0; engine.Load(stream); engine.Index(); ``` ### Vector Search ```csharp float[] queryVector = await myEmbeddingClient.EmbedAsync("comfortable headphones"); var index = engine.EmbeddingFields["embedding"]; var results = index.Search(queryVector, maxResults: 10); var keys = results.Select(r => r.documentKey).ToArray(); var docs = engine.GetJsonDataOfKeys(keys); ``` ### Hybrid Search ```csharp float[] queryVector = await myEmbeddingClient.EmbedAsync("comfortable headphones"); var textResult = engine.Search(new Query("comfortable headphones", 50)); var vectorResults = engine.EmbeddingFields["embedding"].Search(queryVector, maxResults: 50); var merged = IEmbeddingIndex.MergeHybrid( textResults: textResult.Records, embeddingResults: vectorResults, alpha: 0.6f ); var keys = merged.Select(r => r.documentKey).ToArray(); var docs = engine.GetJsonDataOfKeys(keys); ``` --- ## HTTP API ### Configure Fields ```bash curl -X PUT https://your-host/api/teams/acme/datasets/products/fields/configuration \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '[ { "fieldName": "title", "searchable": true }, { "fieldName": "embedding", "embeddable": true } ]' ``` ### Vector Search ```bash curl -X POST https://your-host/api/teams/acme/datasets/products/search/vector \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "fieldName": "embedding", "vector": [0.12, -0.45, 0.33, ...], "maxResults": 10 }' ``` ### Hybrid Search ```bash curl -X POST https://your-host/api/teams/acme/datasets/products/search/hybrid \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "text": "comfortable headphones", "embeddingField": "embedding", "vector": [0.12, -0.45, 0.33, ...], "alpha": 0.6, "maxNumberOfRecordsToReturn": 20 }' ``` Both endpoints return an array of `{ "documentKey": n, "score": 0.94 }` with scores in `[0, 1]`. Use `POST documents/lookup` to fetch the full documents by key. --- # :pixl-Typo_or_bug: Troubleshooting Indexed by **symptom**, because several different causes produce the same one. Most of the entries below share a shape worth knowing up front: a query Indx *refuses* comes back looking like a query that simply found nothing. ## The first check, for any "no results" A refused search returns `200` with no records and a populated `reason` (`Reason` in C#). An honest no-match returns `200` with no records and `reason: null`. They differ in that one field and nowhere else. ```ts const result = await search(client, query); if (result.reason || result.didTimeOut) { // Refused or timed out — a failure. Say so; never render it as "no results". } ``` Do this once, in whatever wraps your search calls, and most of this page stops being able to bite you. Then read on for the cause. --- ## Search returns nothing, for every query **A `fieldBoosts` key that is not a searchable field empties the whole search.** Unknown fields and fields that *were* searchable until someone changed the configuration both do it. The response says so: `reason` carries "FieldBoosts contains unknown or non-searchable field" with the field's name, so the first check above catches it. Check the boost keys against the configuration: ```bash curl -H "Authorization: Bearer $KEY" \ ".../api/teams/$TEAM/datasets/$DATASET/fields/configuration" ``` Every key you send must appear there with `"searchable": true`. The tell is a surface split: the page that sends boosts returns nothing while a page that sends none works perfectly. If you keep weights and boosts in code, assert one against the other in a test — see [the field configuration how-to](/howto/field-configuration). **The dataset is not `Ready`.** `GET status` reports the state and, when it failed, `errorMessage`. A dataset that is loading, indexing or hibernated cannot serve. --- ## An empty query returns nothing **Empty text needs `enableFacets: true`.** Without it the query is refused rather than treated as match-everything, with `reason` set to `Search called with empty string and EnableFacets=false`. The dataset also needs at least one **facetable** field. See [Empty search](/howto/empty-search). --- ## Inserts fail with "field(s) not in the field configuration" **The document carries a field the dataset does not know**, a new field in your CMS, say. Insert and update refuse it with `400 invalidArgument` naming the field, and store nothing: one such document fails its whole batch. (Until October 2026 the field was accepted, shown in hit payloads and never indexed, so "the field shows in results but searching on it finds nothing" is the same cause on an older server.) Only **Replace** can add a field. Re-analyze and rebuild from a full export (`POST …/replace`, or Options → Replace data in the console), which reports the fields it added. Configuring roles and weights on *existing* fields does not need it. The mirror image is worth knowing too: a field in your **configuration** that the engine has not discovered is *rejected*, taking the whole configuration call down with a `400 invalidArgument`. One optional field that happens to be empty in every document is enough. Apply what the engine discovered and report the rest loudly, rather than sending a configuration the dataset cannot accept. --- ## The result count is wrong, or suspiciously round **A count equal to `coverageDepth` is the cap, not your data.** Coverage confirms only the first `coverageDepth` candidates (default **500**), so a broad query reports exactly that many. Raise it to the document count; the cost is linear in depth, so on small and mid-sized datasets it is effectively free. See [Deep coverage](/howto/deep-coverage). **The list and the facet counts disagree.** They ran at different depths, so they describe different universes. Use one depth per surface. --- ## Results are ranked oddly after a change **`fieldBoosts` multiply the configured weight, they do not replace it.** A field at `weight: 3.0` with a boost of `3.0` counts 9×. Keep the ladder in the field configuration and use boosts only for what one surface wants differently. **Pattern matching always returns its nearest shapes,** so a loose query can never answer "nothing" — and score does not separate a useful guess from noise. Search precisely first (`coverageSetup.includePatternMatches: false`); only if that finds nothing, search again with pattern matches on, and label those rows as guesses. See [Exact hits](/howto/exact-hits) and [Pattern only](/howto/pattern-only). --- ## A request that used to work returns 404 or 403 **`403 insufficientKeyScope`** — the key's access level is below what the endpoint needs. Search only keys search; reads like export and field configuration need Read only; writes need Full access. **`404`** — the team or dataset is outside the key's scope. This is deliberately the same answer as a team or dataset that does not exist, so names cannot be enumerated through a key. Two causes look identical here: - the key names datasets and this is not one of them; - **the dataset was renamed.** A key that lists datasets by name silently stops reaching a renamed one. - **the team was renamed.** The team name is part of every path (`/api/teams/{team}/…`), so calls to the old name find no team. The keys themselves keep working; update the path. - **the key is a personal key and its owner left the team.** A personal key acts as the person who made it. For anything deployed, use a team key (Manage team → API keys), which does not depend on one person's membership. Keys cannot be edited after creation. To change a level or a dataset list, create a new key and revoke the old one. --- ## Search works locally but not in production **The key belongs to the other server.** Keys are signed per server, so a key minted on your local instance fails against the cloud with `401` and a signature error, and the reverse. Check the host the key was created on before anything else. **A `VITE_`-style build-time variable changed without a redeploy.** Anything inlined at build time ships with the bundle; changing it in the hosting dashboard does nothing until the next deploy. --- ## A term stays empty long after the index recovered **An empty answer was cached.** A query that ran during a reload or against a not-ready dataset returns no records; cache that and the term stays empty for the life of the entry. Cache hits, not misses, and never cache a response carrying a `reason` or `didTimeOut`. --- ## Nothing here matches `GET status` is the first place to look: it carries the state, the document count, and `errorMessage` from the last refused call. Errors from the HTTP API are RFC 9457 problem documents with a machine-readable `code` — see the [HTTP endpoints reference](/http/endpoints) for the full table. --- # GDPR & Privacy Indx ships in two very different forms, and they have very different data‑handling profiles. The compiled **search library** processes no personal data at all. **Indx** is a full application that does store data — but you run it, and that data never reaches Indx. This statement covers both. --- ## Indx search library (NuGet / DLL) The Indx search library is provided as a compiled binary (`.dll`) or as a NuGet package. It is designed to operate entirely within the host application and **does not transmit, collect, or store any personal data**. - :pixl-Check: **No data transmission** — the library does not send or receive any data over the internet or any external network. - :pixl-Check: **No telemetry** — there is no telemetry, tracking, or analytics built into the library. - :pixl-Check: **No logging of personal data** — the library does not log, store, or transmit any personal or user‑identifiable data. - :pixl-Check: **Local execution** — all operations are performed locally, in memory, within your own environment. Because it processes no personal data as defined under the regulation, **the library is fully compliant with the General Data Protection Regulation (GDPR)** by design. Licensing is handled out‑of‑band — the library itself makes no network calls — so nothing leaves your process. --- ## Indx (self‑hosted) Indx is the HTTP/Blazor server that wraps the engine. Unlike the library, it is a complete application that *does* store data — but **all of it stays inside the environment you deploy it to**. Indx, the company, never receives your data, your documents, or your search traffic. ### What it stores - :pixl-Database: **Identity database** (`identity.db`) — accounts for the users you create to access the API: email address, hashed password, and roles. Managed by ASP.NET Identity. - :pixl-Database: **Application database** (`indx.db`) — configuration and metadata about your search instances and teams. - :pixl-Ram: **Search indexes** — built from the JSON documents you load and held **in memory only**. They are never written to disk; hibernating a dataset frees them, and waking rebuilds them from the stored documents. ### Who controls the data - :pixl-User_privacy: **You are the data controller.** Any personal data in the documents you index, and in the accounts you create, is under your control. Because that data never leaves your servers, Indx is not a processor of it. - :pixl-Lock: **No telemetry.** Indx sends no usage data, analytics, or diagnostics back to Indx. - :pixl-Cloud: **Outbound email is opt‑in.** Account emails (confirmation, password reset) are sent only if you configure an email provider (Azure Communication Services). With no provider configured, these messages are written to the console/log and nothing leaves your machine. As the controller, you remain responsible for the usual obligations under the GDPR — lawful basis, data‑subject requests, retention, and securing the host environment. --- ## Managed (Azure) edition The Azure Managed Application edition is deployed into **your own Azure subscription**, in the **Azure region you choose** — for EU/EEA residency pick a European region such as **Norway East** or **West Europe**. Data residency and operational control stay with you: Indx provides the application, but does not operate it on your behalf and does not receive your indexed data or search traffic. All processing happens inside your subscription, so no data leaves the EU/EEA unless you deploy elsewhere. Because search responses are computed entirely in your environment, you may cache them in your own server or CDN layer as you see fit; invalidate on reload or document change. --- ## This documentation site docs.indx.co is hosted on Vercel. Site search sends your query to an Indx server run by Indx and returns the matching pages; nothing is stored. The **Ask** tab sends your question, and the earlier turns of the same conversation, to the same Indx server for retrieval and then to Anthropic's Claude API for the answer. Questions are logged, with the answer's timing and the pages it drew on, to find where the docs fall short; the log holds no account or contact details, and the IP address is used only to rate-limit the assistant, never stored with the question. Do not put personal data or secrets in a question. --- ## Contact Indx does not process personal data on your behalf in either edition, so a Data Processing Agreement is normally not required. Where your procurement process asks for one, Indx signs a DPA on request — contact [support@indx.co](mailto:support@indx.co) for data‑protection questions or a DPA. --- # C# API Reference IndxSearchLib 5.0.2 --- # SearchEngine The `SearchEngine` class is the core entry point for all operations. It manages the full lifecycle: analyzing data structure, configuring fields, loading data, indexing, and searching. ## Lifecycle ``` Init(stream) → Configure fields → Load(stream) → Index() → Search(query) ``` After `Index()` the engine is in `Ready` state and can be searched continuously. Dynamic operations (`InsertJsonRecord`, `UpdateJsonRecord`, `DeleteJsonRecord`) keep the index in sync without requiring a full `Index()` rebuild. ## Constructor ```csharp // Default — works for most cases (ConfigurationParameters.Default) var engine = new SearchEngine(); // With license file var engine = new SearchEngine("indx-developer.license"); // With logging and an explicit configuration var engine = new SearchEngine( logPrefix: "MyApp", factory: loggerFactory, configuration: ConfigurationParameters.Default, licenseFileName: "indx-developer.license" ); // Vary one thing and state the rest by name, rather than hand-copying every parameter var dense = new SearchEngine( logPrefix: "MyApp", factory: loggerFactory, configuration: ConfigurationParameters.Default.With(indexer: IndexerSetup.Dense), licenseFileName: "indx-developer.license" ); ``` ## Basic Usage ```csharp using Indx.Api; var engine = new SearchEngine(); // 1. Analyze JSON structure using var stream = File.OpenRead("products.json"); engine.Init(stream); // 2. Configure fields engine.GetField("name")!.Searchable = true; engine.GetField("name")!.Weight = 2.0f; engine.GetField("description")!.Searchable = true; engine.GetField("category")!.Filterable = true; engine.GetField("price")!.Sortable = true; // 3. Load and index stream.Position = 0; engine.Load(stream); engine.Index(); // 4. Search var result = engine.Search(new Query("wireless headphones", 20)); ``` ## Lifecycle Methods | Method | Description | |--------|-------------| | `Init(Stream, ...)` | Analyze the JSON stream and discover fields. Must be called first | | `Load(Stream, ...)` | Parse and store documents. Call after configuring fields | | `Index(...)` | Build the inverted index. Call after `Load` | | `Search(Query)` | Run a query. Engine must be in `Ready` state | | `Hibernate(out string)` | Release index memory, keep documents in RAM — the engine returns to `Loaded` | | `WakeUp()` | Rebuild the index and reload filters — the way out of `Loaded` | | `Unload(out string)` | Remove documents and reset to `Created` state | | `Dispose()` | Free all resources | ## Field Access | Method | Returns | Description | |--------|---------|-------------| | `GetField(string)` | `Field?` | Get a field by name. Returns `null` if not found | | `GetFieldList()` | `List` | All discovered fields | | `GetFieldConfiguration()` | `FieldProxy[]` | Full configuration snapshot of all fields | | `TrySetFieldConfiguration(FieldProxy[], out string)` | `bool` | Apply a field configuration. `false` with the reason when any part is refused, and nothing is applied | | `TryValidateFieldConfiguration(FieldProxy[], out string)` | `bool` | Whether `TrySetFieldConfiguration` would accept the same argument, changing nothing | | `SetFieldConfiguration(FieldProxy[])` | `string?` | Obsolete: use `TrySetFieldConfiguration`. Returns an error message or `null` on success | ## Dynamic Operations Insert, update, and delete documents after indexing without rebuilding from scratch. In BM25F mode the shared index is updated incrementally. A periodic full `Index()` re-normalises avgdl drift that accumulates over many mutations. > **A field the configuration does not know is refused.** `InsertJsonRecord(s)` and `UpdateJsonRecord(s)` return `false` with "... the document has field(s) not in the field configuration: 'color'" (nested fields by path, `'meta.batch'`), and change nothing: a batch with one such record is refused whole, and an update leaves the document as it was. To add a field, rebuild: `Init`, configure, `Load`, `Index` (in Indx, `replace`). Missing non-key fields are treated as null; a missing key field fails the batch. | Method | Description | |--------|-------------| | `InsertJsonRecord(string json, out string error)` | Insert a single JSON document. Returns `false` on error | | `InsertJsonRecords(IEnumerable, ProcessMonitor?, out string error)` | Batch insert from a collection | | `InsertJsonRecords(Stream, ProcessMonitor?, out string error)` | Batch insert from a JSON stream | | `UpdateJsonRecord(string json, out string error)` | Replace a document by key | | `UpdateJsonRecords(IEnumerable, ProcessMonitor?, out string error)` | Batch update from a collection | | `UpdateJsonRecords(Stream, ProcessMonitor?, out string error)` | Batch update from a JSON stream | | `DeleteJsonRecord(long id)` | Remove a single document by key | | `DeleteJsonRecords(IEnumerable, ProcessMonitor?)` | Remove multiple documents | | `DeleteRecordsInFilter(Filter)` | Remove all documents matching a filter | | `UpdateField(long id, string fieldName, object? value, out string error)` | Update a single field value on one document | | `UpdateFieldInFilter(Filter, string fieldName, object value, out string error)` | Update a field on all documents matching a filter. Returns count updated | ```csharp // Insert a new document bool ok = engine.InsertJsonRecord( """{"id": 999, "name": "New Product", "price": 49.99}""", out string error); // Update a field value on one document engine.UpdateField(documentKey, "price", 39.99, out _); // Delete a document engine.DeleteJsonRecord(documentKey); ``` ## Data Retrieval | Method | Returns | Description | |--------|---------|-------------| | `GetJsonDataOfKey(long)` | `string` | Full JSON of a document by key | | `GetJsonDataOfKeys(IEnumerable)` | `IEnumerable` | Full JSON for multiple keys | ## Filters | Method | Returns | Description | |--------|---------|-------------| | `CreateValueFilter(string fieldName, object value, out string? error)` | `Filter?` | Match documents where field equals value. The field must **not** hold numbers (`null` + `error` on failure) | | `CreateValueFilter(string fieldName, object value, bool isCaseSensitive, out string? error)` | `Filter?` | As above, choosing case sensitivity. Off by default | | `CreateRangeFilter(string fieldName, double min, double max, out string? error)` | `Filter?` | Match documents where field falls within an inclusive range. The field must hold numbers; **equal limits are numeric equality** (`null` + `error` on failure) | | `CreateRangeFilter(string fieldName, double min, double max, CultureInfo culture, out string? error)` | `Filter?` | As above, parsing the bounds in a given culture rather than `InvariantCulture` | | `LoadAllFilters(...)` | — | Pre-load all filterable fields for faster repeated filtering | | `LoadFilters(Filter[])` | `bool` | Pre-load specific filters | | `DeleteFilter(Filter)` | `bool` | Release a cached filter | | `DeleteAllFilters()` | — | Release all cached filters | | `PreLoadedFilters` | `IReadOnlyDictionary<...>` | Access filters cached via `Field.PreloadFilters = true` | ## Persistence | Method | Description | |--------|-------------| | `SaveFieldConfiguration(string path)` | Save field config to a JSON file | | `LoadFieldConfiguration(string path)` | Restore field config from a JSON file | ## Properties | Property | Type | Description | |----------|------|-------------| | `Status` | `SystemStatus` | Current engine state, counters, and error flags | | `ScoringMode` | `ScoringMode` | `BM25F` or `BM25PerField` — resolved at `Index()` time | | `DocumentFields` | `DocumentFields` | Field metadata discovered by `Init()` | | `NumberOfFilters` | `int` | Number of currently cached filters | | `PreLoadedFilters` | `IReadOnlyDictionary<...>` | Filters preloaded via `Field.PreloadFilters` | ## ScoringMode `ScoringMode` is read-only and resolved automatically at `Index()` time: | Mode | When selected | Behaviour | |------|---------------|-----------| | `BM25F` | All searchable fields share the same `BM25k1` | One shared inverted index; single saturation step on aggregated pseudo term-frequency | | `BM25PerField` | At least two searchable fields have different `BM25k1` | Separate index per field; scores merged by `Field.Weight` | ```csharp engine.Index(); Console.WriteLine(engine.ScoringMode); // BM25F or BM25PerField ``` ## SearchEngine (class) Properties: DocumentFields: DocumentFields (get) — Field metadata discovered after Init() completes. Contains all fields with their type and configuration flags. NumberOfFilters: int (get) — Returns the number of filters currently held in the filter cache. Persistence: IPersistence (get/set) — Persistence provider used for hibernation and wake-up serialization. Assigned by the cloud layer; null in standalone use. PreLoadedFilters: IReadOnlyDictionary> (get) — Provides access to all value filters that were pre-loaded during Load. Only fields marked with Field.PreloadFilters = true appear here. The outer key is the field name; the inner key is the filter value as a string. Populated after Load completes. Status: SystemStatus (get/set) — Returns the current state and health of the search engine. Includes document count, version, license information, and errors from the most recent Init or Load operation. Recoverable and unrecoverable errors from the last operation are available via Status.RecoverableErrors and Status.UnrecoverableErrors even after the ProcessMonitor reference has been discarded. ScoringMode: ScoringMode (get/set) — The scoring algorithm chosen by the engine attime, based on whetheris uniform across searchable fields. Uniform values (or fewer than two searchable fields) select; differing values select. Inspect afterto see what the engine actually used. SynonymList: SynonymList (get/set) — Optional synonym list applied to every subsequent search. When set, the engine expands the query text throughbefore scoring — matching terms are appended to the text, separated by spaces. The query is cut into words using the engine's own, so punctuation attached to a word does not stop it matching. The expansion runs on the engine's own copy of the, so the instance passed tois never modified. IsDisposed: bool (get) — True once this engine has been disposed. A disposed engine must not be searched and itsmust not be read (that can throw). Hosting layers probe this to report a stale instance instead of mistaking its last-known state for a live one. EmbeddingFields: IReadOnlyDictionary (get) — EmbeddableRead-only view of the HNSW embedding indexes, one per field that hasset to true. Methods: SearchEngine CreateInMemoryClone(ProcessMonitor monitor = null, FieldProxy[] fieldOverrides = null) fieldOverridesBuilds an independent SearchEngine with the same in-memory state as this instance: same field configuration, same documents, freshly built index. Does not touch persistence — works for class-library clients that never configured a database. The clone has its own native-memory pools and can be mutated/disposed independently of the original. The clone is built by emitting the current document store as a JSON array, feeding it through Init/Load on the new instance, and rebuilding the index. Documents marked as Deleted are skipped. This is the foundation used by the cloud layer's shadow-swap path; callers that want shadow-swap-like behaviour in their own application can build it on top of this method. Behaviour mirrors:(optional) is applied after the clone has mirrored this engine's configuration but beforeruns. This is the supported way to change Searchable/WordIndexing/Embeddable/BM25b/BM25k1 on a shadow copy: those flags are consumed byduring Load to build the per-field index collection, so they must be set pre-Load to take effect. Throwswith the reason when an override is onerefuses: a field that does not exist, a value out of range, a role the field cannot have. Vectors follow the documents. Where the store does not keep them () the documents carry none and the clone takes each from this engine's graph, which is then the graph's copy of it: with the graph on 16-bit integers, the original to about four digits. Result Search(Query searchQueryIn) Executes a search and returns ranked results. Requires that Init, field configuration, Load, and Index have all completed successfully. The query controls the search text via Query.Text, the maximum number of results via Query.MaxNumberOfRecordsToReturn, optional filtering via Query.Filter, score boosting via Query.Boosts, field-based sorting via Query.SortBy, facet histograms via Query.EnableFacets, and the number of candidate documents evaluated via Query.CoverageDepth. Search is thread-safe and can be called concurrently from multiple threads. Searching an engine that is not Ready — one that is loading, indexing or hibernated — returns an empty result with Result.DidTimeOut set, so it can be told apart from a search that ran and found nothing. Check that flag before showing "no results" to anyone: a dataset being reloaded would otherwise look like a dataset with nothing in it. bool DeleteJsonRecord(long id) String@)Deletes the document with the given key. The document is immediately removed from search results. This is a blocking call. Returns false if no document with that key exists, and also when the engine is not Ready or an index build is under way;says which. bool DeleteJsonRecord(long id, String& error) Int64)errorAs, with the reason for ain: no document has the key, the engine is in a state that takes no changes (named), or an index build is running. Empty on success. void DeleteRecordsInFilter(Filter filter) Deletes all documents that match the given filter. Equivalent to iterating the filter mask and calling DeleteJsonRecord for each matching document. List GetAllDocuments() Returns a list of all loaded documents including their raw JSON and pre-parsed field positions. Intended for bulk operations and diagnostics. Document GetDocumentJsonOfKey(long key) Returns a ready parsed DocumentJson which allows fast substring extraction of fields. string GetJsonDataOfKey(long key) bool InsertJsonRecord(string jsonData, String& error) Inserts a single JSON record without requiring a full reload. The document key is extracted from the JSON using the configured key field. The record is immediately searchable after this call returns. Returns false and populates error if the JSON is invalid or the key is missing. bool UpdateJsonRecord(string jsonData, String& error) Replaces an existing record identified by the document key found in the JSON. If no record with that key exists the call fails and error is populated. The updated record is immediately searchable after this call returns. bool InsertJsonRecords(IEnumerable records, ProcessMonitor monitor, String& error) bool InsertJsonRecords(Stream jsonStream, ProcessMonitor monitor, String& error) Inserts multiple JSON records from a stream containing a JSON array. If monitor is null this is a blocking call. If a monitor is provided, insertion runs asynchronously on a background thread. In that case the caller must keep the stream alive until ProcessMonitor.IsCompleted is true. bool UpdateJsonRecords(IEnumerable records, ProcessMonitor monitor, String& error) bool UpdateJsonRecords(Stream jsonStream, ProcessMonitor monitor, String& error) Replaces multiple records from a stream containing a JSON array. If monitor is null this is a blocking call. If a monitor is provided, the operation runs asynchronously and the stream must be kept alive until ProcessMonitor.IsCompleted is true. bool DeleteJsonRecords(IEnumerable ids, ProcessMonitor monitor = null) bool DeleteJsonRecords(IEnumerable ids, ProcessMonitor monitor, String& error) bool UpdateField(long id, string fieldName, object value, String& error) Updates a single field within an existing document without replacing the entire record. Supports nested fields using dot notation, for example "address.city", and array element access using index notation, for example "tags[0]". Type compatibility is enforced: the new value must match the type of the existing field value. Returns false and populates error on failure. int UpdateFieldInFilter(Filter filter, string fieldName, object value, String& error) Setstoon every document admitted by. The filter is loaded automatically if it has not been loaded yet. Returns the number of documents successfully updated. bool SaveEmbeddings(string path, String& error) Writes the search graph of every vector field to a file, with its vectors as the graph holds them, so that a laterof the same documents can take the graphs from there in place of building them (). Building is the slow part of loading a dataset with vectors: 9 s for 43 000 vectors of 1 536 numbers and four minutes for 1.2 million of 100 on four cores, where reading the file is copying arrays. bool LoadEmbeddings(string path, String& error) Reads graphs written by, for the nextto take in place of building them. Call it after the field configuration is set and before. List GetFacetableFieldList() Returns all fields currently marked as Facetable. Field GetField(string fieldName) Returns the field with the given name, or null if not found. List GetFieldList() Returns all detected fields. List GetFilterableFieldList() Returns all fields currently marked as Filterable. bool TrySetFieldConfiguration(FieldProxy[] fields, String& error) errorApplies a batch of per-field configuration in one call. Nullable properties onhave replace semantics:leaves the existing value untouched; any non-null value (including) overwrites. Returnson success. On,says why — a proposed field the engine does not have (named in the message), a value or a combination of roles it refuses, or no field configuration at all because Init has not run — and nothing was applied. The caller is responsible for callingafterwards if any reindex-requiring property changed — useto check. string SetFieldConfiguration(FieldProxy[] fields) String@)The previous shape of, and the one member of this surface wheremeant success: returns the name of the first proposed field the engine does not have,when there is no field configuration, orwhen it applied. Behaves exactly as before. bool TryValidateFieldConfiguration(FieldProxy[] fields, String& error) String@)Says whetherwould accept, and changes nothing. The same answer in the same way: false with the reason in. FieldProxy[] GetFieldConfiguration() FieldProxyReturns the complete configuration of every field as aarray, including all flags, weights and BM25F parameters. bool LoadFieldConfiguration(string configurationFile) Loads field configuration previously saved by SaveFieldConfiguration, restoring Searchable, Filterable, Facetable, Sortable and other field settings. Call this after Init to avoid manually reconfiguring each field. Returns false if the file does not exist or cannot be parsed. Use theoverload to learn which. bool LoadFieldConfiguration(string configurationFile, String& error) String)errorAs, with the reason for ain: the file does not exist, or it could not be read or parsed. bool SaveFieldConfiguration(string configurationFile) Saves the current field configuration to a file so it can be restored later with LoadFieldConfiguration. Returns false if DocumentFields is null or the file cannot be written. Use theoverload to learn which. bool SaveFieldConfiguration(string configurationFile, String& error) String)errorAs, with the reason for ain: there is no field configuration yet (Init has not run), or the file could not be written. bool LoadSynonyms(string synonymFile) Loads a synonym list from a file previously written byand makes it the engine's active, replacing any list already set. Every search from then on is widened through it. Returns false if the file does not exist or cannot be parsed, in which case the previously active list is left untouched. Use theoverload to learn which. bool LoadSynonyms(string synonymFile, String& error) String)errorAs, with the reason for ain: the file does not exist, or it could not be read or parsed. bool SaveSynonyms(string synonymFile) Saves the engine's activeto a file so it can be restored later with. Returns false if no list is set or the file cannot be written. Use theoverload to learn which. bool SaveSynonyms(string synonymFile, String& error) String)errorAs, with the reason for ain: no synonym list is set, or the file could not be written. void SetDocumentFieldsInternal(DocumentFields documentFields) Intended for Cloud project. Boost CreateBoost(Filter filter, BoostStrength strength) Creates a boost that increases the score of documents matching the given filter. Must be called after Load completes. Pass the resulting Boost instance to Query.Boosts before calling Search. Filter CreateRangeFilter(string fieldName, double lowerInclusiveLimit, double upperInclusiveLimit, String& error) Creates a filter matching documents where fieldName falls within the inclusive numeric range from lowerInclusiveLimit to upperInclusiveLimit. The field must be marked as Filterable and hold numbers: a number stored as a JSON string is text, and a range filter on such a field is refused with the fix in error (store the values as numbers and load again). Equal limits make it an equality filter, which is the right way to ask for one value of a numeric field. Values are parsed using CultureInfo.InvariantCulture. Returns null if the engine is not initialized, the field is not found, not filterable, not numeric, or the range is invalid — in which case error describes the exact cause; error is null on success. Filter CreateRangeFilter(string fieldName, double lowerInclusiveLimit, double upperInclusiveLimit, CultureInfo cultureInfo, String& error) Creates a range filter using a specific CultureInfo when parsing numeric values. See CreateRangeFilter(string, double, double, out string) for full documentation. Filter CreateValueFilter(string fieldName, object filterValue, String& error) Creates a case-insensitive filter matching documents where fieldName equals filterValue, compared as text. The field must be marked as Filterable and must not hold numbers: on a numeric field a text comparison is wrong (129 is not 129.0) and cannot use the field's index, so it is refused with the alternative in error - a range filter with equal limits. Returns null if the engine is not initialized, the field is not found, not filterable, numeric, or the value is unusable — in which case error describes the exact cause; error is null on success. Filter CreateValueFilter(string fieldName, object filterValue, bool isCaseSensitive, String& error) Creates a value filter with explicit case sensitivity. See CreateValueFilter(string, object, out string) for full documentation, including whyis what makes a filter count agree with a facet count. void DeleteAllFilters() Removes all filters from the filter cache. Any query referencing a deleted filter will return empty results. bool DeleteFilter(Filter filter) Removes a single filter from the filter cache. Does not cascade to filters derived from this one via boolean operators. Returns false if not found. bool DeleteFilter(Filter filter, String& error) Filter)errorAs, with the reason for ain: no filter was given, or the cache holds none with its key (it was never loaded, was deleted already, or was evicted). Empty on success. Filter GetFilterFromKey(string key) Retrieves or reconstructs a filter from its serialized key string. Intended for HTTP/cloud scenarios where the filter object may have been evicted from the cache between requests. void LoadAllFilters(ProcessMonitor monitor = null) Loads all filters in the cache using all available CPU cores. A filter created with CreateValueFilter or CreateRangeFilter is not usable in a query until it has been loaded. If a monitor is provided the operation runs asynchronously. void LoadAllFilters(int maxThreadCount, ProcessMonitor monitor = null) Loads all filters using at most maxThreadCount threads. See LoadAllFilters(ProcessMonitor) for full documentation. bool LoadFilters(Filter[] filterList) Loads a specific set of filters. Filters not in the array are unaffected. Returns false if the engine is not in a state that allows filter loading, or if the load failed. Use theoverload to learn which. bool LoadFilters(Filter[] filterList, String& error) Filter[])errorAs, with the reason for ain. bool LoadFilters(Filter[] filterList, int maxThreadCount) Loads a specific set of filters using at most maxThreadCount threads. Returns false if the engine is not in a state that allows filter loading, or if the load failed. bool LoadFilters(Filter[] filterList, int maxThreadCount, String& error) Int32)errorAs, with the reason for ain. void Dispose() Dispose. bool Hibernate(String& errorMessage) Frees index memory while retaining document data, returning the engine to the Loaded state — one step back along the lifecycle, from which Index (or WakeUp) rebuilds search capability without reloading data. Returns false if the engine is not in the Ready state. void Index(ProcessMonitor monitor = null) Builds the search indexes required for Search. Must be called after Load completes and at least one field has been marked as Searchable. BM25 parameters (,) are per field and have sensible defaults. The scoring algorithm () is selected automatically based on whetheris uniform across all searchable fields. If a monitor is provided, indexing runs asynchronously. Callto block until done. void Init(Stream jsonStream, string nameOfKeyField, ProcessMonitor monitor = null) Scans the JSON stream to detect the field structure of the data. Must be called before configuring fields and before Load. The stream position is reset to zero after Init completes so the same stream instance can be passed directly to Load. Recoverable parse errors such as control characters or trailing commas are silently sanitized unless ProcessMonitor.MaxRecoveryAttempts is exceeded, in which case the operation aborts and ProcessMonitor.Succeeded is set to false. Unrecoverable parse errors always abort immediately. If a monitor is provided the error is recorded in ProcessMonitor.UnrecoverableErrors and Succeeded is false. If no monitor is provided a JsonException is thrown. void Init(Stream jsonStream, ProcessMonitor monitor = null) Convenience overload of Init that assumes the key field is named "id". See Init(Stream, string, ProcessMonitor) for full documentation. void Load(Stream jsonStream, ProcessMonitor monitor = null) Loads JSON data into the search engine. Must be called after Init and field configuration, and before Index. The stream position is reset to zero before reading, so the same stream used for Init can be passed here directly. Recoverable parse errors such as control characters or trailing commas are silently sanitized and counted. Each distinct error, grouped by source and message, is recorded once in ProcessMonitor.RecoverableErrors with an occurrence count. The ParseResult details and originating exception are captured from the first occurrence only. If the number of recoverable errors exceeds ProcessMonitor.MaxRecoveryAttempts the load is aborted, Succeeded is set to false, and the error that caused the limit to be exceeded is also added to ProcessMonitor.UnrecoverableErrors. Unrecoverable parse errors always abort the load immediately. The error is added to ProcessMonitor.UnrecoverableErrors and Succeeded is set to false. If no monitor is provided a JsonException is thrown instead. After Load completes, all error details are mirrored to Status.RecoverableErrors and Status.UnrecoverableErrors so they remain accessible after the monitor reference is discarded. Task LoadAsync(Stream jsonStream, ProcessMonitor monitor = null) Used for cloud project. bool LoadDocumentFieldsFromDb() Used for Cloud project. bool LoadDocumentFieldsFromDb(String& error) void LoadFromDatabaseSync(ProcessMonitor monitor) Intended for Cloud project. Use regular Load in other cases. bool Unload(String& errorMessage) bool WakeUp() String@)Rebuilds all indexes and reloads all filters — the transition out of Loaded, packaged as one call. The convenience counterpart to Hibernate. Uses all available CPU cores. Returns false if the engine is not in the Loaded state, or if the index build or the filter load did not succeed. Useto learn which. bool WakeUp(String& error) WakeUperrorAs, with the reason for ain: the engine was not Loaded, the index build failed, or the filter load failed. A failed index build can still leave the engine Ready — the search index built and only the filter indexes did not — and that is reported as a failure, because the call did not do everything it was asked. bool WakeUp(int maxThreadCount) WakeUpAs, using at most maxThreadCount threads when rebuilding indexes and reloading filters. bool WakeUp(int maxThreadCount, String& error) String@)As, using at most maxThreadCount threads. ## ISearchEngine (interface) Primary interface for field-based search over JSON data. Typical usage: call Init to detect fields, configure those fields, call Load to populate the engine, call Index to build search indexes, then call Search. Properties: NumberOfFilters: int (get) — Returns the number of filters currently held in the filter cache. PreLoadedFilters: IReadOnlyDictionary> (get) — Provides access to all value filters that were pre-loaded during Load. Only fields marked with Field.PreloadFilters = true appear here. The outer key is the field name; the inner key is the filter value as a string. Populated after Load completes. Status: SystemStatus (get) — Returns the current state and health of the search engine. Includes document count, version, license information, and errors from the most recent Init or Load operation. Recoverable and unrecoverable errors from the last operation are available via Status.RecoverableErrors and Status.UnrecoverableErrors even after the ProcessMonitor reference has been discarded. SynonymList: SynonymList (get/set) — Optional synonym list applied to every subsequent search. When set, the engine expands the query text throughbefore scoring — matching terms are appended to the text, separated by spaces. The query is cut into words using the engine's own, so punctuation attached to a word does not stop it matching. The expansion runs on the engine's own copy of the, so the instance passed tois never modified. Methods: void Init(Stream jsonStream, string nameOfKeyField, ProcessMonitor monitor = null) Scans the JSON stream to detect the field structure of the data. Must be called before configuring fields and before Load. The stream position is reset to zero after Init completes so the same stream instance can be passed directly to Load. Recoverable parse errors such as control characters or trailing commas are silently sanitized unless ProcessMonitor.MaxRecoveryAttempts is exceeded, in which case the operation aborts and ProcessMonitor.Succeeded is set to false. Unrecoverable parse errors always abort immediately. If a monitor is provided the error is recorded in ProcessMonitor.UnrecoverableErrors and Succeeded is false. If no monitor is provided a JsonException is thrown. void Init(Stream jsonStream, ProcessMonitor monitor = null) Convenience overload of Init that assumes the key field is named "id". See Init(Stream, string, ProcessMonitor) for full documentation. void Load(Stream jsonStream, ProcessMonitor monitor = null) Loads JSON data into the search engine. Must be called after Init and field configuration, and before Index. The stream position is reset to zero before reading, so the same stream used for Init can be passed here directly. Recoverable parse errors such as control characters or trailing commas are silently sanitized and counted. Each distinct error, grouped by source and message, is recorded once in ProcessMonitor.RecoverableErrors with an occurrence count. The ParseResult details and originating exception are captured from the first occurrence only. If the number of recoverable errors exceeds ProcessMonitor.MaxRecoveryAttempts the load is aborted, Succeeded is set to false, and the error that caused the limit to be exceeded is also added to ProcessMonitor.UnrecoverableErrors. Unrecoverable parse errors always abort the load immediately. The error is added to ProcessMonitor.UnrecoverableErrors and Succeeded is set to false. If no monitor is provided a JsonException is thrown instead. After Load completes, all error details are mirrored to Status.RecoverableErrors and Status.UnrecoverableErrors so they remain accessible after the monitor reference is discarded. void Index(ProcessMonitor monitor = null) Builds the search indexes required for Search. Must be called after Load completes and at least one field has been marked as Searchable. BM25 parameters (,) are per field and have sensible defaults. The scoring algorithm () is selected automatically based on whetheris uniform across all searchable fields. If a monitor is provided, indexing runs asynchronously. Callto block until done. bool Unload(String& errorMessage) Unloads all documents, indexes, and filters from memory. The engine returns to its initial state (Created) and must go through Load and Index again before searching. Returns false if the engine is not in the Ready state, in which case errorMessage describes the reason. bool Hibernate(String& errorMessage) Frees index memory while retaining document data, returning the engine to the Loaded state — one step back along the lifecycle, from which Index (or WakeUp) rebuilds search capability without reloading data. Returns false if the engine is not in the Ready state. bool WakeUp() String@)Rebuilds all indexes and reloads all filters — the transition out of Loaded, packaged as one call. The convenience counterpart to Hibernate. Uses all available CPU cores. Returns false if the engine is not in the Loaded state, or if the index build or the filter load did not succeed. Useto learn which. bool WakeUp(String& error) WakeUperrorAs, with the reason for ain: the engine was not Loaded, the index build failed, or the filter load failed. A failed index build can still leave the engine Ready — the search index built and only the filter indexes did not — and that is reported as a failure, because the call did not do everything it was asked. bool WakeUp(int maxThreadCount) WakeUpAs, using at most maxThreadCount threads when rebuilding indexes and reloading filters. bool WakeUp(int maxThreadCount, String& error) String@)As, using at most maxThreadCount threads. bool LoadFieldConfiguration(string configurationFile) Loads field configuration previously saved by SaveFieldConfiguration, restoring Searchable, Filterable, Facetable, Sortable and other field settings. Call this after Init to avoid manually reconfiguring each field. Returns false if the file does not exist or cannot be parsed. Use theoverload to learn which. bool LoadFieldConfiguration(string configurationFile, String& error) String)errorAs, with the reason for ain: the file does not exist, or it could not be read or parsed. bool SaveFieldConfiguration(string configurationFile) Saves the current field configuration to a file so it can be restored later with LoadFieldConfiguration. Returns false if DocumentFields is null or the file cannot be written. Use theoverload to learn which. bool SaveFieldConfiguration(string configurationFile, String& error) String)errorAs, with the reason for ain: there is no field configuration yet (Init has not run), or the file could not be written. bool LoadSynonyms(string synonymFile) Loads a synonym list from a file previously written byand makes it the engine's active, replacing any list already set. Every search from then on is widened through it. Returns false if the file does not exist or cannot be parsed, in which case the previously active list is left untouched. Use theoverload to learn which. bool LoadSynonyms(string synonymFile, String& error) String)errorAs, with the reason for ain: the file does not exist, or it could not be read or parsed. bool SaveSynonyms(string synonymFile) Saves the engine's activeto a file so it can be restored later with. Returns false if no list is set or the file cannot be written. Use theoverload to learn which. bool SaveSynonyms(string synonymFile, String& error) String)errorAs, with the reason for ain: no synonym list is set, or the file could not be written. bool InsertJsonRecord(string jsonData, String& error) Inserts a single JSON record without requiring a full reload. The document key is extracted from the JSON using the configured key field. The record is immediately searchable after this call returns. Returns false and populates error if the JSON is invalid or the key is missing. bool InsertJsonRecords(Stream jsonStream, ProcessMonitor monitor, String& error) Inserts multiple JSON records from a stream containing a JSON array. If monitor is null this is a blocking call. If a monitor is provided, insertion runs asynchronously on a background thread. In that case the caller must keep the stream alive until ProcessMonitor.IsCompleted is true. bool InsertJsonRecords(IEnumerable records, ProcessMonitor monitor, String& error) bool UpdateJsonRecord(string jsonData, String& error) Replaces an existing record identified by the document key found in the JSON. If no record with that key exists the call fails and error is populated. The updated record is immediately searchable after this call returns. bool UpdateJsonRecords(Stream jsonStream, ProcessMonitor monitor, String& error) Replaces multiple records from a stream containing a JSON array. If monitor is null this is a blocking call. If a monitor is provided, the operation runs asynchronously and the stream must be kept alive until ProcessMonitor.IsCompleted is true. bool UpdateJsonRecords(IEnumerable records, ProcessMonitor monitor, String& error) bool UpdateField(long id, string fieldName, object value, String& error) Updates a single field within an existing document without replacing the entire record. Supports nested fields using dot notation, for example "address.city", and array element access using index notation, for example "tags[0]". Type compatibility is enforced: the new value must match the type of the existing field value. Returns false and populates error on failure. bool DeleteJsonRecord(long id) String@)Deletes the document with the given key. The document is immediately removed from search results. This is a blocking call. Returns false if no document with that key exists, and also when the engine is not Ready or an index build is under way;says which. bool DeleteJsonRecord(long id, String& error) Int64)errorAs, with the reason for ain: no document has the key, the engine is in a state that takes no changes (named), or an index build is running. Empty on success. bool DeleteJsonRecords(IEnumerable ids, ProcessMonitor monitor = null) bool DeleteJsonRecords(IEnumerable ids, ProcessMonitor monitor, String& error) void DeleteRecordsInFilter(Filter filter) Deletes all documents that match the given filter. Equivalent to iterating the filter mask and calling DeleteJsonRecord for each matching document. int UpdateFieldInFilter(Filter filter, string fieldName, object value, String& error) Setstoon every document admitted by. The filter is loaded automatically if it has not been loaded yet. Returns the number of documents successfully updated. List GetAllDocuments() Returns a list of all loaded documents including their raw JSON and pre-parsed field positions. Intended for bulk operations and diagnostics. string GetJsonDataOfKey(long key) Returns the raw JSON string for the document with the given key, or null if no document with that key exists. List GetFacetableFieldList() Returns all fields currently marked as Facetable. Field GetField(string fieldName) Returns the field with the given name, or null if not found. List GetFieldList() Returns all detected fields. List GetFilterableFieldList() Returns all fields currently marked as Filterable. bool TrySetFieldConfiguration(FieldProxy[] fields, String& error) errorApplies a batch of per-field configuration in one call. Nullable properties onhave replace semantics:leaves the existing value untouched; any non-null value (including) overwrites. Returnson success. On,says why — a proposed field the engine does not have (named in the message), a value or a combination of roles it refuses, or no field configuration at all because Init has not run — and nothing was applied. The caller is responsible for callingafterwards if any reindex-requiring property changed — useto check. bool TryValidateFieldConfiguration(FieldProxy[] fields, String& error) String@)Says whetherwould accept, and changes nothing. The same answer in the same way: false with the reason in. string SetFieldConfiguration(FieldProxy[] fields) String@)The previous shape of, and the one member of this surface wheremeant success: returns the name of the first proposed field the engine does not have,when there is no field configuration, orwhen it applied. Behaves exactly as before. FieldProxy[] GetFieldConfiguration() FieldProxyReturns the complete configuration of every field as aarray, including all flags, weights and BM25F parameters. SearchEngine CreateInMemoryClone(ProcessMonitor monitor = null, FieldProxy[] fieldOverrides = null) InvalidOperationExceptionBuilds an independent SearchEngine with the same in-memory state: same field configuration, same documents, freshly built index. Does not require persistence. The clone has its own native-memory pools and can be mutated and disposed independently of the original. Foundation for shadow-swap patterns: build a clone, mutate it, swap your instance reference, dispose the old one. Ifis supplied it is wired to the Index phase (the dominant cost), giving callers progress visibility and a cancellation handle. Init and Load run silently with internal monitors.(optional) is applied after the clone has mirrored this engine's configuration but beforeruns — the supported way to flip Searchable/WordIndexing/Embeddable/BM25b/BM25k1 on a shadow copy. Those flags are consumed byduring Load to build the per-field index collection, so they must be set pre-Load to take effect. Throwsif any override references a non-existent field. Boost CreateBoost(Filter filter, BoostStrength strength) Creates a boost that increases the score of documents matching the given filter. Must be called after Load completes. Pass the resulting Boost instance to Query.Boosts before calling Search. Filter CreateRangeFilter(string fieldName, double lowerInclusiveLimit, double upperInclusiveLimit, String& error) Creates a filter matching documents where fieldName falls within the inclusive numeric range from lowerInclusiveLimit to upperInclusiveLimit. The field must be marked as Filterable and hold numbers: a number stored as a JSON string is text, and a range filter on such a field is refused with the fix in error (store the values as numbers and load again). Equal limits make it an equality filter, which is the right way to ask for one value of a numeric field. Values are parsed using CultureInfo.InvariantCulture. Returns null if the engine is not initialized, the field is not found, not filterable, not numeric, or the range is invalid — in which case error describes the exact cause; error is null on success. Filter CreateRangeFilter(string fieldName, double lowerInclusiveLimit, double upperInclusiveLimit, CultureInfo cultureInfo, String& error) Creates a range filter using a specific CultureInfo when parsing numeric values. See CreateRangeFilter(string, double, double, out string) for full documentation. Filter CreateValueFilter(string fieldName, object filterValue, String& error) Creates a case-insensitive filter matching documents where fieldName equals filterValue, compared as text. The field must be marked as Filterable and must not hold numbers: on a numeric field a text comparison is wrong (129 is not 129.0) and cannot use the field's index, so it is refused with the alternative in error - a range filter with equal limits. Returns null if the engine is not initialized, the field is not found, not filterable, numeric, or the value is unusable — in which case error describes the exact cause; error is null on success. Filter CreateValueFilter(string fieldName, object filterValue, bool isCaseSensitive, String& error) Creates a value filter with explicit case sensitivity. See CreateValueFilter(string, object, out string) for full documentation, including whyis what makes a filter count agree with a facet count. void DeleteAllFilters() Removes all filters from the filter cache. Any query referencing a deleted filter will return empty results. bool DeleteFilter(Filter filter) Removes a single filter from the filter cache. Does not cascade to filters derived from this one via boolean operators. Returns false if not found. bool DeleteFilter(Filter filter, String& error) Filter)errorAs, with the reason for ain: no filter was given, or the cache holds none with its key (it was never loaded, was deleted already, or was evicted). Empty on success. Filter GetFilterFromKey(string key) Retrieves or reconstructs a filter from its serialized key string. Intended for HTTP/cloud scenarios where the filter object may have been evicted from the cache between requests. void LoadAllFilters(ProcessMonitor monitor = null) Loads all filters in the cache using all available CPU cores. A filter created with CreateValueFilter or CreateRangeFilter is not usable in a query until it has been loaded. If a monitor is provided the operation runs asynchronously. void LoadAllFilters(int maxThreadCount, ProcessMonitor monitor = null) Loads all filters using at most maxThreadCount threads. See LoadAllFilters(ProcessMonitor) for full documentation. bool LoadFilters(Filter[] filters) Loads a specific set of filters. Filters not in the array are unaffected. Returns false if the engine is not in a state that allows filter loading, or if the load failed. Use theoverload to learn which. bool LoadFilters(Filter[] filterList, String& error) Filter[])errorAs, with the reason for ain. bool LoadFilters(Filter[] filterList, int maxThreadCount) Loads a specific set of filters using at most maxThreadCount threads. Returns false if the engine is not in a state that allows filter loading, or if the load failed. bool LoadFilters(Filter[] filterList, int maxThreadCount, String& error) Int32)errorAs, with the reason for ain. Result Search(Query query) Executes a search and returns ranked results. Requires that Init, field configuration, Load, and Index have all completed successfully. The query controls the search text via Query.Text, the maximum number of results via Query.MaxNumberOfRecordsToReturn, optional filtering via Query.Filter, score boosting via Query.Boosts, field-based sorting via Query.SortBy, facet histograms via Query.EnableFacets, and the number of candidate documents evaluated via Query.CoverageDepth. Search is thread-safe and can be called concurrently from multiple threads. Searching an engine that is not Ready — one that is loading, indexing or hibernated — returns an empty result with Result.DidTimeOut set, so it can be told apart from a search that ran and found nothing. Check that flag before showing "no results" to anyone: a dataset being reloaded would otherwise look like a dataset with nothing in it. --- ## ConfigurationParameters (class) Everything an engine needs to know about turning text into an index: n-gram sizes, start/stop characters, stop-term limit, the index and search text ceilings, case sensitivity, tokenizer and text-normalizer setup, filter cache size, and word matching. Properties: Indexer: IndexerSetup (get) — How text becomes index entries: gram sizes, character weighting, the stop-word threshold, case sensitivity, and the indexed-text and search-text ceilings. FilterCacheSize: int (get) — Size of the filter cache, in entries. TextNormalizer: TextNormalizer (get) TokenizerSetup: TokenizerSetup (get) WordMatcherSetup: WordMatcherSetup (get) — LD1 / affix fuzzy matching at search time; null disables it. EmbeddingSetup: EmbeddingSetup (get) — How vector fields are held and searched. Default: ConfigurationParameters (get) — The standard production configuration: trigram scalar indexing (start characters 2), digram+trigram array indexing (start character 1), full diacritic normalization, and the LD1 + affix word matcher. This is what an engine gets when no configuration is supplied. Methods: ConfigurationParameters With(IndexerSetup indexer = null, TokenizerSetup tokenizerSetup = null, TextNormalizer textNormalizer = null, int? filterCacheSize = null, WordMatcherSetup wordMatcherSetup = null, EmbeddingSetup embeddingSetup = null) void Save(Stream stream) Writes this configuration as JSON. The counterpart to: what comes back is equal to what went in, property for property, including the diacritic table inside. void Save(string path) Stream)Writes this configuration to a file. See. static ConfigurationParameters Load(Stream stream) Stream)Reads a configuration written by. The constructor validates it on the way in, so a file that has been hand-edited into nonsense throws here rather than producing an engine that behaves oddly later. static ConfigurationParameters Load(string path) Stream)Reads a configuration from a file. See. static TextNormalizer CreateDefaultOneWayNormalizer() Builds the standard Indx one-way text normalizer (diacritic folding + delimiter string replacements). Kept because it is public API; the table itself moved to, which is now its only definition. Returns a fresh instance, as it always did —is the shared one. ## TokenizerSetup (class) The Indx search engine do not have a concept of words, or in more general terms tokens. It may however, by this TokenizerSetup class, be passed a tokenizer if one wants to index entire tokens in addition to the regular pattern used by SearchEngine. For most use cases this is not needed. Indexing tokens will tend to increase weight of exact word hits. Token hit weighting may also be adjusted by the product configuration made by Indx. Properties: Delimiters: Char[] (get) — The token delimiters. Returns a COPY: the array is handed to callers, and a get-only property protects the reference, never the contents — without the copy,would retune every engine in the process that took the defaults. HighResolutionMode: bool (get/set) — HighResolutionMode will make the tokenizer also to generate indexes after removal of all delimiters. MaxSizeOfWordToken: int (get/set) — Limits the token size to be indexed. Longer tokens will not be indexed. MinimumWordSize: int (get) — Defines the minimum wordSize to get, tokenized. Note that this parameter is currently only used for Field.WordIndexing. Default: TokenizerSetup (get) — The production tokenizer setup: the full delimiter set, high resolution off. ## TextNormalizer (class) TextNormalizer is usually part of the configuration and will contain both a StringReplacer and a CharReplacer instance. It will make the indexed text are repesented in a normalized way. The StringReplacer will typically be applied to remove characters that are insignificant for the search and that usually will not appear in the search query. The CharReplacer will typically be used to convert national characters in an invariant way .i.e to convert 'æ' to 'a'. The property OneWayMode of the CharReplacer, will make the system search and index both converted and unconverted character set. Properties: CharReplacer: CharReplacer (get) — return the CharReplacer class. StringReplacer: StringReplacer (get) — return the StringReplacer. Default: TextNormalizer (get) — The production normalizer: diacritic folding plus the delimiter string replacements. Shared, so an engine that takes the defaults does not rebuild the table. Methods: static TextNormalizer CreateDefaultOneWay() DefaultBuilds a fresh copy of the standard one-way normalizer.is one of these, cached; call this instead when you intend to own the instance. ## WordMatcherSetup (class) Fuzzy word matching at search time: whether a query word may match an indexed word it does not equal, and within which word lengths. Properties: MaximumWordSizeExact: int (get/set) — Longest word eligible for exact matching. Past this a word is matched on its n-grams alone. MaximumWordSizeLD1: int (get/set) — MaximumWordSizeExactLongest word eligible for edit-distance-1 matching. Keep at or below. MinimumWordSizeExact: int (get/set) — Shortest word eligible for exact matching. MinimumWordSizeLD1: int (get/set) — Shortest word eligible for LD1. Higher than the exact minimum on purpose: at three characters, one edit reaches a large share of the vocabulary and the match stops meaning much. SupportLD1: bool (get/set) — Allow a one-edit difference — insertion, deletion or substitution — so a typo still finds the word. The forgiving half, and the expensive half. SupportAffix: bool (get/set) — Allow a match on a word that extends the query word, so "fish" reaches "fishing". Prefix-style matching for word tokens. Default: WordMatcherSetup (get) — The production word matcher: LD1 and affix matching ON. Methods: string ToString() int GetHashCode() bool Equals(object obj) bool Equals(WordMatcherSetup other) WordMatcherSetup $() void Deconstruct(Int32& MaximumWordSizeExact, Int32& MaximumWordSizeLD1, Int32& MinimumWordSizeExact, Int32& MinimumWordSizeLD1, Boolean& SupportLD1, Boolean& SupportAffix) ## CharReplacer (class) CharReplacer will keep an array of character replacements which applied by the replace method when passed a string. Properties: OneWayMode: bool (get/set) — When i.e. 'ä' is replaced with 'a' the system will not be able to distinguish a search for Q = "grä" and a search for Q ="gra". Setting OneWayMode = true will overcome this problem on the cost of slightly higher resource usage. The overhead will depend on the fraction of the documents which differs after replacement. ToBeReplaced: Char[] (get) — StringReplacerThe characters replaced, and what each becomes — the same parallel-array shapeuses. Exposed so a normalizer can be written out and read back: without it the replacement table was unreachable from the public surface, and a configuration that round-tripped through JSON came back silently unable to fold diacritics. It would still index and still search, just differently. ReplaceBy: Char[] (get) Methods: string Replace(Span sourceAndDestination, Boolean& didChange) string Replace(string inputText, Boolean& didChange) ## StringReplacer (class) StringReplacer makes it possible to replace or remove sub strings from the search. One may for example replace national characters like 'ø' with 'oe'. An instance of this class can be passed to the SearchEngine constructor. This will only have effect on the indexed text. No changes are made to text fields of the CoreDocument class. Properties: ReplaceBy: string[] (get) — The replacement targets. Returns a COPY — a get-only property protects the reference, never the contents, and these arrays are reachable from a shared configuration. ToReplace: string[] (get) Methods: string Replace(string text) Replace will make the actual replacement in the passed argument and return the result as a new string. --- # ProcessMonitor Tracks the state, progress, and completion of a long-running process. Supports timeout monitoring, cancellation, and both synchronous and asynchronous waiting. The ProcessMonitor can be used for `Init`, `Load`, and `Index`. Using the ProcessMonitor allows you to run loading and indexing in parallel. `Init`, `Load` and `LoadAllFilters` put the work on a background thread and return immediately; the monitor is already running by the time they return, so it is safe to poll or wait on the very next line. `Index` runs on the calling thread. Pick **one** way to wait — they are alternatives, not steps. **Poll for progress:** ```csharp var monitor = new ProcessMonitor(); monitor.TimeoutSeconds = 120; engine.Load(fstream, monitor); while (monitor.IsRunning) { Console.WriteLine($"Progress: {monitor.ProgressPercent}%"); Thread.Sleep(200); } if (!monitor.Succeeded) Console.WriteLine($"Error: {monitor.ErrorMessage}"); ``` **Wait synchronously:** ```csharp engine.Load(fstream, monitor); monitor.WaitForCompletion(); // returns false on timeout if (!monitor.Succeeded) Console.WriteLine($"Error: {monitor.ErrorMessage}"); ``` **Wait asynchronously:** ```csharp engine.Load(fstream, monitor); await monitor.WaitForCompletionAsync(); if (!monitor.Succeeded) Console.WriteLine($"Error: {monitor.ErrorMessage}"); ``` One monitor per operation. Reusing an instance while it is still running throws; wait for it first. And note that `WaitForCompletionAsync` on a monitor that was never handed to an operation completes immediately — it reports "nothing to wait for", not "finished successfully", so always check `Succeeded`. ## Properties | Property | Type | Description | |----------|------|-------------| | `TimeoutSeconds` | `int` | Max time a process can take before terminating. Default: `Timeout.Infinite` | | `ErrorMessage` | `string` | Details about a failure, if any. Empty string means no error | | `Exception` | `Exception` | Catches and assigns any exceptions thrown during processing | | `IsRunning` | `bool` | Whether the process is currently running | | `ProgressPercent` | `int` | Completion percentage of the process (0–100) | | `Succeeded` | `bool` | True if the process finished successfully | | `StartTime` | `DateTime` | Timestamp of when the process started | | `DidTimeOut` | `bool` | True if the wait timed out before the process finished | | `IsCancelled` | `bool` | True for a cancelled process | | `IsCompleted` | `bool` | True for a completed process | ## Methods | Method | Description | |--------|-------------| | `Cancel()` | Requests cancellation of the monitored process | | `WaitForCompletion()` | Waits for the process to complete, timeout, or cancel | | `WaitForCompletionAsync()` | Waits asynchronously for the process to complete, timeout, or cancel | | `WaitForProcessStarted(int timeoutMs)` | May be used for the sync case to avoid race conditions | | `Dispose()` | Frees resources | ## ThreadPriority Allows clients to set priority on the threads executing the process. ```csharp monitor.ThreadPriority = ThreadPriority.Normal; // default // Options: // ThreadPriority.Lowest // ThreadPriority.BelowNormal // ThreadPriority.Normal // ThreadPriority.AboveNormal // ThreadPriority.Highest ``` ## ProcessMonitor (class) Tracks the state, progress, and completion of a long-running process such as Init, Load, or Index. Supports timeout monitoring, cancellation, and both synchronous and asynchronous waiting. Not recommended, but for robustness the monitor may be reused after WaitForCompletion or WaitForCompletionAsync returns. Reuse while running throws InvalidOperationException. Set MaxRecoveryAttempts before starting the operation to control how many recoverable errors are tolerated. When the threshold is exceeded a JsonException is thrown, the operation is aborted, and Succeeded is set to false. Properties: DidTimeOut: bool (get/set) — Returns true if the wait timed out before the process finished. ErrorMessage: string (get/set) — Provides a short description of the first fatal failure, if any. An empty string means no fatal error occurred. For full error details including recoverable errors see RecoverableErrors and UnrecoverableErrors. Exception: Exception (get/set) — Catches and exposes any unhandled exception thrown during processing. IsCancelled: bool (get) — Returns true for a cancelled process. IsCompleted: bool (get) — Returns true once the process has completed, regardless of outcome. IsRunning: bool (get/set) — Indicates whether the process is currently running. MaxRecoveryAttempts: int (get/set) — Controls how many recoverable errors are tolerated before the operation is aborted. A recoverable error is one where the input could be sanitized and processing continued, for example a control character or trailing comma in a JSON record. When this threshold is reached a JsonException is thrown, the operation is aborted, Succeeded is set to false, and the error that caused the limit to be exceeded is added to UnrecoverableErrors. Defaults to int.MaxValue, meaning fully tolerant. Set to 0 to treat any recoverable error as fatal. ProgressPercent: int (get/set) — Returns the completion percentage of the process, from 0 to 100. RecoverableErrors: List (get/set) — Recoverable errors encountered during the operation, grouped by Source and Message. Each entry contains the ProcessError describing the first occurrence and a count of how many times that same error was seen. ParseError and Exception on each ProcessError reflect the first occurrence only. Populated as the operation runs. Cleared when the monitor is reused. StartTime: DateTime (get/set) — Timestamp of when the process started. Succeeded: bool (get/set) — Returns true if the process finished successfully. ThreadPriority: ThreadPriority (get/set) — Allows clients to set the priority of threads executing the process. TimeoutSeconds: int (get/set) — Sets the maximum time in seconds a process may run before it is considered timed out. Use Timeout.Infinite to disable the limit. UnrecoverableErrors: List (get/set) — Unrecoverable errors that caused the operation to abort. Each entry is a distinct fatal error. Succeeded is set to false whenever an entry is added. Also populated when the MaxRecoveryAttempts threshold is exceeded, in which case the error that triggered the limit is promoted here from RecoverableErrors. Cleared when the monitor is reused. Methods: void Cancel() Requests cancellation of the monitored process. void Dispose() Disposes the monitor and frees all resources. bool WaitForCompletion() Blocks until the process completes or the timeout elapses. Returns false immediately if the process was cancelled. Task WaitForCompletionAsync() Waits asynchronously for the process to complete or timeout. void WaitForProcessStarted(int timeoutMilliseconds = -1) Blocks until the process has started. May be used in the synchronous case to avoid race conditions. void MarkFinished() Marks the process as finished. Intended for use by internal and cloud API processes. --- # Field Fields are discovered automatically via `Init()` and then configured before `Load()`. The engine is schema-less — you never declare fields up front. ```csharp engine.Init(fstream); engine.GetField("name")!.Searchable = true; engine.GetField("name")!.Weight = 2.0f; engine.GetField("description")!.Searchable = true; engine.GetField("category")!.Filterable = true; engine.GetField("category")!.Facetable = true; engine.GetField("price")!.Filterable = true; engine.GetField("price")!.Sortable = true; ``` ## Configuration Properties | Property | Type | Default | Description | |----------|------|---------|-------------| | `Searchable` | `bool` | `false` | Include this field in the text search index | | `Filterable` | `bool` | `false` | Allow this field to be used in filters and boosts. Refused with an `ArgumentException` on a field with no type | | `Facetable` | `bool` | `false` | Return value/count pairs for this field with search results | | `Sortable` | `bool` | `false` | Allow sorting by this field. Score is always the primary sort key. Refused with an `ArgumentException` on a field with no type | | `WordIndexing` | `bool` | `false` | Build a full word index for exact single-word matching. Useful for large datasets with many repeated values | | `HighResolution` | `bool` | `false` | Also index N-grams over the field text with all delimiters removed (e.g. `"hvis feks"` additionally indexes `"hvisfeks"`), and strip delimiters from the query too, so a run-together or split query matches across removed delimiters. Only meaningful when `Searchable` | | `Embeddable` | `bool` | `false` | Mark this field for embedding-based (vector) search. Embeddable fields are excluded from the text search index | | `PreloadFilters` | `bool` | `false` | Cache a filter for each distinct value during `Load`. Accessible via `SearchEngine.PreLoadedFilters`. Faster repeated filtering at the cost of memory | | `HighResolution` | `bool` | `false` | Add extra delimiter-removed N-grams to sharpen run-together / split-word matches. Coverage already matches these, so this lifts precision at the cost of index space — best on a few key fields like titles. Only when `Searchable` | | `Weight` | `float` | `1.0f` | Relative score weight for this field. Higher values make matches in this field rank higher | | `BM25b` | `float` | `0.75f` | Length normalisation parameter. `0` = no normalisation, `1` = full normalisation. Applies in both BM25F and BM25PerField modes | | `BM25k1` | `float` | `1.2f` | Term-frequency saturation. Typical range 1.0–2.0. Setting different values across fields forces BM25PerField mode; uniform values select BM25F mode | ## Read-Only Properties | Property | Type | Description | |----------|------|-------------| | `Name` | `string` | Field name as it appears in the JSON data | | `Type` | `JsonValueKind` | Value kind: `String`, `Number`, `True`/`False`, `Array`, `Object` | | `IsArray` | `bool` | Whether this field holds an array of values | | `Optional` | `bool` | `true` if the field is absent or null in at least one document | | `EmbeddingDimensions` | `int` | Detected vector dimension. `0` until data is loaded with `Embeddable = true` | > Setting `PreloadFilters = true` automatically sets `Filterable = true` on the same field. ## Fields With No Type A field that was null or absent in every document `Init()` read has no type. `Filterable` and `Sortable` are the only two roles that need one, to choose between a numeric and a categorical index and to pick a comparator, so both are refused on such a field: `TrySetFieldConfiguration` returns `false` with the reason, and the setters on `Field` throw `ArgumentException`. Clearing either is always allowed. `Searchable` and `Facetable` do not read the type and stay available. Setting `Searchable` on an empty field prepares it: a record inserted later that does carry a value is indexed and found. ## Changing Configuration After Load A change needs one of three things, and you can ask which before applying it: ```csharp var proposed = new[] { new FieldProxy { FieldName = "category", Facetable = true } }; if (engine.DocumentFields.RequiresReload(proposed)) { // Searchable, WordIndexing, Embeddable, PreloadFilters, BM25k1 and HighResolution. // Only Load consumes these. Applied in place they return the engine to Created, // so searches fail with a reason instead of answering as if the field were empty. } else if (engine.DocumentFields.RequiresReindex(proposed)) { engine.TrySetFieldConfiguration(proposed, out string error); engine.Index(); } else { engine.TrySetFieldConfiguration(proposed, out string error); // takes effect at query time } ``` `RequiresReindex` covers a field getting its **first** role. A document keeps values only for the fields in use when it was loaded, so such a field has none until `Index()` reads them back from the stored JSON. A second role on a field that already had one takes effect at query time. To apply a reload-only change without taking the engine out of service, build the new index beside the live one with `CreateInMemoryClone(monitor, fieldOverrides)` and swap it in. --- ## FieldProxy `FieldProxy` is used by `GetFieldConfiguration()` (returns `FieldProxy[]`) and `TrySetFieldConfiguration(FieldProxy[], out string error)`. All boolean and float properties are nullable — `null` on a set call means "leave unchanged"; every property is populated on a get call. ```csharp // Read the full configuration FieldProxy[] config = engine.GetFieldConfiguration(); // Apply changes — only non-null values are written engine.TrySetFieldConfiguration([ new FieldProxy { FieldName = "title", Searchable = true, Weight = 2.0f }, new FieldProxy { FieldName = "description", Searchable = true, Weight = 1.0f }, new FieldProxy { FieldName = "category", Filterable = true, Facetable = true }, new FieldProxy { FieldName = "price", Filterable = true, Sortable = true }, ], out string error); ``` | Property | Type | Description | |----------|------|-------------| | `FieldName` | `string` | Must match a field name discovered by `Init()` | | `FieldType` | `string?` | Read-only on GET (`"String"`, `"Number"`, `"Boolean"`). Ignored on SET | | `IsArray` | `bool?` | Read-only on GET. Ignored on SET | | `Searchable` | `bool?` | Include in text search index | | `Filterable` | `bool?` | Allow use in filters and boosts | | `Facetable` | `bool?` | Return value/count pairs with results | | `Sortable` | `bool?` | Allow sorting by this field | | `WordIndexing` | `bool?` | Exact word matching in coverage | | `Embeddable` | `bool?` | Vector embedding field | | `PreloadFilters` | `bool?` | Cache a filter per distinct value during `Load`. Also forces `Filterable = true` | | `HighResolution` | `bool?` | High-resolution indexing for run-together/split-word precision. Best on a few key fields like titles | | `Weight` | `float?` | Score weight multiplier | | `BM25b` | `float?` | Length normalisation parameter | | `BM25k1` | `float?` | TF saturation parameter | --- ## Scoring Modes Setting `BM25k1` differently across fields controls which scoring algorithm the engine uses after `Index()`: ```csharp // BM25F mode (default) — uniform k1, single shared index engine.GetField("title")!.BM25k1 = 1.2f; engine.GetField("body")!.BM25k1 = 1.2f; // same → BM25F // BM25PerField mode — divergent k1, one index per field engine.GetField("title")!.BM25k1 = 2.0f; engine.GetField("body")!.BM25k1 = 1.2f; // different → BM25PerField ``` Inspect `SearchEngine.ScoringMode` after `Index()` to confirm which path was selected. ## Field Weights `Weight` is a plain `float`. The practical range is **0.5–3.0**. The ratio between fields matters — `2.0` and `1.0` produces the same relative effect as `4.0` and `2.0`. ```csharp engine.GetField("title")!.Weight = 3.0f; engine.GetField("heading")!.Weight = 2.0f; engine.GetField("metadata")!.Weight = 1.5f; engine.GetField("body")!.Weight = 1.0f; ``` There is no hard maximum, but values above 5× the lowest weight in your set offer diminishing returns. If one field needs to dominate that strongly, it is usually better to make it the only searchable field. ## Field (class) This is a meta class of a Field. It contains a set of properties which are important with respect to searching, filtering, faceting, boosting etc. Properties: BM25b: float (get/set) — Per-field length-normalization parameter b for BM25 / BM25F scoring. Range [0, 1]: 0 = no length normalization, 1 = full normalization. Typical values: 0.3 for short fields (title), 0.75 for longer fields (body). Defaults to 0.75. Applies in both(per-field BM25) and(multi-field BM25F). BM25k1: float (get/set) — Per-field term-frequency saturation parameter k1 for BM25 scoring. Typical range: 1.0–2.0. Lower values saturate TF faster (diminishing returns on repeated terms). Defaults to 1.2. HighResolution: bool (get/set) — High-resolution indexing for this field. When true, the tokenizer ALSO generates N-grams over the field text with all delimiters removed (e.g. "hvis feks" additionally indexes "hvisfeks"), and a search whose words are run together (or split) still matches across the removed delimiters. The query side strips delimiters too whenever at least one searchable field is high-resolution. Delimiters are the ones defined by the engine's configuration. Off by default; opt in per field. Embeddable: bool (get/set) — When true, this field holds a vector embedding — a fixed-length array of float values produced by an embedding model. Fields must be set embeddable after Init during the fieldconfiguration. Embeddable fields are excluded from the standard text search index. During Load their float[] values are collected into a per-field list that is passed to the HNSW index for approximate nearest-neighbour search. The expected dimensionality is fixed on the first document loaded and validated on all subsequent ones. Setting this to false on a field whose name matches the auto-detection pattern overrides the automatic classification. EmbeddingDimensions: int (get/set) — The expected number of dimensions for this embedding field. Set automatically from the first document loaded during Load. Zero means not yet determined. Ignored when Embeddable is false. Facetable: bool (get/set) — Set to true if this field is going to be used as a facetable field. Filterable: bool (get/set) — String)Set to true if this field is going to be used as a filterable field. Refused on a field with no type - see. Searchable: bool (get/set) — sets whether the content should be searchable. Sortable: bool (get/set) — Must be set in order to sort on this field. This will be a secondary search key – score is always first. Both this field and the sort order must be passed to Query. WordIndexing: bool (get/set) — Enables exact word matching for single-word queries. When enabled, records containing an exact match of the query word are prioritized in the result list. For example, searching for "computer" will rank records containing the word "computer" topmost. This feature is only applicable in Coverage mode. Any field marked with this property set to true will get its values indexed for exact matching. The minimum word size defined by the tokenizersetup of the default configuration (400 default, that is set to 2). These matches are never truncated and the max word size is 8 characters. Case sensitivity is determined by configuration settings. For array fields one matching element is considered a hit. Note: Exact word matches are given a compensated top score (typically 255, 254,253 or 252), adjusted based on the client's boost setting. This property itself does not apply any boost. Words are split using the delimiters defined in TokenizerSetup of configuration. IsArray: bool (get/set) — Is true for a possibly flattened array. Name: string (get/set) — The name of the field. The name must be unique. Optional: bool (get/set) — Optional field SampleValue: string (get/set) — A short, display-ready sample of this field's content, captured from the first document that carried a non-empty value for it. Surfaced asso a caller configuring the field can see what it actually holds. PreloadFilters: bool (get/set) — Setting this will make engine setup a valuefilter for each distinct value of a field. The filters will be created and loaded during Load and may be accessed via SearchEngine.GetPreloadedFilters. Type: JsonValueKind (get/set) — Only primitive types and their arrays are accepted. Note that for fields containing flattened arrays they will all have primitive types, and not JsonArray. Use the IsArray property to check. Value: object (get/set) — Actual value of the field. Weight: float (get/set) — BM25PerFieldHow much this field contributes to the relevance score, relative to the other searchable fields —in the BM25F formula, and the weight the per-field scores are averaged by in. Defaults to 1.0. Typical range: 1.0–3.0, where 1.0 is neutral and higher values increase the field's contribution. Values below 1.0 de-emphasize the field. Applies only to searchable fields; set it after Init() and before Index(). Methods: Field Clone() Creates a deep copy of this field. The clone is detached: no NotifyChanged subscribers are carried over, and UniqueSet is duplicated so callers can mutate the clone without affecting the original. Backing fields for Searchable/Sortable/Weight/PreloadFilters are assigned directly so the clone's NotifyChanged event is silent during construction. static bool ValidateFieldValue(Field field, object value, String& error) Validates that a value is compatible with the field's type and array requirements. ## ScoringMode (enum) Selects which BM25 scoring algorithm the engine uses when more than one field is marked as searchable. The mode is determined automatically attime from the per-fieldvalues: Values: BM25PerField = 0 BM25F = 1 ## DocumentFields (class) JsonParserMeta class for defining fields in a document. Supports parsing JSON to extract fields and saving / loading field configuration to file. JSON repair (control characters, trailing commas) is delegated torather than being duplicated here. Properties: NameOfDocumentKeyField: string (get/set) — Name of the key field used to identify documents. Required for operations such as document deletion. AnalyzedDocumentCount: int (get) — Number of documents the analyze pass (Init) saw in the stream. A value of 1 on a large file is the classic "export envelope" symptom: a root object wrapping the real document array, which the parser reads as one giant document. Callers can use this to warn before Load/Index is attempted. Methods: void Dispose() List GetFacetableFieldList() Returns the list of fields marked as Facetable. Field GetField(string fieldName) Returns the field with the given name, or null if not found. bool RequiresReindex(FieldProxy[] proposed) Returns true if the proposed configuration needsbefore it takes effect. Two quite different reasons, and both answer here so a caller has one question to ask:Filterable, Facetable, Sortable, Weight and PreloadFilters on a field that already has a role take effect at query time, and those still answer false. bool RequiresReload(FieldProxy[] proposed) Returns true when the proposal changes something a newhas to consume, socannot make it take effect however often it is called. Six flags:andandare read by, which decides the scoring engines and the scoring mode;by;by, which fills the word matcher; andby the preloaded-filter pass. All six run inside Load and nowhere else. DocumentFields Clone() Creates a deep copy of this DocumentFields instance with cloned Field objects. Cached field lists (searchable/facetable) are intentionally not copied; they rebuild lazily from the cloned _fields dictionary on next access. The clone has its own NotifyChanged event chain (no subscribers from the original). List GetFieldList() Returns a list of all fields. List GetFilterableFieldList() Returns all fields marked as filterable. string GetSerialized() Returns the instance serialized as a JSON string. bool Haskey() Returns true when this instance has a key field of the expected type. void SaveToFile(string fileName) Serializes the field configuration to a file. static DocumentFields Analyze(string jsonDocument, String& errorMessage) JsonParserAnalyzes a JSON string and extracts its field structure. Delegates JSON repair tobefore parsing. static DocumentFields Analyze(Stream jsonStream, String& errorMessage) JsonParserAnalyzes a JSON stream and extracts its field structure. Delegates JSON repair tobefore parsing. static void Analyze(string jsonDocument, DocumentFields documentFields, String& errorMessage) DocumentFieldsAnalyzes a JSON string into an existinginstance. static Task> AnalyzeAsync(Stream jsonStream) String@)Async variant of. static DocumentFields ReadFromFile(string fileName) Deserializes an instance from a previously saved file. --- # Query The `Query` object configures what and how to search. At minimum, provide search text and a max results count. ## Constructor ```csharp var query = new Query("search text", maxResults); ``` ## Full Configuration ```csharp var query = new Query("search text", maxResults) { EnableCoverage = true, // default: true CoverageDepth = 500, // default: 500 CoverageSetup = coverageSetup, // default: null EnableFacets = false, // default: false EnableBoost = false, // default: false SortBy = engine.GetField("rating"), // default: null SortAscending = false, // default: false RemoveDuplicates = true, // default: true Filter = filter, // default: null Boosts = boostArray, // default: null FieldBoosts = new Dictionary { ["title"] = 2.0f // multiplies title's Weight; key must be searchable }, // BM25F mode only, default: null KeyExcludeFilter = excludeSet, // exclude specific document keys KeyIncludeFilter = includeSet, // restrict to specific document keys TimeOutLimitMilliseconds = 1000 // default: 1000, max: 10000 }; ``` ## Properties | Property | Type | Default | Description | |----------|------|---------|-------------| | `Text` | `string` | `""` | Search text | | `MaxNumberOfRecordsToReturn` | `int` | — | Maximum results to return | | `EnableCoverage` | `bool` | `true` | Enable coverage-based ranking | | `CoverageDepth` | `int` | `500` | How many candidate documents coverage evaluates. Hits beyond the depth are never confirmed, so a count landing exactly on the depth is the cap, not the data — see [Deep coverage](/howto/deep-coverage) | | `CoverageSetup` | `CoverageSetup?` | `null` | Fine-grained coverage configuration | | `EnableFacets` | `bool` | `false` | Return facet counts in the result. Also required for an empty `Text`: without it the empty query is refused, returning an empty `Result` with a `Reason` — see [Empty search](/howto/empty-search) | | `EnableBoost` | `bool` | `false` | Apply boosts defined in `Boosts` | | `SortBy` | `Field?` | `null` | Secondary sort field. Score is always the primary key | | `SortAscending` | `bool` | `false` | Sort direction when `SortBy` is set | | `RemoveDuplicates` | `bool` | `true` | When documents share a foreign key, return only the best-scoring one. Not a group-by on arbitrary fields — see [Variants and editions](/howto/variants) | | `Filter` | `Filter?` | `null` | Boolean filter applied before scoring | | `Boosts` | `Boost[]?` | `null` | Score boosts applied to matching documents | | `FieldBoosts` | `Dictionary?` | `null` | Per-query multipliers on each field's `Weight`. Keys must be searchable fields — an unknown or non-searchable key makes `Search` return an empty `Result`. Only applies in BM25F mode | | `TimeOutLimitMilliseconds` | `int` | `1000` | Abort scoring after this many milliseconds. Max: 10000 | ## Per-Query Field Boosts `FieldBoosts` lets you adjust how much each field counts for one search, without reconfiguring or rebuilding the index. ```csharp // title and description are both Searchable in the field configuration. var query = new Query("noise cancelling", 10) { FieldBoosts = new Dictionary { ["title"] = 3.0f, // 3 × title's Weight ["description"] = 0.5f // half of description's Weight } }; ``` - **A boost multiplies the field's `Weight`; it does not replace it.** A field with `Weight = 2.0f` and a boost of `3.0f` counts 6×. Fields you leave out keep their `Weight` (a boost of 1.0). In BM25F terms the boost is folded into the pseudo term-frequency as `boost × weight × tf`. - **Every key must be a searchable field.** An unknown or non-searchable key makes `Search` return an empty `Result` — no records, and today no `Reason` either, so it looks like a search that matched nothing. The message is on `engine.Status.ErrorMessage`. Making a field non-searchable does not remove it from boosts your code still sends. - **Boosts only apply in BM25F mode.** Check `engine.ScoringMode` after `Index()`. The engine uses BM25F when every searchable field has the same `BM25k1` (the default); if they differ it scores fields separately and ignores `FieldBoosts` — valid keys have no effect, and an invalid key still returns an empty result. ## Query (class) Query class to be applied for Json documents Properties: Boosts: Boost[] (get/set) — is the method to pass the list of boosts to be applied during the search. CoverageDepth: int (get/set) — The number of results from the pattern matcher to be considered by Coverage. CoverageSetup: CoverageSetup (get/set) — Field that controls behavior of the Coverage algorithms. Have no effect for other algorithms. DocumentsBoosted: int (get) — Returns the number of documents that may be boosted in the search. EnableBoost: bool (get/set) — Is used to toggle boosted searches on or off. EnableCoverage: bool (get/set) — Coverage is a function that detects exact matching on tokens in the query, and lifts them up to the top of the result list. If set to false, the search will run with pattern recognition (Relevancy Ranking) only. When Coverage is active, the search result will also return an index number to truncate the list on. EnableFacets: bool (get/set) — Activates calculations of facet histograms in the search results. FieldBoosts: Dictionary (get/set) — Per-field BM25F boost factors applied at query time. Key = field name (must match a searchable field name). Value = multiplicative boost for that field's contribution to pseudo_tf. Fields not listed default to 1.0. Typical range: 0.5–3.0. Values below 1.0 de-emphasize a field; values above ~5.0 tend to overwhelm the IDF component and reduce result quality. Example: { "title": 2.0f, "body": 1.0f } doubles the title field's influence. Filter: Filter (get/set) — Is used to pass a filter to the search query. LogPrefix: string (get/set) — If the instance of the search engine has had a logger injected into its constructor, every search will be logged. The log prefix is then first added to the log text, and can be used by the client to add statistical information, for example for use in dashboards. MaxBoost: int (get/set) — Denotes the Maximum possible boost that can be assigned. Note that a hit with full boost will get score 255. Hence full "unboosted" score will be 255 - MaxBoost. MaxNumberOfRecordsToReturn: int (get/set) — Defines the maximum number of documents to be returned. RemoveDuplicates: bool (get/set) — Removes all duplicates of documents with the same foreign key. Only the one with the best score value is returned in the results list. If foreign keys are not in use, this can be set to null. SerializedQueryKey: string (get) — Serialized key for caching empty search results. Format: F:{filterKey}|B:{boostFilter1};{strength1},{boostFilter2};{strength2}|S:{sortField};{asc}|M:{maxRecords}|E:{enableFacets} SortAscending: bool (get/set) — Only applicable if SortBy != null. SortBy: Field (get/set) — Use this in order to setup sorting of the results. Text: string (get/set) — Refers to the text (input) to be searched for. TimeOutLimitMilliseconds: int (get/set) — Sets maximum waiting time in case of overloaded CPU. We recommend 1000 ms. Cannot be more than 10000 ms. --- # Results Search returns a `Result` object containing ranked document keys and scores — not full JSON. Retrieve full documents with `GetJsonDataOfKey`. ## Basic Usage ```csharp var result = engine.Search(query); if (result != null) { foreach (var rec in result.Records) { long key = rec.DocumentKey; int score = rec.Score; string json = engine.GetJsonDataOfKey(key); Console.WriteLine($"[{score}] {json}"); } } ``` ## Result Properties | Property | Type | Description | |----------|------|-------------| | `Records` | `ScoreEntry16[]` | Ranked documents. Each has `DocumentKey` (long) and `Score` (ushort, 0–65535) | | `Facets` | `Dictionary[]>?` | Field → value/count pairs, keyed on the value as stored. `null` when `EnableFacets` is false | | `TruncationIndex` | `int` | Index in `Records` where coverage truncation occurred. `-1` if no truncation | | `TruncationScore` | `ushort` | Score at the truncation point | | `DidTimeOut` | `bool` | `true` if scoring was cut short by `TimeOutLimitMilliseconds` | ## Facet Counts and Filter Counts A facet keys on the value as stored, so a dataset that spells a value both ways, `Fiction` and `FICTION`, lists it twice. A value filter compares case-insensitively unless you build it with `isCaseSensitive: true`, so clicking either entry returns the documents of both spellings. A facet count therefore equals a filter count only for a case-sensitive filter, which gives up the per-field posting index and loads by scanning: same answers, more work. A document whose array holds the same value twice counts once, so an array field's facet counts agree with its own filter. ## Working with Facets ```csharp query.EnableFacets = true; var result = engine.Search(query); if (result.Facets != null) { foreach (var facet in result.Facets) { Console.WriteLine($"{facet.Key}:"); foreach (var bucket in facet.Value) Console.WriteLine($" {bucket.Key} ({bucket.Value})"); } } ``` ## Retrieving Full Documents ```csharp // Single document string json = engine.GetJsonDataOfKey(result.Records[0].DocumentKey); // Multiple documents var keys = result.Records.Select(r => r.DocumentKey); foreach (var json in engine.GetJsonDataOfKeys(keys)) Console.WriteLine(json); ``` ## ScoreEntry16 | Property | Type | Description | |----------|------|-------------| | `DocumentKey` | `long` | Unique key identifying the document | | `Score` | `ushort` | Relevance score 0–65535. Higher is more relevant | ## Result (class) Query)Search result returned by. Properties: DidTimeOut: bool (get/set) — True when the search could not be served, rather than served and found nothing. Two situations set it: Reason: ProcessError (get/set) — RecordsWhy this search returned nothing, when the cause was the request or this engine rather than an absence of matches. Null on a normal search, including one that legitimately matched nothing — an emptywith a null Reason means "no matches", which is the distinction a caller could not previously make. SystemState: SystemState (get/set) — SystemStatusThe engine's state at the moment this search ran, which is what a caller needs to decide whether to retry:orwill pass, a bad request will not. A snapshot of the one field worth copying — the rest ofis engine-wide and describes the engine over time rather than this request, so reading it per search would report other callers' conditions as if they were yours. Facets: Dictionary[]> (get/set) — Facet counts per facetable field, keyed by field name. Null when no facetable fields are configured. Records: ScoreEntry16[] (get/set) — Ranked search hits in descending score order. TruncationIndex: int (get/set) — Internal index at which results were truncated to the requested top-k limit. TruncationScore: UInt16 (get/set) — Score threshold at which results were truncated. Methods: static Result MakeEmptyResult(bool timedOut = false, ProcessError reason = null, SystemState systemState = Created) Returns an empty result, optionally marked as having timed out. ## ScoreEntry16 (struct) 16-bit variant of ScoreEntry. Score range is 0..65535. Used by ScoreArray16 for higher resolution ranking. Properties: DocumentKey: long (get/set) — Id, key or internal index of a document. Score: UInt16 (get/set) — The 16-bit score value of the document. --- # Filters Create filters on fields marked as `Filterable`. Filters must be created after `Init()` and `Load()` are complete. > Never filter results client-side. Always use server-side filters so the engine applies them during search. Filter creation returns `null` on failure and reports the reason via an `out string? error` parameter: the field is unknown, it is not `Filterable`, a bound or culture can't be parsed, or the filter is the wrong kind for the field's type. `null` means **rejected**, never "no filter" — passing it on would widen the search to the whole dataset. > **The field's type decides the filter kind.** A range filter needs a field whose values are > numbers; a value filter needs a field whose values are not. Both used to be accepted and > answered by walking every document. A value filter on a number was not only slow but wrong, > because it compares text: asked for `129` it missed `129.0`. The `error` names the alternative. > > For an exact match on a numeric field, use a range with **equal limits** — > `CreateRangeFilter("item_id", id, id, out _)`. It compares as numbers and reads the field's > posting index. Note that a number stored as a JSON **string** is a text field, so it refuses a > range filter: store the values as numbers and load again. ## Value Filters Array fields (`prices.amount`, `editions.format`) hold one value per element, and a value or range filter matches a document if **any** element matches. Combine with OR to filter at variant level while results stay one document per work. ```csharp Filter? categoryFilter = engine.CreateValueFilter("category", "electronics", out string? error); if (categoryFilter is null) throw new InvalidOperationException(error); // e.g. "Field 'category' is not filterable" ``` Matching is case-insensitive by default. An overload takes the flag: ```csharp Filter? exact = engine.CreateValueFilter("category", "Electronics", isCaseSensitive: true, out _); ``` A facet count equals a filter count only for a case-sensitive filter. Case-sensitive filters are answered by walking the documents rather than by the field's posting index, so they cost more. ## Range Filters ```csharp Filter? priceFilter = engine.CreateRangeFilter("price", 10.0, 100.0, out string? error); // Equal limits are numeric equality -- the right way to match one value on a numeric field Filter? oneItem = engine.CreateRangeFilter("item_id", 129, 129, out _); ``` Both limits are inclusive. An overload takes a `CultureInfo`; without one the bounds are parsed as `InvariantCulture`. ## Combining Filters ```csharp // AND — both conditions must match Filter combined = categoryFilter & priceFilter; // OR — either condition matches Filter either = categoryFilter | priceFilter; // NOT — negate a filter Filter excluded = !categoryFilter; ``` ## Using Filters in Queries ```csharp query.Filter = combined; // Check how many documents match int count = combined.NumberOfDocumentsInFilter; // Reset filter query.Filter = null; ``` ## Preloading Filters For large datasets where many filters are applied simultaneously: ```csharp engine.LoadFilters(new[] { categoryFilter, priceFilter }, maxThreadCount: 2); // Or preload all: engine.LoadAllFilters(maxThreadCount: 4); ``` ## Filter (class) Common base class for all types of filters currently RangeFilter and ValueFilter. Supports logical operators. Note that "\0" and "\1" is used as left and rigth brackets to avoid strings that can occur in json text. Properties: IsArray: bool (get) — Set during Analyze if JsonObject is an Array. NumberOfDocumentsInFilter: int (get) — Returns the number of documents that are contained in the filter. SerializedKey: string (get) — Used internally exposed for ease of debugging. NeedsLoading: bool (get/set) — May be used in order to check if filter is loaded. ## RangeFilter (class) RangeFilter allows filtering within a range for a number datatype or a text datatype that can be parsed to a Double. Properties: Culture: CultureInfo (get) — Cultureinfo determines how double is parsed. SerializedKey: string (get) — Make the SerializedKey such that the filter parameters are recoverable by parsing. ## ValueFilter (class) Filter class to test for equality. Properties: SerializedKey: string (get) --- # Boosts Boost results matching certain criteria without excluding non-matching results. Boosts must be created after `Load()` completes. ## Creating Boosts ```csharp // BoostStrength enum: Low = 1, Med = 2, High = 3 Filter yearFilter = engine.CreateRangeFilter("year", 1980, 2025, out _)!; Filter genreFilter = engine.CreateValueFilter("genre", "Documentary", out _)!; var boosts = new List(); Boost b = engine.CreateBoost(yearFilter & genreFilter, BoostStrength.Med); Console.WriteLine($"Documents boosted: {b.DocumentsBoosted}"); boosts.Add(b); query.Boosts = boosts.ToArray(); query.EnableBoost = true; ``` ## Personalized Boosting For user-specific boosting across many items, OR per-item filters into one filter and boost it. `item_id` holds numbers, so each one is a range with equal limits: a value filter compares text and is refused on a numeric field. ```csharp Filter? frequentPurchases = null; foreach (long itemId in userFrequentItemIds) { Filter f = engine.CreateRangeFilter("item_id", itemId, itemId, out _)!; frequentPurchases = frequentPurchases is null ? f : frequentPurchases | f; } var userBoosts = new List(); userBoosts.Add(engine.CreateBoost(frequentPurchases!, BoostStrength.Med)); query.Boosts = userBoosts.ToArray(); query.EnableBoost = true; ``` Applying a boost costs the same however many documents it covers: one check per scored result. Building a large per-user boost has its own cost, since every item in it is a filter. ## Boost (class) Api class for a Boost definition Properties: BoostStrength: BoostStrength (get/set) — Defines the boost strength. DocumentsBoosted: int (get) — Returns the number of documents boosted. Filter: Filter (get/set) — Filter which defines the set of documents to be boosted. ## BoostStrength (enum) Defines the magnitude of the boost to be applied Values: Low = 1 Med = 2 High = 3 --- # Coverage Coverage is a collection of algorithms that detect exact and near-exact token matches in the query, lifting them to the top of the result list. It complements the core pattern recognition which always runs first. ## How It Works After pattern matching produces ranked candidates, coverage analyzes the top-K results (controlled by `CoverageDepth`) and scores each 0–65535 based on how much of the query the document covers. Results confirmed by coverage are promoted above pure pattern matches. ## How Coverage Ranks The score is the share of the query text the document covers, so `65535` means all of it. Among documents that cover the query equally, three small deductions decide the order: | Deduction | Amount | When | |-----------|--------|------| | Word out of order | `257` per word | An exactly matched word breaks the query's word order. Order is relative: the phrase in order anywhere in the text is in order | | Not at the start | `16` | The document does not begin with the query's first word | | Ends inside a word | `8` | The whole query was found as one string, but its last character is followed by more letters | When the **whole query** appears in the document as one string, it sets a floor under the score, by where that string starts: | Whole query found | Score at least | |-------------------|----------------| | At the start of the document | `65535` | | At the start of a word further in | `65519` | | Inside a word | `(q − 1) / q × 65535`, where `q` is the query length | This is what makes search-as-you-type rank well. For `knut h`, the final `h` is too short to count as a word, but `knut hamsun` contains the whole query and scores `65527` (starts the document, ends inside a word), while `knut faldbakken` covers only `knut` and scores `43690`. Diacritic folding follows the `TextNormalizer`. In one-way mode (the default) a query is folded only against a document whose text folds, so `blåbær` fully matches `blåbær` but not `blabar`, while `blabar` fully matches both. In two-way mode both sides always fold, and the two spellings are the same query. ## CoverageSetup ```csharp CoverageSetup cov = new CoverageSetup(); // Strict mode: only whole words cov.CoverFuzzyWords = false; cov.CoverPrefixSuffix = false; cov.CoverWholeQuery = false; cov.CoverJoinedWords = true; cov.CoverWholeWords = true; cov.MinWordSize = 3; query.CoverageSetup = cov; ``` ## Detection Algorithms | Property | Default | Description | |----------|---------|-------------| | `CoverWholeQuery` | `true` | Detect the whole search query as a single string, and let it set a floor under the score (see above) | | `CoverWholeWords` | `true` | Detect individual whole words from the query | | `CoverFuzzyWords` | `true` | Detect words with minor error tolerance | | `CoverJoinedWords` | `true` | Detect words that are joined or split | | `CoverPrefixSuffix` | `true` | Detect incomplete strings as prefix or suffix | ## Result Control | Property | Default | Description | |----------|---------|-------------| | `Truncate` | `true` | Cut results at the truncation index | | `IncludePatternMatches` | `true` | Include pure pattern matches | | `TruncationScore` | `65024` | A result at or above this score is never cut | | `TruncateWordHitLimit` | `1` | Minimum query words that must match | | `TruncateWordHitTolerance` | `0` | Max difference in word hit count from best result | ## Common Patterns Coverage should be kept enabled for nearly all use cases, including search-as-you-type. It is fast and improves result quality across multiple searchable fields. **Agent/tool usage (near-exact hits only):** ```csharp CoverageSetup cov = new CoverageSetup(); cov.IncludePatternMatches = false; query.CoverageSetup = cov; ``` **Run coverage but keep all results:** ```csharp CoverageSetup cov = new CoverageSetup(); cov.Truncate = false; query.CoverageSetup = cov; ``` **Deep coverage (evaluate all documents):** ```csharp query.CoverageDepth = engine.Status.DocumentCount; ``` ## CoverageSetup (class) Coverage is a function that detects exact and near-exact matching on strings and tokens in the query, and lifts them up to the top of the result list. Coverage is a collection of algorithms that works together, and this complements the pattern matching algorithm that runs first. CoverageSetup is a member of Query. Properties: IncludePatternMatches: bool (get/set) — Gets or sets a value indicating whether pure pattern matches that are not detected by Coverage are included in the results. Set this to false if you only want near-exact matches returned. Should be used with Coverage enabled. LevenshteinMaxWordSize: int (get/set) — Longest word for Coverage to do edit distance calculation. This does not affect the fault tolerance of the pattern recognition search. Max value is 63. MinWordSize: int (get/set) — Smallest number of characters to hit for CoverageScoreEntry to give full word hits score. TruncateWordHitLimit: int (get/set) — The smallest number of words to hit for truncation of the search result list. The effective limit may be higher, since it is also determined by TruncateWordHitTolerance. TruncateWordHitTolerance: int (get/set) — Maximum difference in number of word hits to maxWordHits to truncate the result list. maxWordHits is calculated for entire result list. The truncation criteria is expressed by bool doTruncate = wordHits >= Max(TruncateWordHitLimit, maxWordHits - TruncateWordHitTolerance). CoverWholeQuery: bool (get/set) — Sets whether to detect the whole search query as a string. A document containing it gets a floor under its coverage score, by where the string starts: 65535 at the start of the document, 65519 at the start of a word further in, (q - 1) / q of the maximum inside a word; 8 less for the first two when it ends inside a word. This is what ranks a query being typed, such as "knut h", whose last word is too short to match as a word. A document containing the whole query is also never cut by truncation. CoverWholeWords: bool (get/set) — Sets whether to detect whole words from the string in the result list. This will look for multiple words. CoverFuzzyWords: bool (get/set) — Sets whether to detect words with an edit distance of 1. CoverJoinedWords: bool (get/set) — Sets whether to detect words that are either joined or split up. Both will be returned in the same query. CoverPrefixSuffix: bool (get/set) — Sets whether to detect incomplete strings as prefix or suffix of a bigger word. Truncate: bool (get/set) — If Truncate is enabled the list of result will be truncated at the point of TruncationIndex. TruncationScore: UInt16 (get/set) — Always truncate at or above this score. Represents the 16-bit score of the truncation point in the search result (0..65535). Default 65024 is the 16-bit equivalent of the old byte default 254 (254 * 257). --- ## SynonymList (class) A named set of synonym rules that can be attached to ato widen searches. Read and written the same way as— see,,and. Properties: Name: string (get/set) — The symbolic name this list is stored and looked up under. Description: string (get/set) — Free-text description of what the list covers. Optional. UpdatedUtc: DateTimeOffset (get/set) — When the list was last modified. Entries: List (get/set) — InvalidateThe rules in the list. Replacing this collection drops the lookup index built from it; mutating it in place does not, so callafter an in-place edit. Methods: string Expand(string queryText, Char[] delimiters = null) void Invalidate() Drops the cached lookup index so it is rebuilt on the next. Only needed after mutatingin place. string GetSerialized() Returns the instance serialized as a JSON string. void SaveToFile(string fileName) Serializes the list to a file. static SynonymList Deserialize(string serialized) GetSerializedDeserializes an instance from a JSON string produced by. static SynonymList ReadFromFile(string fileName) Deserializes an instance from a previously saved file. ## SynonymEntry (class) One row of a synonym list: a set of terms plus the direction they expand in. Properties: Direction: SynonymDirection (get/set) — Whetherare mutually equivalent or reachable only from. Source: string (get/set) — The term that triggers the expansion whenis. Null forentries, where every term inis its own trigger. Terms: string[] (get/set) — The terms this entry contributes to the query. May contain multi-word terms ("sosialt arbeid", "kognitiv atferdsterapi"); those are matched as whole word sequences, not as loose words. ## SynonymDirection (enum) SynonymEntryWhether aexpands in both directions or only one. Values: Multidirectional = 0 OneWay = 1 --- # SystemStatus Access via `engine.Status`. Returns a snapshot of the current engine state, counters, and any error conditions. ```csharp var status = engine.Status; Console.WriteLine($"State: {status.SystemState}"); Console.WriteLine($"Documents: {status.DocumentCount}"); Console.WriteLine($"Version: {status.Version}"); ``` ## Properties | Property | Type | Description | |----------|------|-------------| | `SystemState` | `SystemState` | Current lifecycle state | | `DocumentCount` | `int` | Number of documents in the index | | `SearchCounter` | `int` | Total number of `Search()` calls since creation | | `SecondsToIndex` | `double` | Duration of the last `Index()` call in seconds | | `Version` | `string` | Indx library version number | | `LicenseInfo` | `LicenseInfo` | License status and limits | | `ErrorMessage` | `string` | Human-readable error description. Empty string means no error | | `InvalidArgument` | `bool` | `true` if `Query` was null | | `InvalidState` | `bool` | `true` if `Index()` was called with zero documents, or `Search()` was called before `Index()` | | `TooLongSearchText` | `bool` | `true` if a query's text exceeded `IndexerSetup.MaxSearchTextLength` (default **300**); such a search is refused and returns an empty result with a `Reason`, never a truncated one | | `IndexedTextTruncated` | `bool` | `true` if a document's text was cut at `IndexerSetup.MaxIndexTextLength` (default **100 000**) on the way into the index; the stored record is untouched | | `TimeOfInstanceCreation` | `DateTime` | When the constructor was called | | `TimeOfLastIndexBuild` | `DateTime` | When `Index()` last completed | ## SystemState Enum ```csharp enum SystemState { Created = 0, Loading = 1, Loaded = 2, Indexing = 3, Ready = 4, Error = 255 } ``` | State | Value | Description | |-------|-------|-------------| | `Created` | 0 | Instance created, no data loaded | | `Loading` | 1 | `Load()` is in progress | | `Loaded` | 2 | Documents loaded (or hibernated), not yet indexed — `Index()` or `WakeUp()` builds the index | | `Indexing` | 3 | `Index()` is in progress | | `Ready` | 4 | Ready to search. Dynamic operations are also available in this state | | `Error` | 255 | An unrecoverable error has occurred | > **Removed:** `Hibernated = -1` (Sep 2026). `Hibernate()` now lands in `Loaded` — hibernation is > a step backwards along the lifecycle, not a state of its own. Over HTTP, a hibernated dataset > reports `Created` with `recordsOnDisk > 0` in its status. A client comparing `systemState == -1` > should compare against these instead. ## SystemStatus (class) Represents the current state and health of a search engine instance. In addition to live state such as SystemState and DocumentCount, this class carries the error lists from the most recent Init or Load operation. These mirror the lists on ProcessMonitor and remain accessible after the monitor reference has been discarded. Properties: DocumentCount: int (get/set) — Number of documents currently loaded and indexed. ErrorMessage: string (get/set) — Short description of the most recent fatal error, if any. An empty string means no fatal error occurred. For full detail on all errors from the last operation see RecoverableErrors and UnrecoverableErrors. InvalidArgument: bool (get/set) — Set to true if a search or operation was called with a null or invalid argument. InvalidDataSetName: bool (get/set) — Only relevant for certain configurations. See individual configuration documentation for details. InvalidState: bool (get/set) — Set to true if an operation was called in an invalid system state, for example if Search is called before Index has completed. LicenseInfo: LicenseInfo (get/set) — License information for this engine instance. RecoverableErrors: List (get/set) — Recoverable errors from the most recent Init or Load operation, grouped by Source and Message. Each entry contains the ProcessError describing the first occurrence and a count of how many times that same error was seen. Mirrored from ProcessMonitor at the end of the operation and remains accessible after the monitor reference is discarded. An empty list means no recoverable errors were encountered. SearchCounter: int (get/set) — Total number of Search calls made since the engine was created. SecondsToIndex: double (get/set) — Duration of the most recent Index operation in seconds. SystemState: SystemState (get/set) — Current lifecycle state of the engine. TimeOfInstanceCreation: DateTime (get/set) — Timestamp of when this engine instance was created. TimeOfLastIndexBuild: DateTime (get/set) — Timestamp of the most recent completed Index call. TooLongSearchText: bool (get/set) — Set to true when a query's text exceeded. Such a search is refused and returns an empty result rather than being truncated: a query that long cannot match more by being cut, and cutting it would answer a question nobody asked. Not cleared until DeleteAll. IndexedTextTruncated: bool (get/set) — Set to true when a document's text was cut aton the way into the index. The document is still searchable on what survived, and the stored record is untouched — only the index is incomplete. Not cleared until DeleteAll. UnrecoverableErrors: List (get/set) — Unrecoverable errors from the most recent Init or Load operation. Each entry is a distinct fatal error that caused the operation to abort. Succeeded on the ProcessMonitor was set to false for each entry. Also populated when the MaxRecoveryAttempts threshold is exceeded, in which case the error that triggered the limit is promoted here. Mirrored from ProcessMonitor at the end of the operation. An empty list means the operation completed without fatal errors. Version: string (get/set) — Version number of the Indx library. ## SystemState (enum) Reflects the major lifecycle state of a search engine instance. Values: Created = 0 Loading = 1 Loaded = 2 Indexing = 3 Ready = 4 Error = 255 --- # LicenseInfo Class used to authenticate a user or commercial license with Indx. Access through `engine.Status.LicenseInfo`. Load a `.license` file via the SearchEngine constructor: ```csharp var engine = new SearchEngine("filename.license"); var license = engine.Status.LicenseInfo; Console.WriteLine($"Licensed: {license.Licensed}"); Console.WriteLine($"Licensed to: {license.LicensedTo}"); Console.WriteLine($"Document limit: {license.DocumentLimit}"); ``` ## Properties | Property | Type | Description | |----------|------|-------------| | `LicenseFileName` | `string` | Filename of the loaded `.license` file | | `Licensed` | `bool` | True if a correct `.license` file has been loaded | | `ValidLicense` | `bool` | True if all parameters are within specification | | `Type` | `string` | License type: developer, commercial, or other | | `Description` | `string` | Information about the loaded license | | `LicensedTo` | `string` | Name of the license holder | | `DocumentLimit` | `int` | Maximum number of documents allowed. Without a license: 100,000 | | `DocumentLimitExceeded` | `bool` | True if more documents are loaded than the license permits | | `ExpirationDate` | `DateTime` | Date when the license expires | ## License Tiers | Tier | Document Limit | Cost | |------|---------------|------| | No license | 100,000 | Free | | Extended license | Unlimited | Free — register at [indx.co](https://indx.co) | | Company license | Unlimited + SLA | Paid | ## LicenseInfo (class) Class to define and read the licensing system. Properties: Description: string (get/set) — Gets the description of the license. DocumentLimit: int (get/set) — Gets the maximum number of documents allowed by the license. DocumentLimitExceeded: bool (get/set) — Gets a value indicating whether the document limit has been exceeded. Note that this limit is on the underlying core documents, so a JSON document with multiple fields and long text can be split into multiple core documents. ExpirationDate: DateTime (get/set) — Gets the expiration date of the license. LicenseFileFound: bool (get/set) — Gets a value indicating whether a license file was found at the specified path. This is true even if the license has expired or is otherwise invalid. Use this to distinguish "no file present" from "file present but not valid". Licensed: bool (get/set) — Gets a value indicating whether the application is licensed. LicensedTo: string (get/set) — Gets the name of the license holder. Person or company. LicenseFileName: string (get/set) — Gets or sets the file name of the license. Type: string (get/set) — Gets the type of the license. ValidLicense: bool (get/set) — Gets a value indicating whether the license is valid. Methods: static LicenseInfo GetLicenseInfo(string licenseFileNameAndPath, Boolean& isObfuscated) --- # HTTP API Reference --- # Data Loading ## Initial Load Workflow Every dataset endpoint is scoped to a team and dataset — prefix each operation below with `/api/teams/{teamName}/datasets/{dataSetName}/`. ``` 1. PUT (the dataset route) → create or open dataset (201 created / 200 existed) 2. POST analyze → discover fields from JSON 3. PUT fields/configuration → configure fields (204) 4. POST load → load documents (204) 5. POST index → start the index build (202 Accepted) 6. GET status → poll until systemState == 4 (Ready) 7. POST search → search ``` ## What each call does to data already there The loading calls are not interchangeable. The difference is what happens to what the dataset already holds: | Call | Documents already there | Field configuration | Search while it runs | |---|---|---|---| | `analyze` | **Dropped.** On a dataset that has been loaded, the running engine is discarded and a new, empty one takes its place | **Replaced** by the fields found in this body, with no roles | **Stops** until `load` and `index` finish | | `load` | **Replaced.** It clears the dataset and loads its body. A second `load` does not append | Kept | Unavailable until `index` finishes | | `replace` | Replaced in one step | Carried over. New fields arrive unconfigured | **Keeps serving** the old data until the swap | | `POST documents` | **Added to.** A key that already exists is refused, with its whole batch | Kept. An unknown field is refused | Keeps serving, and new documents are searchable at once | | `PUT documents` | Replaces the documents named by key | Kept | Keeps serving | Two things follow from this: - **Don't call `analyze` on a live dataset** to pick up a schema change. It takes the dataset offline and resets every field role. Use [Replace](#replace-full-reload) instead. - **`analyze` decides which fields exist.** A sample is enough, but only if it contains every field. A field missing from the analyzed body is unknown afterwards, and every later insert or update carrying it is refused. ### Choosing a path | Situation | Calls | |---|---| | First load | `analyze`, `fields/configuration`, `load`, `index`, then poll `status` | | First load too large for one request | `analyze` a sample containing every field, configure, `load` the first part, `index`, then `POST documents` with the rest in batches | | Full reload with no downtime | `replace`, with the whole set in one request | | Keeping a live dataset in sync | `POST`, `PUT`, `PATCH` and `DELETE` on `documents` | The server accepts request bodies up to 2 GB, so `analyze`, `load` and `replace` normally take a whole catalogue in one request. A proxy in front of the server can have a lower limit of its own: under IIS the shipped `web.config` raises it, and behind nginx raise `client_max_body_size`. ## TypeScript Example ```typescript import { SystemState, QueryProxy, FieldProxy } from '@indxsearch/indx-types'; const team = 'my-team'; // team that owns the dataset const dataset = 'products'; const base = `https://your-host/api/teams/${team}/datasets/${dataset}`; const api = (op: string, init?: RequestInit) => fetch(`${base}/${op}`, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, ...init, }); // 1. Create dataset (PUT on the dataset route itself — 201 created / 200 existed) await fetch(base, { method: 'PUT', headers: { Authorization: `Bearer ${token}` } }); // 2. Analyze await api('analyze', { method: 'POST', body: jsonString }); // 3. Configure fields (204 No Content) const fields: FieldProxy[] = [ { fieldName: 'name', searchable: true, weight: 2.0 }, { fieldName: 'description', searchable: true, weight: 1.0 }, { fieldName: 'category', filterable: true, facetable: true }, { fieldName: 'price', filterable: true, sortable: true }, ]; await api('fields/configuration', { method: 'PUT', body: JSON.stringify(fields) }); // 4. Load (204 No Content) await api('load', { method: 'POST', body: jsonString }); // 5. Index (202 Accepted — the build starts in the background) await api('index', { method: 'POST' }); // 6. Poll until ready let status; do { await new Promise(r => setTimeout(r, 500)); status = await api('status').then(r => r.json()); } while (status.systemState !== SystemState.Ready); ``` --- ## Field Configuration `PUT /api/teams/{teamName}/datasets/{dataSetName}/fields/configuration` Configure field properties, for any number of fields in a single call. `null` properties are ignored (leave current value unchanged). This is the one route that writes field roles; the role lists (`GET fields/searchable` and the rest) are read-only. Before the dataset is indexed the answer is always `204 No Content`. On a dataset that is `Ready`, a change that needs the index rebuilt answers `202 Accepted` instead: the rebuild has been started in the background and the dataset keeps serving with the old configuration meanwhile. Poll `GET status` until `shadowBuildInProgress` is `false`, then check that `shadowBuildError` is `null`. See [Endpoints](endpoints#what-put-fieldsconfiguration-answers). ```json [ { "fieldName": "name", "searchable": true, "weight": 2.0, "bM25b": 0.75, "bM25k1": 1.2 }, { "fieldName": "category", "filterable": true, "facetable": true, "preloadFilters": true }, { "fieldName": "embedding", "embeddable": true } ] ``` `GET /api/teams/{teamName}/datasets/{dataSetName}/fields/configuration` returns the same `FieldProxy[]` shape with all current values populated. > The single-role setters (`PUT fields/searchable`, `PUT fields/filterable`, `PUT fields/facetable`, `PUT fields/sortable`, `PUT fields/word-indexing`, `PUT fields/embeddable`) were removed in October 2026 and answer `405`. Send the same roles to `PUT fields/configuration`. A field marked `embeddable` must be an array of numbers and can have no other role. --- ## Dynamic Operations Insert, update, and delete documents after the index is built. The index stays in sync without a full rebuild. > **Insert and update refuse a field the configuration does not know.** A document carrying a field that is not in the dataset's field configuration gets `400 invalidArgument`, naming the field (nested fields by path, such as `'meta.batch'`), and nothing is stored: one such document fails its whole batch, and an update leaves the document as it was. Typical case: a new field added in your CMS (Sanity, Contentful, ...) while the sync job keeps inserting. To adopt it, run a full [Replace](#replace-full-reload) with a complete export. It reports the field under `added`, after which you assign roles in Field configuration. Missing non-key fields are accepted and treated as null; a missing key field rejects the whole batch. ### Insert `POST /api/teams/{teamName}/datasets/{dataSetName}/documents/{documentKey}` — single document (`201`) ```bash curl -X POST https://your-host/api/teams//datasets/products/documents/1001 \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '"{ \"id\": 1001, \"name\": \"New Product\", \"price\": 49.99 }"' ``` `POST /api/teams/{teamName}/datasets/{dataSetName}/documents` — batch (`201`) ```bash curl -X POST https://your-host/api/teams//datasets/products/documents \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '["{ \"id\": 1001, \"name\": \"A\" }", "{ \"id\": 1002, \"name\": \"B\" }"]' ``` ### Update `PUT /api/teams/{teamName}/datasets/{dataSetName}/documents/{documentKey}` — single document (`204`) `PUT /api/teams/{teamName}/datasets/{dataSetName}/documents` — batch (array of JSON strings; `204`) `PATCH /api/teams/{teamName}/datasets/{dataSetName}/documents/{documentKey}` — update one field (`204`) ```json { "fieldName": "price", "value": 39.99 } ``` `POST /api/teams/{teamName}/datasets/{dataSetName}/documents/update-by-filter` — update a field on all matching documents ```json { "filter": { "hashString": "abc123..." }, "fieldName": "inStock", "value": false } ``` Returns the count of updated documents as `{"count": n}`. ### Delete `DELETE /api/teams/{teamName}/datasets/{dataSetName}/documents/{documentKey}` — single document (`204`) `DELETE /api/teams/{teamName}/datasets/{dataSetName}/documents` — multiple documents (body: `long[]`; `204`) `POST /api/teams/{teamName}/datasets/{dataSetName}/documents/delete-by-filter` — all documents matching a filter (`204`) ```json { "hashString": "abc123..." } ``` --- ## Real-time updates Document inserts, updates and deletes (`POST`/`PUT`/`PATCH`/`DELETE …/documents…`) apply to the live index and are **searchable immediately** — no queue, no rebuild, no eventual consistency. A record published from your CMS is in the next search. Only adding new *fields* or changing a field's type needs a reload, which the Replace endpoint below does with zero downtime. ## Replace (full reload) `POST /api/teams/{teamName}/datasets/{dataSetName}/replace` Atomically swaps **all** documents in the dataset for a fresh JSON payload — the streamed-body equivalent of a full reload. Use it to push a new export of the same dataset without the downtime and data loss of delete-and-recreate. What it preserves and guarantees: - **Identity & settings survive** — field configuration, **boost rules**, the declared key field, and the description all carry over (reconciled against the new schema). Only the documents change. - **Zero downtime** — the old data keeps serving searches until the new index is built, then the engine is swapped atomically. - **All-or-nothing** — if the new JSON fails to parse or index, the existing dataset is left **completely untouched** and still serving. - **State-transparent** — works whether the dataset is `Ready`, hibernated or idle-evicted; the old documents are never reloaded just to be discarded. ```bash curl -X POST https://your-host/api/teams//datasets/products/replace \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ --data-binary @products-latest.json ``` The body is the full new dataset as a JSON array of documents — the same shape you pass to `load` / `load/text`. No separate `analyze` / `fields/configuration` / `index` steps are needed; `replace` runs the whole pipeline and returns when the new index is live. **Response (`200`)** — a summary of how the new schema differed from the previous field configuration: ```json { "added": ["newField"], // in the new JSON, not previously configured (loads unconfigured) "removed": ["oldField"], // previously configured, absent from the new JSON (its config is dropped) "typeChanged": ["price"], // detected type changed; that field's roles were reset "lostRoles": ["price (filterable, sortable)"], // removed or retyped fields that HAD a role, with the roles — searches and filters on them now silently miss "keyFieldFallback": null // set to a message when the declared key field is absent from the new data and the engine keyed by its default instead } ``` Treat `lostRoles` and `keyFieldFallback` as warnings: they are the changes that alter search behaviour or document identity. A declared key field missing from the new data does **not** fail the replace — the engine keys by its default (an `id` field if present, else auto-generated), exactly as for a dataset with no declared key — but document keys may then differ from the previous load, so change the key under **Field configuration → Document key** if that is not what you want. A removed field with no role is just schema noise; a removed *searchable* field means queries that used to match on it no longer do. A boost rule that references a field which disappears simply goes dormant — it is **not** deleted, and it re-applies if a later replace brings the field back. **Errors** | Status | Meaning | |--------|---------| | `409` | Another index build (a `replace` or a field-config re-index) is already running for this dataset. Retry once it finishes. | | `400` | The new JSON couldn't be built (`code: loadFailed`) — invalid JSON, an indexing error, or it keeps none of the dataset's currently searchable fields. The old dataset is unchanged. | **From the web console** — the same operation is the **Replace data** section of a dataset's **Options** tab: choose the JSON file, click *Replace all data*, and the schema-change summary is shown when it completes. New fields appear in **Field configuration** unconfigured; mark them searchable or filterable there to use them. > The key field is preserved across a replace, so documents keep stable identities between reloads — provided the declared key field is a unique whole number present on every document. If no field qualifies, the dataset uses auto-generated keys. --- ## Lifecycle `POST /api/teams/{teamName}/datasets/{dataSetName}/hibernate` — release memory while preserving the document store in the database (`204`). `POST /api/teams/{teamName}/datasets/{dataSetName}/wakeup` — reload and re-index from the database (`204`). The engine will be back in `Ready` state when `GET status` returns `systemState == 4`. --- # Filters Filters are created server-side and referenced by an opaque `hashString`. Pass that string back in search or bulk-operation requests; do not construct or parse it yourself. ## Value Filter `POST /api/teams/{teamName}/datasets/{dataSetName}/filters/value` Match documents where a field equals a specific value. ```bash curl -X POST https://your-host/api/teams/acme/datasets/products/filters/value \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"fieldName": "category", "value": "electronics"}' ``` Response: ```json { "hashString": "abc123..." } ``` `value` is a string, number or boolean, compared as text against the stored value. The field must hold text or booleans: on a field that holds numbers a text comparison is wrong (`129` is not `129.0`) and the request is answered `400` naming the filter to use instead, a range filter with equal limits. The match is case-insensitive unless you add `"isCaseSensitive": true`. Do that when the value comes from a facet: facets count distinct stored values with their casing, so a facet showing `Fiction: 23 419` and `FICTION: 51` is matched by the default filter with 23 470 documents, and only a case-sensitive filter on `Fiction` answers the 23 419 the facet showed. A case-sensitive filter is built by scanning the documents rather than from the field's index, so it costs more on a large dataset; the answers are the same. ## Range Filter `POST /api/teams/{teamName}/datasets/{dataSetName}/filters/range` Match documents where a field falls within a numeric range, both ends inclusive. Equal limits match one value, which is how to ask for equality on a numeric field. The field must hold numbers: a number stored as a JSON string is text, and the request is answered `400` with the fix - store the values as numbers and load again. ```bash curl -X POST https://your-host/api/teams/acme/datasets/products/filters/range \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"fieldName": "price", "lowerLimit": 10, "upperLimit": 100}' ``` Response: ```json { "hashString": "def456..." } ``` ## Combining Filters `POST /api/teams/{teamName}/datasets/{dataSetName}/filters/combine` Combine two filters with AND or OR. ```bash curl -X POST https://your-host/api/teams/acme/datasets/products/filters/combine \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "a": { "hashString": "abc123..." }, "b": { "hashString": "def456..." }, "useAndOperation": true }' ``` Response: ```json { "hashString": "combined789..." } ``` Set `useAndOperation` to `false` for OR. ## Negating a Filter `POST /api/teams/{teamName}/datasets/{dataSetName}/filters/not` Match every document the given filter does not. The body is the filter's token; the result is a token like any other and can be combined further. ```bash curl -X POST https://your-host/api/teams/acme/datasets/products/filters/not \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "hashString": "abc123..." }' ``` Response: ```json { "hashString": "not789..." } ``` ## The hashString is opaque Treat it as a token: obtain it from a filter endpoint, pass it back in a search or bulk operation, and do not parse, construct or modify it. Its internal form is not part of the API contract and may change without notice. Tokens are durable — one keeps working after the server has evicted the filter from its cache, and across a restart — so a client may hold one for as long as the dataset's fields are unchanged. A token the server cannot honour is answered `400` with `code: unknownFilter`; re-create the filter and use the new token. ## Using a Filter in Search ```json { "text": "wireless", "maxNumberOfRecordsToReturn": 20, "filter": { "hashString": "combined789..." } } ``` A `hashString` the server cannot resolve — mistyped, truncated, or naming a field that has since been removed or un-marked as Filterable — is answered `400` with `code: unknownFilter`. The search is **not** run without the filter. Re-create the filter and retry with the token you get back; never retry the search with the filter omitted. The same applies to every endpoint that takes a token: `filters/combine`, `filters/not`, `boosts/from-filter`, `filters/delete`, `documents/delete-by-filter` and `documents/update-by-filter`. ## Pre-loading Filters `POST /api/teams/{teamName}/datasets/{dataSetName}/filters/load` — loads all filterable fields into memory for faster repeated filtering (`204`). Call once after indexing. `GET /api/teams/{teamName}/datasets/{dataSetName}/filters/count` — returns the count of currently cached filters as `{"count": n}`. ## Releasing Filters `POST /api/teams/{teamName}/datasets/{dataSetName}/filters/delete` — release a specific filter (body: `{ "hashString": "..." }`; `204`). `DELETE /api/teams/{teamName}/datasets/{dataSetName}/filters` — release all cached filters (`204`). --- # Schemas Reference for all request and response shapes used by the HTTP API. --- ## QueryProxy `POST /api/teams/{teamName}/datasets/{dataSetName}/search` ```json { "text": "wireless headphones", "maxNumberOfRecordsToReturn": 20, "enableCoverage": true, "coverageDepth": 500, "enableFacets": false, "enableBoost": false, "removeDuplicates": true, "sortBy": null, "sortAscending": false, "timeOutLimitMilliseconds": 1000, "filter": null, "boosts": null, "fieldBoosts": { "title": 2.0 }, "coverageSetup": null } ``` `coverageDepth` — how many pattern-match candidates coverage confirms, default **500**. Hits past that depth are never evaluated, so on a broad query the shortfall appears as a total that lands exactly on the depth: a count equal to `coverageDepth` is the cap, not your data. Raise it to the document count on small and mid-sized datasets, and use the same depth for the result list and the facet counts. See [Deep coverage](/howto/deep-coverage). `enableFacets` — required for an **empty** `text`. Empty text with `enableFacets: false` is refused, not treated as match-everything: `200`, no records, and `reason` set to `Search called with empty string and EnableFacets=false`. The dataset also needs at least one facetable field. See [Empty search](/howto/empty-search). `fieldBoosts` — per-query multipliers on each field's configured `weight` (a field with `weight: 2.0` and a boost of `2.0` counts 4×). Keys must be searchable fields: a key that is not empties the whole search, returned today as `200` with no records. Only applies in BM25F mode. `null`, or a field left out, uses the configured `weight` unchanged. See [Search](/http/Search) for details. --- ## Result Response from `POST /api/teams/{teamName}/datasets/{dataSetName}/search` ```json { "records": [ { "documentKey": 42, "score": 220 }, { "documentKey": 17, "score": 185 } ], "facets": { "category": [ { "key": "electronics", "value": 12 }, { "key": "accessories", "value": 5 } ] }, "truncationIndex": -1, "truncationScore": 0, "didTimeOut": false } ``` `score` is a 16-bit unsigned integer (0–65535). `truncationIndex` is `-1` when no truncation occurred. `reason` is present when the engine **refused** the query rather than finding nothing — an empty `text` without `enableFacets`, text over the 300-character limit, a dataset that is not ready. The records are empty either way, so check `reason` alongside `didTimeOut` and report a refusal as a failure rather than as an empty result. --- ## VectorQueryProxy `POST /api/teams/{teamName}/datasets/{dataSetName}/search/vector` ```json { "fieldName": "embedding", "vector": [0.12, -0.45, 0.33], "maxResults": 10, "filter": null } ``` The `vector` must match the dimension of the indexed embedding field. Its length does not matter: Indx brings it to unit length. --- ## HybridQueryProxy `POST /api/teams/{teamName}/datasets/{dataSetName}/search/hybrid` ```json { "text": "comfortable wireless headphones", "embeddingField": "embedding", "vector": [0.12, -0.45, 0.33], "alpha": 0.6, "maxNumberOfRecordsToReturn": 20, "filter": null, "timeOutLimitMilliseconds": 1000 } ``` `alpha` — blend weight in `[0, 1]`. `0` = pure text, `1` = pure embedding. --- ## EmbeddingResultEntry Response from `search/vector` and `search/hybrid` ```json [ { "documentKey": 42, "score": 0.94 }, { "documentKey": 17, "score": 0.81 } ] ``` `score` is in `[0, 1]`. For vector search it is cosine similarity; for hybrid search it is the blended value. --- ## FieldProxy Used by `PUT fields/configuration` (request) and `GET fields/configuration` (response). `null` on a PUT means "leave unchanged". ```json [ { "fieldName": "name", "fieldType": "String", "isArray": false, "searchable": true, "filterable": false, "facetable": false, "sortable": false, "wordIndexing": false, "embeddable": false, "preloadFilters": false, "highResolution": false, "weight": 2.0, "bM25b": 0.75, "bM25k1": 1.2, "highResolution": false } ] ``` `fieldType` and `isArray` are read-only — they are populated by the server on GET and ignored on PUT. --- ## SystemStatus Response from `GET /api/teams/{teamName}/datasets/{dataSetName}/status` ```json { "systemState": 4, "documentCount": 5000, "recordsOnDisk": 5000, "fieldsDiscovered": 12, "searchCounter": 142, "secondsToIndex": 3, "shadowBuildInProgress": false, "shadowBuildStartedUtc": null, "version": "5.0.0", "licenseInfo": { ... }, "errorMessage": "", "timeOfInstanceCreation": "2025-01-01T00:00:00Z", "timeOfLastIndexBuild": "2025-01-01T00:05:00Z" } ``` `systemState` values: `0` Created · `1` Loading · `2` Loaded · `3` Indexing · `4` Ready · `255` Error A hibernated dataset reports `Created` together with `recordsOnDisk > 0`; `fieldsDiscovered` says how many fields its stored configuration holds. (`-1` Hibernated existed until Sep 2026 and was removed.) --- ## DataSetListDto Response from `GET /api/me/datasets` ```json [ { "name": "products", "teamName": "acme", "role": "Admin" }, { "name": "catalogue", "teamName": "research", "role": "Editor" } ] ``` `role` is `"Admin"`, `"Editor"`, or `"Viewer"` — your role on the owning team. --- ## TransferToTeamRequest `POST /api/teams/{teamName}/datasets/{dataSetName}/transfer` ```json { "targetTeamName": "research" } ``` Moves the dataset to another team. The caller must be an Admin of both teams. --- ## FilterProxy A filter reference returned by `filters/value`, `filters/range`, `filters/combine` or `filters/not`. Pass it back in search or bulk-operation requests. ```json { "hashString": "abc123..." } ``` --- ## ValueFilterProxy `POST /api/teams/{teamName}/datasets/{dataSetName}/filters/value` ```json { "fieldName": "category", "value": "electronics", "isCaseSensitive": false } ``` `value` may be a string, a number or a boolean. `isCaseSensitive` is optional and off by default; a facet count equals a filter count only for a case-sensitive filter, and a case-sensitive filter is answered by walking the documents rather than by the field's index, so it costs more. The field must **not** hold numbers. A value filter compares text, so on a numeric field the request is answered `400` naming the filter to use instead — see `RangeFilterProxy`. --- ## RangeFilterProxy `POST /api/teams/{teamName}/datasets/{dataSetName}/filters/range` ```json { "fieldName": "price", "lowerLimit": 10, "upperLimit": 100 } ``` Both limits are inclusive, and **equal limits are numeric equality** — that is how to match one value on a numeric field. The field must hold numbers; a number stored as a JSON string is text, and the request is answered `400` saying to store the values as numbers and load again. --- ## BoostProxy `POST /api/teams/{teamName}/datasets/{dataSetName}/boosts/from-filter` ```json { "filterProxy": { "hashString": "abc123..." }, "boostStrength": 2 } ``` `boostStrength`: `1` = Low, `2` = Med, `3` = High. --- # Search Three search modes are available: full-text, vector (embedding-based), and hybrid (blended). --- ## Full-Text Search `POST /api/teams/{teamName}/datasets/{dataSetName}/search` ```bash curl -X POST https://your-host/api/teams/acme/datasets/products/search \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"text": "wireless headphones", "maxNumberOfRecordsToReturn": 20}' ``` ### With Coverage ```json { "text": "wireless headphones", "maxNumberOfRecordsToReturn": 30, "enableCoverage": true, "coverageDepth": 500 } ``` ### With Facets and Filter ```json { "text": "headphones", "maxNumberOfRecordsToReturn": 20, "enableFacets": true, "filter": {"hashString": "abc123..."} } ``` ### With Per-Field Boosts (BM25F mode only) `fieldBoosts` adjusts how much each field counts for one search, without reconfiguring or rebuilding the index. Keys are field names, values are multipliers. ```json { "text": "noise cancelling", "maxNumberOfRecordsToReturn": 10, "fieldBoosts": { "title": 3.0, "description": 0.5 } } ``` - **A boost multiplies the field's configured `weight`; it does not replace it.** A field with `weight: 2.0` and a boost of `3.0` counts 6×. Fields you leave out keep their configured weight (a boost of 1.0). - **Every key must be a searchable field.** Here `title` and `description` must both have `searchable: true` in the dataset's field configuration. - **A key that is not a searchable field empties the whole search.** The response is `200` with no records and a `reason` naming the field ("FieldBoosts contains unknown or non-searchable field: 'x'"), so read `reason` before showing "no results". If a search that used to return results suddenly returns none, check `fieldBoosts` against the field configuration first — making a field non-searchable does not remove it from boosts your code still sends. - **Boosts only apply in BM25F mode**, which a dataset is in when every searchable field has the same `bM25k1` (the default; check with `GET fields/configuration`). If `bM25k1` differs between searchable fields the dataset scores each field separately and ignores `fieldBoosts` — valid keys have no effect, and an invalid key still empties the search. ### Browse / Empty Search ```json { "text": "", "maxNumberOfRecordsToReturn": 50, "sortBy": "rating", "sortAscending": false, "enableFacets": true } ``` --- ## Vector Search `POST /api/teams/{teamName}/datasets/{dataSetName}/search/vector` Approximate nearest-neighbour search against a field marked `Embeddable`. The query vector must match the field's embedding dimensions. Any length is accepted: Indx brings it to unit length, so scores are cosine similarity. ```bash curl -X POST https://your-host/api/teams/acme/datasets/products/search/vector \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "fieldName": "embedding", "vector": [0.12, -0.45, 0.33, ...], "maxResults": 10 }' ``` | Property | Type | Default | Description | |----------|------|---------|-------------| | `fieldName` | `string` | required | Name of the `Embeddable` field to search | | `vector` | `float[]` | required | Query vector, with the field's dimensions | | `maxResults` | `int` | `10` | Maximum number of results | | `filter` | `FilterProxy?` | `null` | Optional filter to restrict the search space | Returns an array of `EmbeddingResultEntry` with scores in `[0, 1]` (cosine similarity). --- ## Hybrid Search `POST /api/teams/{teamName}/datasets/{dataSetName}/search/hybrid` Blends text search and vector search scores. The combined score is: ``` score = alpha × embeddingScore + (1 − alpha) × normalisedTextScore ``` ```bash curl -X POST https://your-host/api/teams/acme/datasets/products/search/hybrid \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "text": "comfortable headphones", "embeddingField": "embedding", "vector": [0.12, -0.45, 0.33, ...], "alpha": 0.6, "maxNumberOfRecordsToReturn": 20 }' ``` | Property | Type | Default | Description | |----------|------|---------|-------------| | `text` | `string` | `""` | Text query | | `embeddingField` | `string` | required | Name of the `Embeddable` field | | `vector` | `float[]` | required | Query vector, with the field's dimensions | | `alpha` | `float` | `0.5` | Blend weight. `0` = pure text, `1` = pure embedding. Values around `0.5–0.7` work well | | `maxNumberOfRecordsToReturn` | `int` | `10` | Maximum results | | `filter` | `FilterProxy?` | `null` | Optional filter | | `timeOutLimitMilliseconds` | `int` | `1000` | Text search timeout | Returns an array of `EmbeddingResultEntry` with blended scores in `[0, 1]`. --- ## Retrieving Full Documents Search endpoints return keys and scores only. Use `documents/lookup` to fetch the full JSON for a set of keys. `POST /api/teams/{teamName}/datasets/{dataSetName}/documents/lookup` ```bash curl -X POST https://your-host/api/teams/acme/datasets/products/documents/lookup \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '[42, 17, 305]' ``` Returns a `string[]` of JSON documents in the same order as the input keys. --- # Statistics Indx collects e-commerce-shaped search statistics per dataset: which searches happen, which results get chosen, and which choices convert. Searches are counted by the server itself — you send only what the server cannot see: the click and the conversion. A search counts when it has text and ran: one the server refused (a `reason` in the response) or that timed out is not search data, and is not counted. An empty search with a filter counts as browsing, reported per filter value. An empty search without one, such as a browse page opening, is not counted, and neither are searches from the web console's own search preview. The dataset's **Statistics** tab in the web console shows the same numbers these endpoints serve. --- ## How the pieces connect 1. Every search response carries an **`Indx-Query-Id`** header — an opaque id for that one search. It is CORS-exposed, so a browser client can read it: ```js const response = await fetch(searchUrl, { ...}); const queryId = response.headers.get('Indx-Query-Id'); ``` 2. When the user clicks a result, send a **select** with that id, the document's key and its 1-based position. When something valuable happens — an order, an add-to-cart — send a **convert**. 3. Aggregates come back from the `statistics/*` endpoints, merged over rolled-up days and live events. A Search-level key (the one already in your storefront) is enough to send events; reading statistics needs a Read key. Events answer `202 Accepted` — they are queued and batch-written. --- ## Sending events `POST /api/teams/{teamName}/datasets/{dataSetName}/events/select` ```bash curl -X POST https://your-host/api/teams/acme/datasets/products/events/select \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"queryId": "cf55aa99…", "documentKey": 42, "position": 1, "subject": "customer-123"}' ``` `POST /api/teams/{teamName}/datasets/{dataSetName}/events/convert` ```bash curl -X POST https://your-host/api/teams/acme/datasets/products/events/convert \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"documentKey": 42, "type": "order", "subject": "customer-123"}' ``` | Field | | | |---|---|---| | `queryId` | optional | The value from the search response's `Indx-Query-Id` header. Optional on convert — an order may come long after the search, or from another channel; it still counts on the document. An unknown or expired id is accepted too: the event keeps its counts and just finds no search to join | | `documentKey` | required | The document's key | | `position` | select only, required | 1-based position of the clicked result in the list | | `type` | convert only, required | Your own name for the event: `"order"`, `"addToCart"`, `"signup"`, … | | `value`, `currency`, `quantity` | optional | Accepted and stored, not reported. Indx counts conversions as a search signal; what an order was worth belongs in your own systems | | `subject` | optional | See below | | `count` | optional | `false` for an event that is not a visitor's (a test, an agent). An event on a search that was sent with `count=false` is not counted either way | ### Checking that clicks join their search The event posts answer `202` before the event is written, and an unknown `queryId` is accepted, so the answer cannot tell you whether the click found its search. The reads can, within a few seconds (events are written in batches every couple of seconds, and the reads include them at once): 1. Search for something unusual and click a result, with the `Indx-Query-Id` of that response as `queryId`. 2. `GET …/statistics/queries/documents?days=1&text=` lists the document you clicked. A click that joined its search is counted on the query; one that did not is counted on the document only, so it shows in `statistics/documents` and not here. Run the check counted: a search sent with `count=false`, and every click on it, is left out of the reads by design. A test dataset, or a single counted search you can live with, does it. ### The session Search-as-you-type sends a search on every keystroke, so typing "oslo" sends `o`, `os`, `osl` and `oslo`. Send the same `session` on each of them and only the one the visitor settled on is counted: ``` POST …/search?session=8f3a1c ``` The session also merges repeats. A front-end often sends the same search more than once: again with facets after a short delay, for a new sort, or to load more. The same session sending the same text and filter within 30 seconds is one search, and the click counts on the last one, so keep the `Indx-Query-Id` of the latest response. A request for no records (`maxNumberOfRecordsToReturn: 0`) is not counted at all. A session is any random id you create per page load. It identifies no one, and it is never shown or stored anywhere but next to the search. Without one, the server can only leave out single letters, so the prefixes of longer queries are counted. A search that got a click is always counted. **A page rendered on the server needs a session too.** Without a cookie there is no visitor to follow, so make the id per page view: create it when the results page is first rendered, and carry it in the links and forms that page sends, so paging, a new sort or a facet click repeat it. Without one, each of those counts as another search, and a popular query reads as more searches, with a lower click-through, than it had. The server does not merge searches without a session on its own: on a busy page, two visitors searching the same thing at the same time are two searches. **A strict search with a fuzzy retry is one search in one session.** A common pattern searches with `includePatternMatches: false` first, so an empty answer is a trustworthy no-match, and only then again with pattern matching on, showing those results as guesses. Send both with the same `session`: they are the same text and filter, so they count as one search, the retry is the one kept, and a click on one of its results counts as a **fuzzy find**. Sent without a session they are two searches, one of them a search that found nothing. ### The surface, and searches that should not count A dataset can have several ways in: a search box in the header, a product finder, an app. Name the one a search came from with `source`, and it is stored with the search and counted as usual: ``` POST …/search?session=8f3a1c&source=header ``` A search that is not a visitor's - an agent checking what a query returns, a monitor, a test - sends `count=false`. It is stored, with its `source`, but left out of every statistic, the way the web console's own search preview is (stored as `source` `console`). A select or conversion on such a search is not counted either. ``` POST …/search?source=monitor&count=false ``` ### The subject `subject` is one opaque string **you** define: an end-user id, a session id, or a customer segment. Send it on searches too (`POST …/search?subject=customer-123`) and the same statistics serve per-user personalization ("top 10 for this user"). Omitted means anonymous — don't invent a placeholder value; anonymous events are counted honestly as anonymous. `DELETE …/statistics/subjects/{subject}` is the GDPR erasure: the subject's personalization rows are deleted and its raw events anonymised, so aggregate counts stay true while nothing ties them to the subject any more. --- ## Reading statistics All reads take `?days=` (default 30) and need a Read-level key. ### Days, in your time zone A day runs from midnight to midnight in the dataset's time zone: UTC unless you choose another, or unless the server's operator set a default (`Statistics:TimeZone`). Every read counts in it, `days=7` is the last seven of its days, today included, and `from`/`to` on a delete are its days. A Norwegian shop sets Oslo, and an evening search lands on the evening's day: ``` PUT …/statistics/settings {"timeZone": "Europe/Oslo"} ``` The name is an IANA time zone. `""` goes back to the default, and a name that is not a time zone is a `400`. Changing it counts again every day whose searches are still kept, 90 days by default; older days keep the zone they were counted in. In the web console the choice is on the **Options** tab. ### One surface at a time `overview`, `timeseries`, `queries`, `queries/documents`, `documents` and `filters` take `?source=`, and then count the searches sent with that `source` alone: ``` GET …/statistics/overview?days=7&source=typeahead GET …/statistics/overview?days=7&source=books ``` A click or conversion counts for the surface of the search it names by `queryId`; one sent without a `queryId` belongs to no surface and is counted only in the totals. A `source` no search was sent with gives zeros, not the totals. A read for one surface comes from the stored searches, so it reaches back as far as they are kept, 90 days by default. `GET …/statistics/sources` lists the surfaces in the window, most searched first, with their searches counted as the overview counts them. A surface where visitors only browsed (a filter, no text) is listed with 0 searches. Searches sent without a `source` are the row with `"source": null`: ```json [ { "source": "typeahead", "searches": 180 }, { "source": "books", "searches": 52 }, { "source": null, "searches": 3 } ] ``` The web console's Statistics tab has the same choice, as a selector beside the period once any search has named its surface. `GET …/statistics/overview` — the headline numbers: ```json { "searches": 1204, "uncovered": 96, "uncoveredRate": 0.08, "uncoveredChosen": 41, "clickedSearches": 311, "selects": 402, "converts": 58, "clickThroughRate": 0.258, "averageClickPosition": 2.4, "zeroHits": 37, "zeroHitRate": 0.031 } ``` **Fuzzy finds** are where Indx earns its keep. `uncovered` counts the searches with typos too large for an exact match: coverage confirmed nothing, so what the visitor saw came from fuzzy search alone, or nothing was shown. `uncoveredChosen` counts the ones where the visitor chose a result anyway, the searches fuzzy search answered. `zeroHits` remains for compatibility. A search made with coverage switched off is not counted as uncovered, since there is nothing to tell. Rates are computed server-side so every consumer shares the definitions: click-through counts searches with at least one select against **all** searches (zero-hit ones included), and a rate whose denominator is zero is `null`, never `0%`. `GET …/statistics/timeseries` — one row per day (`"date": "2026-09-29"`), zero rows included so charts have no holes. `GET …/statistics/queries?limit=50` — top queries with searches, selects, click-through and average click position per query. `order=` picks the order: `searches` (the default), `lowestClickThrough` (searched often and chosen from rarely, so either the dataset lacks what people want or ranks it too low), `highestClickThrough`, and `chosenAnyway` with `uncoveredOnly=true`. The two click-through orders leave out queries searched fewer than five times, where one click reads as a rate of 100 %. `GET …/statistics/queries/documents?text=lamp` lists, for one query, every document chosen from its results, most chosen first, with how often, the sum of the positions it was chosen at, and the conversions from the same searches. It tells a query that found the thing people meant from one that only offered a substitute. Read from the raw events, so up to 90 days back by default. `GET …/statistics/queries?uncoveredOnly=true` — the misspelled searches behind the console's **Fuzzy search** section, each with `uncovered` (how often), `uncoveredChosen` (how often a result was chosen anyway) and `mostChosenDocument`, the result chosen most. `GET …/statistics/documents?limit=50` — top documents by selects, with conversions. `GET …/statistics/filters?limit=50` — browsing: how often people narrowed by each filter value, with or without text, and how often that left them nothing. A combined filter counts once per value in it; a range filter is reported by its field, with an empty `value`. Browsing stores the filter of every search. On a dataset of people a filter value can be a name or a customer number, so it can be switched off per dataset: `PUT …/statistics/settings` with `{"recordFilters": false}` (Full key, team admin), or the Options tab in the web console. Switching it off stops the recording; filter values already recorded stay until they age out of retention or the dataset's statistics are erased. `GET …/statistics/settings` reads the setting; it is on by default. `GET …/statistics/subjects/{subject}?limit=10` — one subject's lifetime top documents. ### Names beside the keys `documents`, `queries/documents` and `subjects/{subject}` give each document a `label` beside its `documentKey`, and the misspelled-query list a `mostChosenLabel`: the value of one field, so a report can say which book rather than which number. It is the first searchable field (usually the title) unless you choose another: ``` PUT …/statistics/settings {"labelField": "title"} ``` A nested field goes by its dotted path (`author.name`); an array field gives its first three values. `""` goes back to the first searchable field, and a field the dataset does not have is a `400`. A property you leave out of the `PUT` stays as it is, so this does not touch `recordFilters`. The label is read from the loaded index, and a statistics read never wakes a sleeping dataset: while it sleeps, `label` is `null` and the key stands alone. In the web console the choice is on the **Options** tab, beside the Browsing setting. --- ## What changed, beside the numbers When a number moves, the first question is what changed. Indx records the changes made to the dataset itself, so the answer sits next to the chart: synonyms saved, boost rules saved, fields changed, a reindex, a replace, the dataset hibernating or waking, a rename. Document inserts, updates and deletes are counted per day, and a single request that changes many documents at once (a thousand, or a tenth of the dataset if that is fewer) is recorded on its own, as is every delete-by-filter and update-by-filter. Configuring a dataset before its first index is setup, not a change, and is not recorded. Only what changed and when is kept: counts and field names. Never the content, and never who made the change. In the web console the changes are small marks under the chart on the **Statistics** tab, with a list below it. `GET …/statistics/changes?days=30` — the window's changes, oldest first, each with `at` (UTC), `kind` and a small `summary`, and the document totals per day: ```json { "changes": [ { "at": "2026-09-12T08:41:03Z", "kind": "synonyms", "summary": { "entriesBefore": 34, "entries": 44 } }, { "at": "2026-09-14T02:04:51Z", "kind": "documents", "summary": { "operation": "deleteByFilter", "inserted": 0, "updated": 0, "deleted": 19000, "documents": 28000 } } ], "documents": [ { "date": "2026-09-14", "inserted": 0, "updated": 5200, "deleted": 19000 } ] } ``` ## Lifecycle and retention Statistics are keyed on the team and the dataset **name** — the same way document keys are yours — so they survive dataset deletion and recreation under the same name, a zero-downtime `replace` continues them seamlessly, and a dataset moved to another team takes them along. `DELETE …/statistics` (Full key, team Admin) is the one explicit way to start clean, and it deletes the change history too. In the web console it is on the dataset's **Options** tab. To remove test traffic without losing the real history, delete part of it instead: ``` DELETE …/statistics?source=deploy-check one surface's searches DELETE …/statistics?from=2026-10-06&to=2026-10-06 the searches of those days (inclusive) DELETE …/statistics?source=deploy-check&from=2026-10-06 both ``` Each takes the searches it selects with their clicks and conversions, and keeps what the owners changed. By days, the daily totals of those days go too. By source, a select or conversion goes with the search it names by `queryId`, and the daily totals are counted again from what is left; days older than the raw events (90 days) keep that surface's share. A day that is not a date, or `from` after `to`, is a `400` and deletes nothing. Raw events are kept 90 days (operator-configurable) and rolled up into daily aggregates that are kept until deleted. When the operator has switched the feature off, every route here answers `404` with code `statisticsDisabled`. --- # Endpoints All endpoints are prefixed with `/api/` and require JWT Bearer authentication unless noted. **Dataset endpoints are scoped to a team and a dataset:** ``` /api/teams/{teamName}/datasets/{dataSetName}/{operation} ``` The tables below list the `{operation}` for each dataset endpoint. Endpoints that are not dataset-scoped (listing datasets, account) show their full path. The current API version is `2.0-beta`. It is the default — no version header is needed for standard use. If you want to be explicit, pass it as a header or query string: ``` api-version: 2.0-beta # header ?api-version=2.0-beta # query string ``` ## Authentication Create an API key in the console (**Account → API keys**, `/account/api-key`), then send it as a Bearer header on every request. ```bash curl -H "Authorization: Bearer " https://your-host/api/... ``` Every key is limited to **one team**, optionally to **some of its datasets**, and to an **access level**: | Level | Can call | Use it for | |---|---|---| | **Search only** | `search`, `search/vector`, `search/hybrid`, `documents/lookup`, `events/select`, `events/convert`, `filters/value`, `filters/range`, `filters/combine`, `filters/not`, `boosts/from-filter`, `fields/facetable`, `fields/filterable`, `fields/sortable`, `fields/searchable`, `GET fields/configuration`, `status`, and `PUT` on an existing dataset | Websites and apps — the key is visible in the browser, and this is all it can do | | **Read only** | Every read: adds `export`, `fields/configuration`, `synonyms`, `boosts`, `statistics/*`, counts, dataset lists | Exports, reporting, agents, back-ups — keep it on a server | | **Full access** | Everything your team role allows, including loading, changing and deleting data | Your own servers and pipelines | A key never exceeds its owner's role in the team: a Viewer can create Search and Read keys, not Full ones. Keys cannot be changed after they are created — create a new one and revoke the old. A key outside its limits gets the same answer as a missing team or dataset (`404 teamNotFound` / `datasetNotFound`); a call above its level gets `403 insufficientKeyScope`. Keys created before access levels existed are unscoped and reach every team you belong to — replace them. ### Personal keys and team keys There are two kinds of key, with the same access levels: | | Personal key | Team key | |---|---|---| | Created under | **Account → API keys** | **Manage team → API keys**, by a team Admin | | Acts as | You: never more than your role in the team | The team: its access level alone. Full access writes as an Editor does; deleting datasets and managing the team stay with people (`403 insufficientRole`) | | If its creator leaves the team | Stops working | Keeps working | | Seen and revoked by | You | Every Admin of the team | | Use it for | Your own scripts and AI agents | A production website, a shared server, CI | For anything deployed, use a team key, so it does not depend on one person's account. **Showing a key again.** Search only and Read only keys can be shown again from the key list (**Show key**): a personal key to its owner, a team key to the team's Admins. Full access keys are shown once, when they are created; lost one? Create a new key and revoke the old. A Read only key created before October 2026 was not stored and cannot be shown again either. Every key is listed with its last four characters (`…a1b2`), so you can match the key in a config file to its row. **Expiry.** Keys last 30, 90, 180 or 365 days. A team Search only key can also be set to never expire. The team's Admins are notified 30 and 7 days before a team key expires. **Rotating a key without downtime.** Create the new key, deploy it, check that traffic works, then revoke the old one. Revoking takes effect at once. --- ## Error responses Every error is an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) `ProblemDetails` served as `application/problem+json`, with a machine-readable **`code`** extension — branch on `code` (or the status), never on the `detail` text. | Status | `code` | Meaning | |--------|--------|---------| | `400` | `invalidArgument`, `invalidDatasetName`, `loadFailed`, `operationFailed` | The request or its payload can't be served — see `detail` | | `400` | `unknownFilter` | A referenced filter `hashString` could not be resolved — re-create the filter and retry with the new token | | `401` | — (body-less) | Missing, expired, or invalid bearer token | | `401` | `invalidCredentials`, `userNotFound` | Login failed | | `403` | `insufficientRole` | You are a member, but your team role is too low (needs Editor/Admin) | | `403` | `insufficientKeyScope` | Your role allows it, but the API key's access level does not (a Search key calling a write, say) | | `404` | `teamNotFound` | The team doesn't exist **or** you are not a member (identical on purpose, so team names can't be enumerated) | | `404` | `datasetNotFound`, `documentNotFound` | The addressed dataset or document key doesn't exist | | `404` | `statisticsDisabled` | Statistics are switched off on this instance — the events and statistics routes are unavailable | | `409` | `invalidState`, `shadowBusy` | Wrong lifecycle state, or a background rebuild is already running | | `429` | `rateLimited` | A rate limit or capacity cap was hit — always retryable after `retryAfterSeconds` | | `500` | `internalError` | Unexpected server error — report the included `traceId` | Batch document operations are all-or-nothing: a bad key or record rejects the whole batch (missing keys are all named in one `documentNotFound` problem) and nothing is applied. The single-document routes `POST documents/{documentKey}` and `PUT documents/{documentKey}` require the route key to match the body's key field. A **`409 invalidState`** is returned when a request is valid but the dataset's `systemState` can't serve it — for example calling `search` before the dataset is `Ready`, or `wakeup` when it is already up: ```json { "status": 409, "detail": "Search cannot run on dataset 'products' because it is currently Indexing. Indexing is in progress — retry once the dataset reaches Ready.", "code": "invalidState", "currentState": "Indexing", "allowedStates": ["Ready"], "retryable": true } ``` When `retryable` is `true` (states `Loading` / `Indexing`) the response also carries a **`Retry-After`** header (seconds): poll `GET status` until `Ready`, then retry. When `retryable` is `false` (e.g. `Created`, `Error`), take the corrective step from `detail` first — `Created` with `recordsOnDisk > 0` in the status is a hibernated dataset, so `POST wakeup`; `Created` with nothing stored → `POST load` then `POST index`. Resending the same request without changing the state will keep returning 409. A **`429 rateLimited`** means nothing is wrong with the request; the server is asking you to slow down. It carries `retryAfterSeconds` and a matching **`Retry-After`** header, and is always retryable — wait that long, then send the same request again: ```json { "status": 429, "title": "Rate limit exceeded", "detail": "This API key exceeded 50 requests per second (burst 200). Try again in 1 seconds.", "code": "rateLimited", "retryAfterSeconds": 1 } ``` Three limits share this response. Which one you hit is in `detail`, but the handling is the same: | Limit | Applies to | Default | |-------|-----------|---------| | Login attempts | `POST /api/login` and the dashboard's login and password forms, per client address | 10 per minute | | Per API key | All authenticated calls, per key — only where the operator enabled it | 50 requests/s, burst 200 | | No valid token | Calls to `/api` or `/mcp` whose bearer token is missing, expired or invalid, per client address | 30 per minute | To stay clear of them: send documents in batches (the `documents` endpoints take arrays) rather than one call per record. The operator's side of these limits is in [Server Setup](/guides/server-setup#rate-limits). Note that a busy dataset is a `409`, not a `429`: a rebuild that is already running does not queue another behind it. --- ## Datasets | Method | Path / Operation | Description | |--------|------------------|-------------| | `GET` | `/api/me/datasets` | List every dataset across the teams you belong to (each with its team and your role) | | `GET` | `/api/teams/{teamName}/datasets` | List the datasets owned by one team | | `PUT` | `/api/teams/{teamName}/datasets/{dataSetName}` | Create or open a dataset — `201` if it was created, `200` if it already existed | | `GET` | `status` | Get current engine state and counters | | `DELETE` | `/api/teams/{teamName}/datasets/{dataSetName}` | Permanently delete the dataset and all its data (team Admin) — `204` | --- ## Data Loading | Method | Operation | Description | |--------|-----------|-------------| | `POST` | `analyze` | Discover fields from a streamed JSON body | | `POST` | `analyze/text` | Discover fields from a JSON string (`text/plain`) | | `POST` | `load` | Load documents from a streamed JSON body — `204` | | `POST` | `load/text` | Load documents from a JSON string (`text/plain`) — `204` | | `POST` | `replace` | Atomically replace the whole dataset, preserving config + boost rules (zero-downtime) | | `POST` | `load/from-database` | Reload documents from the persisted database — `204` | | `POST` | `index` | Start the index build — `202 Accepted` at once, with the status; the build runs in the background. First build: poll `GET status` until `Ready`. Rebuild of a `Ready` dataset: poll until `shadowBuildInProgress` is `false`, then read `shadowBuildError` | | `GET` | `documents/count` | Count documents stored in the database — `{"count": n}` | --- ## Field Configuration | Method | Operation | Description | |--------|-----------|-------------| | `PUT` | `fields/configuration` | Set field roles, weights and BM25 parameters, for any number of fields in one call — `204` when applied at once, `202` when it started a rebuild (see below) | | `GET` | `fields/configuration` | Get current configuration for all fields | | `GET` | `fields` | List all discovered field names | | `GET` | `fields/searchable` | List searchable fields | | `GET` | `fields/filterable` | List filterable fields | | `GET` | `fields/facetable` | List facetable fields | | `GET` | `fields/sortable` | List sortable fields | | `GET` | `fields/word-indexing` | List word-indexed fields | | `GET` | `fields/embeddable` | List vector (embeddable) fields | | `GET` / `PUT` | `fields/key` | Get / set the key field (`PUT` returns `needsReloadToReKey` in the body) | > **Roles are written in one place.** The role lists above are read-only. Until October 2026 each also had a `PUT` (`PUT fields/searchable` and the rest, and `PUT fields/embeddable`); those are removed and answer `405`. Send the same change to `PUT fields/configuration`: one object per field, with only the properties you want changed. ### What `PUT fields/configuration` answers The whole request is checked before anything is changed. A request that is refused — an unknown field, a negative weight, a vector field given another role — is a `400` and changes nothing. An accepted request answers in one of two ways: | Answer | Meaning | What to do | |--------|---------|------------| | `204` | The change is applied. Every change before the dataset is indexed, and a change of query-time roles (filterable, facetable, sortable, weight) on fields already in use | Nothing | | `202` + status | The change needs the index rebuilt and the dataset is `Ready`. A rebuild has been **started** on a shadow engine; the dataset keeps serving with the old configuration until it is swapped in | Poll `GET status` until `shadowBuildInProgress` is `false`. `shadowBuildError` is then `null` if the new configuration is in use, or says why the rebuild failed (the old configuration is still in use) | ```json { "systemState": 4, "shadowBuildInProgress": false, "shadowBuildStartedUtc": null, "shadowBuildFinishedUtc": "2026-10-05T07:12:44.103Z", "shadowBuildError": null } ``` While a rebuild runs, a second configuration change, a `POST index` and a bulk document change are refused with `409 shadowBusy`: retry when `shadowBuildInProgress` is `false`. Until October 2026 the request waited for the rebuild — minutes on a large dataset, past the timeout of most HTTP clients. A **vector field has no other role**: a field marked `embeddable` cannot also be searchable, filterable, facetable, sortable or word-indexed, and only an array of numbers can be marked `embeddable`. --- ## Search | Method | Operation | Description | |--------|-----------|-------------| | `POST` | `search` | Full-text search | | `POST` | `search/vector` | Embedding-based nearest-neighbour search | | `POST` | `search/hybrid` | Blended text + embedding search | | `POST` | `documents/lookup` | Retrieve full JSON documents by key (body: key array) | --- ## Dynamic Operations | Method | Operation | Description | |--------|-----------|-------------| | `POST` | `documents/{documentKey}` | Insert a single document — `201` | | `POST` | `documents` | Batch insert documents — `201` | | `PUT` | `documents/{documentKey}` | Replace a single document — `204` | | `PUT` | `documents` | Batch replace documents — `204` | | `PATCH` | `documents/{documentKey}` | Update one field on one document — `204` | | `POST` | `documents/update-by-filter` | Update a field on all documents matching a filter — `{"count": n}` | | `DELETE` | `documents/{documentKey}` | Delete a single document — `204` | | `DELETE` | `documents` | Delete multiple documents by key array — `204` | | `POST` | `documents/delete-by-filter` | Delete all documents matching a filter — `204` | --- ## Filters | Method | Operation | Description | |--------|-----------|-------------| | `POST` | `filters/value` | Create a filter matching a specific field value | | `POST` | `filters/range` | Create a filter matching a field value range | | `POST` | `filters/combine` | Combine two filters with AND or OR | | `POST` | `filters/not` | Negate a filter: every document it does not match | | `POST` | `filters/load` | Pre-load all registered filters into memory — `204` | | `GET` | `filters/count` | Count currently cached filters — `{"count": n}` | | `POST` | `filters/delete` | Release a cached filter (filter key in the body) — `204` | | `DELETE` | `filters` | Release all cached filters — `204` | --- ## Boosts | Method | Operation | Description | |--------|-----------|-------------| | `GET` | `boosts` | List the dataset's saved boost rules | | `PUT` | `boosts` | Replace the dataset's saved boost rules — `204` | | `DELETE` | `boosts` | Delete all saved boost rules — `204` | | `POST` | `boosts/from-filter` | Create a score boost for documents matching a filter | --- ## Statistics and Events Searches are counted by the server itself; the client reports selects and conversions. The search response's `Indx-Query-Id` header (CORS-exposed) is what joins them — full detail on the [Statistics](/http/Statistics) page. | Method | Operation | Description | |--------|-----------|-------------| | `POST` | `events/select` | The user chose a result (`queryId?`, `documentKey`, 1-based `position`, `subject?`, `count?`) — Search key → `202` | | `POST` | `events/convert` | A conversion (`type`, optional `value`/`currency`/`quantity`, optional `queryId`/`subject`/`count`) — Search key → `202` | | `GET` | `statistics/overview` | The window's totals and rates (`?days=30`) — Read key | | `GET` | `statistics/timeseries` | One row per UTC day, zero rows included — Read key | | `GET` | `statistics/queries` | Top queries (`?order=`); `uncoveredOnly=true` = the misspelled searches fuzzy search answered or missed. Read key | | `GET` | `statistics/queries/documents` | For one query (`?text=`): the documents chosen from its results. Read key | | `GET` | `statistics/documents` | Top documents by selects, with conversions. Read key | | `GET` | `statistics/subjects/{subject}` | One subject's lifetime top documents — Read key | | `DELETE` | `statistics` | Purge the dataset's statistics (Full key, team Admin) — `204` | | `DELETE` | `statistics/subjects/{subject}` | GDPR erasure of one subject (Full key, team Admin) — `204` | --- ## Lifecycle | Method | Operation | Description | |--------|-----------|-------------| | `POST` | `hibernate` | Release memory while preserving the document store — `204` | | `POST` | `wakeup` | Restore from hibernation — `204` | --- ## Teams Access is governed by **team membership** — a user is an Admin, Editor, or Viewer of every dataset their team owns. Membership is managed from the account portal's team pages, not the API. The only cross-team API operation is moving a dataset: | Method | Operation | Description | |--------|-----------|-------------| | `POST` | `transfer` | Move the dataset to another team (body: `{ "targetTeamName": "..." }`; caller must be Admin of both teams) | --- ## Account | Method | Path | Description | |--------|------|-------------| | `POST` | `/api/change-password` | Change password | > API tokens are created on the **API Key** page of the account portal (`/Account/ApiKey`), not via an API call. ## BoostRules POST /api/teams/{teamName}/datasets/{dataSetName}/boosts/from-filter CreateBoost will create a Boost setup which may be passed to any search. GET /api/teams/{teamName}/datasets/{dataSetName}/boosts Returns the dataset's persisted boost rules (server-side ranking rules applied when a search sets enableBoost). Config, not state-gated — available even when not Ready. PUT /api/teams/{teamName}/datasets/{dataSetName}/boosts Replaces the dataset's boost rules (whole list). Each rule needs a name, at least one condition, and every condition must reference a Filterable field and be either a value match or a numeric range (not both). DELETE /api/teams/{teamName}/datasets/{dataSetName}/boosts Clears all boost rules for the dataset. ## DatasetExport GET /api/teams/{teamName}/datasets/{dataSetName}/export Downloads every document in the dataset as one JSON array, ordered by document key — the records as they were loaded, replaced or last updated, including fields that are not configured. Streams, so it works on a dataset of any size, and works whether the dataset is Ready, hibernated or idle-evicted. Any team member may export. Field configuration and boost rules are separate exports. ## Datasets POST /api/teams/{teamName}/datasets/{dataSetName}/analyze As Analyze but handles a stream as input text. POST /api/teams/{teamName}/datasets/{dataSetName}/analyze/text Analyze the fields of a string containing json. Since json may be invalid, which will cause a 400 error, it is sent as plain text. PUT /api/teams/{teamName}/datasets/{dataSetName} Creates the data set (201). Idempotent: an existing data set is left exactly as it is and answers 200. There is no longer a `configuration` query parameter — there was one configuration to choose from, so choosing was theatre. Clients that still send `?configuration=400` (every published `@indxsearch/intrface` does) are unaffected: a query value that binds to nothing is ignored, which `UnknownQueryParameterTests` pins. Note the one behaviour change that comes with it — a garbage value used to be a validation 400 from enum binding and is now simply ignored, which is safe only because the value no longer reaches anything. DELETE /api/teams/{teamName}/datasets/{dataSetName} DeleteDataSet, will delete the entire dataSet including all contained Documents. Requires team Admin. GET /api/teams/{teamName}/datasets/{dataSetName}/status GetStatus will return the status of the dataSetName in the search engine. GET /api/me/datasets Returns all datasets the current user can reach via team membership, across every team they belong to. Each entry carries the owning team name and the caller's role on it. GET /api/teams/{teamName}/datasets Lists the datasets owned by a single team. POST /api/teams/{teamName}/datasets/{dataSetName}/index IndexDataSet will start indexing of the loaded documents. POST /api/teams/{teamName}/datasets/{dataSetName}/load/from-database Loads the jsonData into search engine from the database. POST /api/teams/{teamName}/datasets/{dataSetName}/load Loads the jsonData into search engine as a stream. POST /api/teams/{teamName}/datasets/{dataSetName}/replace Atomically replaces the entire dataset's documents with the streamed JSON. Unlike delete+recreate this preserves the dataset's identity, field configuration, boost rules and description, serves the old data with zero downtime until the new index is ready, and leaves the old dataset untouched if the new JSON fails to build. Works whether the dataset is Ready, hibernated or idle-evicted (the old documents are never reloaded). Returns a summary of how the new schema differed from the previous field configuration. POST /api/teams/{teamName}/datasets/{dataSetName}/load/text Loads the jsonData into search engine as a string. POST /api/teams/{teamName}/datasets/{dataSetName}/hibernate Hibernates the dataset: everything leaves memory while the documents stay in the store. The dataset reports Created with recordsOnDisk > 0 afterwards, and WakeUp — or, for a timed/pinned dataset, simply the next access — restores it. POST /api/teams/{teamName}/datasets/{dataSetName}/wakeup Wakes a hibernated dataset — Created with records in the store — by reloading the documents from the store and rebuilding the index. The same operation the console's Wake button runs, so its progress shows there too. ## Documents DELETE /api/teams/{teamName}/datasets/{dataSetName}/documents/{documentKey} Deletes a document from the dataset by its key. POST /api/teams/{teamName}/datasets/{dataSetName}/documents/{documentKey} Inserts one single Json record. The route key must match the document's key field (the engine keys documents from the body, so a disagreeing route would otherwise silently insert under a different key than the URL claims). PUT /api/teams/{teamName}/datasets/{dataSetName}/documents/{documentKey} Updates one single Document. The route key must address an existing document and match the document's key field (the engine keys documents from the body, so a disagreeing route would otherwise silently update a different document than the URL claims). PATCH /api/teams/{teamName}/datasets/{dataSetName}/documents/{documentKey} Updates a single field on a document identified by its key. DELETE /api/teams/{teamName}/datasets/{dataSetName}/documents Deletes documents from the dataset by their keys. POST /api/teams/{teamName}/datasets/{dataSetName}/documents Inserts new JSON records into the dataset. PUT /api/teams/{teamName}/datasets/{dataSetName}/documents Updates existing JSON records in the dataset. POST /api/teams/{teamName}/datasets/{dataSetName}/documents/lookup Returns the raw json records as string[] for the keys. GET /api/teams/{teamName}/datasets/{dataSetName}/documents/count Returns the number of JSON records in the database for the given dataset. POST /api/teams/{teamName}/datasets/{dataSetName}/documents/delete-by-filter Deletes all documents matching the given filter. POST /api/teams/{teamName}/datasets/{dataSetName}/documents/update-by-filter Updates a field on all documents matching the given filter. Returns the number of updated documents. ## Events POST /api/teams/{teamName}/datasets/{dataSetName}/events/select The user chose a result: the document, its 1-based position, and optionally the queryId from the search response's Indx-Query-Id header and a subject. POST /api/teams/{teamName}/datasets/{dataSetName}/events/convert Something the customer considers valuable happened for a document: an order, an add-to-cart — the type is theirs. QueryId is optional; without it the conversion still counts on the document. ## Fields GET /api/teams/{teamName}/datasets/{dataSetName}/fields GetAllFields will return the fields found during analyze. GET /api/teams/{teamName}/datasets/{dataSetName}/fields/facetable GetFacetableFields will return the array of facetable field names. PUT /api/teams/{teamName}/datasets/{dataSetName}/fields/facetable Sets the Facetable property on the specified fields. GET /api/teams/{teamName}/datasets/{dataSetName}/fields/filterable GetFilterableFields will return the array of filterable field names. PUT /api/teams/{teamName}/datasets/{dataSetName}/fields/filterable Sets the Filterable property on the specified fields. GET /api/teams/{teamName}/datasets/{dataSetName}/fields/searchable GetSearchableFields will return the array of searchable field names. PUT /api/teams/{teamName}/datasets/{dataSetName}/fields/searchable Sets the Searchable property and weight on the specified fields. GET /api/teams/{teamName}/datasets/{dataSetName}/fields/sortable GetSortableFields will return the array of sortable field names. PUT /api/teams/{teamName}/datasets/{dataSetName}/fields/sortable Sets the Sortable property on the specified fields. GET /api/teams/{teamName}/datasets/{dataSetName}/fields/word-indexing GetWordIndexingFields will return the array of word-indexing field names. PUT /api/teams/{teamName}/datasets/{dataSetName}/fields/word-indexing Sets the WordIndexing property on the specified fields. PUT /api/teams/{teamName}/datasets/{dataSetName}/fields/configuration SetFieldConfiguration sets any combination of field properties (Searchable, Filterable, Facetable, Sortable, WordIndexing, Embeddable, PreloadFilters, Weight, BM25b, BM25k1) in one call. Nullable properties have replace semantics: null = leave untouched, any value (including false) = overwrite. GET /api/teams/{teamName}/datasets/{dataSetName}/fields/configuration GetFieldConfiguration returns the full configuration of every field in the dataset. Search level, not Read: a filter panel needs each field's type to know whether a selected value is a value filter or a range with equal limits, and the name lists it reads today do not carry it. A Search key can already search and read documents, so the types and sample values here reveal nothing it does not see. GET /api/teams/{teamName}/datasets/{dataSetName}/fields/key Returns the dataset's declared key field — the JSON field whose value identifies each document (the primary key). Empty string means none is declared (the engine auto-generates keys). Required, when set, to be a numeric field. PUT /api/teams/{teamName}/datasets/{dataSetName}/fields/key Declares the dataset's key field (the JSON field whose value is each document's primary key). Pass an empty string to clear it (auto-generated keys). The field must be numeric. The choice is preserved across reloads and applied on the next Load/replace; documents already loaded keep their existing keys until the data is reloaded. PUT /api/teams/{teamName}/datasets/{dataSetName}/fields/embeddable Marks the specified fields as embeddable so that their vector values are indexed during the next Load. Must be called after AnalyzeStream and before LoadStream. ## Filters POST /api/teams/{teamName}/datasets/{dataSetName}/filters/combine CombineFilters will combine two filters using AND or OR operation. POST /api/teams/{teamName}/datasets/{dataSetName}/filters/not NegateFilter returns a filter matching every document the given filter does not: the NOT of a value, range or combined filter. The result is a token like any other and can be combined further. POST /api/teams/{teamName}/datasets/{dataSetName}/filters/range CreateRangeFilter will create a RangeFilter which may be passed to any search. POST /api/teams/{teamName}/datasets/{dataSetName}/filters/value CreateValueFilter will create a ValueFilter which may be passed to any search. Case-insensitive unless `isCaseSensitive` is set; see Indx.Http.ValueFilterProxy for when it should be, and what it costs. POST /api/teams/{teamName}/datasets/{dataSetName}/filters/delete Deletes a single filter from the filter cache. DELETE /api/teams/{teamName}/datasets/{dataSetName}/filters Deletes all filters from the filter cache. POST /api/teams/{teamName}/datasets/{dataSetName}/filters/load Pre-loads all registered filters in the background. GET /api/teams/{teamName}/datasets/{dataSetName}/filters/count Returns the number of filters currently registered in the filter cache. ## Search POST /api/teams/{teamName}/datasets/{dataSetName}/search Search will validate the search query and return the search result. POST /api/teams/{teamName}/datasets/{dataSetName}/search/vector Searches a single embedding field using approximate nearest-neighbour search. POST /api/teams/{teamName}/datasets/{dataSetName}/search/hybrid Combines text search with embedding nearest-neighbour search and blends scores. ## Statistics GET /api/teams/{teamName}/datasets/{dataSetName}/statistics/overview The window's totals and rates - the dashboard's header numbers. Rates are computed here so every consumer shares the definitions (see the response type). GET /api/teams/{teamName}/datasets/{dataSetName}/statistics/timeseries The per-day series behind the charts: one row per UTC day in the window. Days without events are served as zero rows, so a chart never has holes. GET /api/teams/{teamName}/datasets/{dataSetName}/statistics/queries Top queries in the window: searches, zero-hit count and selects per query text (lowercased). With uncoveredOnly=true, the searches without coverage: those coverage confirmed nothing for, how often a result was chosen anyway, and the document chosen most (mostChosenDocument), which is usually the synonym or spelling to add. order=lowestClickThrough lists the queries searched often and chosen from rarely (highestClickThrough the reverse; both leave out queries searched fewer than five times, where one click reads as 100%), and order=chosenAnyway with uncoveredOnly the fuzzy finds. With zeroHitsOnly=true, the zero-hit report — the searches the dataset could not answer, ordered by how often. GET /api/teams/{teamName}/datasets/{dataSetName}/statistics/queries/documents For one query (text, compared lowercased like the query list): which documents visitors chose from its results, most chosen first, with the position sum and the conversions from the same searches. A query searched often and chosen from rarely is either missing what people want or ranking it too low; which of the two, and whether the choices were the thing itself or a substitute, is read from this list. From the raw rows, so as far back as they are kept (90 days by default). GET /api/teams/{teamName}/datasets/{dataSetName}/statistics/filters Browsing in the window: how often people narrowed by each filter value, with or without text, and how often that came back empty. A combined filter counts once per operand; a range counts by its field, with an empty value. GET /api/teams/{teamName}/datasets/{dataSetName}/statistics/documents Top documents in the window: selects, converts and summed convert value per document key. The keys are the customer's own; titles are looked up by key. GET /api/teams/{teamName}/datasets/{dataSetName}/statistics/subjects/{subject} One subject's lifetime top documents — the personalization read ("top 10 per user"). The subject is whatever the customer sent on the events. DELETE /api/teams/{teamName}/datasets/{dataSetName}/statistics/subjects/{subject} The GDPR erasure of one subject: the subject↔document edge is deleted and the raw rows are anonymised, so aggregate counts stay true while nothing ties them to the subject any more. GET /api/teams/{teamName}/datasets/{dataSetName}/statistics/changes What the dataset's owners changed in the window, oldest first, and the daily document totals: the why beside the numbers (Notes/statistics-design.md, "Change events"). GET /api/teams/{teamName}/datasets/{dataSetName}/statistics/settings The dataset's statistics settings. `recordFilters`: whether each search's filter is stored, which is what the Browsing report counts. On by default. PUT /api/teams/{teamName}/datasets/{dataSetName}/statistics/settings Changes the dataset's statistics settings. Switching `recordFilters` off stops recording filters; what is already recorded stays until it ages out or the statistics are erased. Team admin, Full key: a privacy setting. DELETE /api/teams/{teamName}/datasets/{dataSetName}/statistics Deletes every statistics row of the dataset. This is the explicit purge: statistics survive dataset delete/recreate, so starting clean is a choice made here. ## Synonyms GET /api/teams/{teamName}/datasets/{dataSetName}/synonyms Returns the dataset's synonym list, or a `null` body when it has none — always 200. The list widens every search on this dataset: a query word that matches an entry gets that entry's terms appended to the query text before scoring. One list per dataset — there is no name to supply, and no other dataset is affected. PUT /api/teams/{teamName}/datasets/{dataSetName}/synonyms Sets the dataset's synonym list, replacing any list it already had. Send a body of `null` to remove the list — the dataset then searches without synonyms again. Takes effect on the very next search: synonyms are applied to the query text, so nothing is re-indexed and the dataset does not have to be in any particular state. The list is stored with the dataset, so it survives a restart and follows the dataset if it is transferred to another team. Note that widening a query lowers Coverage scores in proportion to how much text is added — see the remarks on `Indx.Api.SynonymList`. ## Schemas ### BoostCondition One condition of a boost rule: a filterable field matched either by an exact IndxServer.Models.BoostCondition.Value (string/keyword fields) or by a numeric IndxServer.Models.BoostCondition.Min/IndxServer.Models.BoostCondition.Max range. Exactly one of the two forms is used: if IndxServer.Models.BoostCondition.Min or IndxServer.Models.BoostCondition.Max is set it's a range, otherwise IndxServer.Models.BoostCondition.Value is an exact match. field: string value: string min: double max: double isRange: boolean ### BoostProxy boostStrength: int32 filterProxy: object ### BoostRule A persisted, per-dataset ranking rule: when a search has `enableBoost` on, documents matching this rule's combined condition get their score lifted by IndxServer.Models.BoostRule.Strength. Rules are managed in the UI and applied server-side; see `BoostRuleStore`. name: string enabled: boolean conditions: object[] join: int32 strength: int32 activeFrom: date activeUntil: date ### ChangePasswordRequest Body of POST /api/changePassword. Used to rotate the seeded admin's initial password (set at deployment time) before any other API calls can be made. currentPassword: string newPassword: string ### CombinedFilterProxy a: object b: object useAndOperation: boolean ### ConvertEventRequest The convert event: whatever the customer considers valuable (order, add-to-cart, …). Type is the customer's own name for it. Value/Currency/Quantity are optional commerce magnitudes. QueryId is optional — a conversion may happen long after the search or from another channel, and still counts on the document. Count as for IndxServer.Models.SelectEventRequest. queryId: string documentKey: int64 type: string value: double currency: string quantity: int64 subject: string count: boolean ### CoverageSetup includePatternMatches: boolean levenshteinMaxWordSize: int32 (default: 20) minWordSize: int32 (default: 2) truncateWordHitLimit: int32 (default: 1) truncateWordHitTolerance: int32 (default: 0) coverWholeQuery: boolean (default: true) coverWholeWords: boolean (default: true) coverFuzzyWords: boolean (default: true) coverJoinedWords: boolean (default: true) coverPrefixSuffix: boolean (default: true) truncate: boolean (default: true) truncationScore: int32 (default: 65024) ### FieldProxy fieldName: string fieldType: string isArray: boolean optional: boolean sampleValue: string searchable: boolean filterable: boolean facetable: boolean sortable: boolean wordIndexing: boolean embeddable: boolean preloadFilters: boolean weight: float bM25b: float bM25k1: float highResolution: boolean ### FilterFieldUpdateProxy filter: object fieldName: string value: any ### FilterProxy hashString: string ### HybridQueryProxy text: string maxNumberOfRecordsToReturn: int32 (default: 10) filter: object timeOutLimitMilliseconds: int32 (default: 1000) embeddingField: string vector: float[] alpha: float (default: 0.5) ### LicenseInfo description: string documentLimit: int32 documentLimitExceeded: boolean expirationDate: date-time licenseFileFound: boolean licensed: boolean licensedTo: string licenseFileName: string type: string validLicense: boolean ### ParseResult token: string progressPercent: int32 recordIndex: int32 isEndOfStream: boolean errorType: int32 errorMessage: string lineNumber: int64 bytePositionInLine: int64 jsonPath: string wasSanitized: boolean originalToken: string ### ProblemDetails type: string title: string status: int32 detail: string instance: string ### ProcessError source: string message: string parseError: object timeStampUtc: date-time ### ProcessErrorCount error: object count: int32 ### QueryProxy boosts: object[] (default: null) coverageDepth: int32 (default: 500) coverageSetup: object enableBoost: boolean (default: false) enableCoverage: boolean (default: true) enableFacets: boolean (default: false) fieldBoosts: object (default: null) filter: object logPrefix: string (default: null) maxNumberOfRecordsToReturn: int32 (default: 30) removeDuplicates: boolean (default: true) sortAscending: boolean (default: false) sortBy: string (default: null) text: string timeOutLimitMilliseconds: int32 (default: 1000) ### RangeFilterProxy fieldName: string lowerLimit: double upperLimit: double ### SelectEventRequest The select event: the user chose a result. QueryId is the value the search response returned in its `Indx-Query-Id` header; it is optional and opaque — an unknown or expired id still counts on the document, it just finds no search to join. Position is 1-based in the result list. Subject is the optional general identity: a user, a session or a customer-defined segment. Count is false for a select that is not a visitor's (a probe, a test); a select on a search that was not counted is not counted either, whatever it says. See Notes/statistics-design.md. queryId: string documentKey: int64 position: int32 subject: string count: boolean ### StatisticsSettings A dataset's statistics settings (GET/PUT …/statistics/settings). recordFilters: boolean ### StringSingleValueTuple ### SynonymEntry direction: int32 source: string terms: string[] ### SynonymList name: string description: string updatedUtc: date-time entries: object[] ### SystemStatus documentCount: int32 errorMessage: string invalidArgument: boolean invalidDataSetName: boolean invalidState: boolean licenseInfo: object recoverableErrors: object[] searchCounter: int32 secondsToIndex: double systemState: int32 timeOfInstanceCreation: date-time timeOfLastIndexBuild: date-time tooLongSearchText: boolean indexedTextTruncated: boolean unrecoverableErrors: object[] version: string ### UpdateFieldProxy fieldName: string value: any ### ValueFilterProxy fieldName: string value: any isCaseSensitive: boolean ### VectorQueryProxy fieldName: string vector: float[] maxResults: int32 (default: 10) filter: object