Skip to main content

Serene Docs Search

Serene Docs Search is a self-hosted documentation search application powered by SereneDB. It combines instant BM25 full-text search, optional vector search, and optional streamed AI answers with citations. The browser widget talks only to a small search backend; that backend pulls and indexes your Git repository, local folder, website, or S3-compatible bucket into SereneDB.

The configurator above opens the same setup wizard used by the Search docs widget. It keeps an unfinished draft in this browser, generates the deployment artifacts with the package's real generator, and does not change the search configuration for this documentation site.

Architecture

Git / folder / website / S3  ── pulls ──────────────────┐

browser widget ── HTTP /v1/search, /v1/ask ──> search backend ──> SereneDB

The generated stack contains SereneDB and serenedb/docs-search-backend:latest. Depending on your choices, it also adds Ollama and/or serenedb/docs-search-mcp:latest. The MCP service is optional and is only for external AI agents; the widget and its Ask AI tab do not require it.

Configure and deploy

  1. Select a source: Git repository, mounted folder, live website, or S3-compatible bucket.
  2. Select file formats and parsing rules. Markdown can be indexed as a whole file or split into linkable heading sections; HTML can be scoped with CSS selectors.
  3. Choose full-text or hybrid search. Hybrid search requires an embeddings provider. AI answers and the MCP server are independent opt-ins.
  4. Choose a sync mode: Git commit checks, scheduled polling, or a webhook from CI.
  5. Select Generate deploy files. Download serene-search.config.json and copy the displayed docker-compose.yml.
  6. Put both files in one directory, export any API-key variables referenced by the config, and start the stack:
docker compose up -d

The compose generator creates a random SERENE_SEARCH_TOKEN for administrative setup and sync endpoints. Keep that token on the backend; public search, health, and Ask AI requests do not require it. For a local-folder source, the compose file mounts the path selected in the wizard read-only at /data/docs, and the downloaded config is rewritten to use that in-container path.

Back in the wizard, enter the backend URL (by default http://localhost:7700) and generated token, select Test connection, then start the initial index build. After indexing finishes, the widget saves that connection in browser storage.

Install the widget

React

npm install @serenedb/docs-search-react@latest
import { SereneDocsSearch } from "@serenedb/docs-search-react";
import "@serenedb/docs-search-react/styles.css";

export function DocsSearch() {
return (
<SereneDocsSearch
backendUrl="https://search.example.com"
mcp={{
endpoint: "https://mcp.example.com/mcp",
serverName: "product-docs",
}}
navigate={(url) => router.push(url)}
sections={[
{
id: "docs",
label: "Docs",
match: { urls: ["https://docs.example.com/**"] },
},
{
id: "blog",
label: "Blog",
match: { urls: ["https://blog.example.com/**"] },
},
]}
/>
);
}

Set backendUrl in the production widget so visitors go directly to search. Do not send the administrative token to end users. Omit backendUrl only for an installer UI that should expose first-run setup.

Script tag

<link
rel="stylesheet"
href="https://unpkg.com/@serenedb/docs-search-embed@latest/dist/serene-docs-search.css"
>
<script src="https://unpkg.com/@serenedb/docs-search-embed@latest/dist/serene-docs-search.js"></script>
<script>
SereneDocsSearch.init({
container: "#docs-search",
backendUrl: "https://search.example.com",
});
</script>

The React component supports theme, hotkey, suggestions, limit, navigate, transformUrl, sections, contextUrl, mcp, open/onOpenChange, and a headless useSereneDocsSearch() hook in addition to the minimal example above. Explicit sections props override rules returned by the configured backend.

MCP tab

The search modal includes Search, optional Ask AI, and MCP tabs. When MCP is active, a compact Codex / Claude selector appears at the right of that same tab row and the panel displays one copyable direct-HTTP CLI command at a time. Pass the public Streamable HTTP endpoint explicitly:

<SereneDocsSearch
backendUrl="https://search.example.com"
mcp={{
endpoint: "https://mcp.example.com/mcp",
serverName: "product-docs",
}}
/>

Choose a client, copy the single command, run it in a terminal, then reopen the client and run /mcp. Codex receives:

codex mcp add product-docs --url https://mcp.example.com/mcp

Claude receives the analogous direct HTTP command:

claude mcp add --transport http product-docs https://mcp.example.com/mcp

mcp.endpoint must be the complete public /mcp URL. Without it, the tab shows a concise configuration-needed state; it never derives an endpoint from backendUrl or offers a local package wrapper. mcp.serverName defaults to serene-docs. Pass mcp={false} to hide the tab. This widget prop is separate from mcp.enabled in serene-search.config.json: the latter adds the optional port-7710 HTTP service to generated compose.

This site's production integration passes https://api.serenedb.com/mcp with server name serenedb-docs, so its Codex command is:

codex mcp add serenedb-docs --url https://api.serenedb.com/mcp

Generated files

docker-compose.yml

The generated compose file always defines:

  • serenedb/serenedb:latest, with a persistent serene-data volume;
  • serenedb/docs-search-backend:latest, with the config mounted read-only at /etc/serene/config.json;
  • the backend port from server.port (default 7700) and a generated SERENE_SEARCH_TOKEN.

It conditionally defines:

  • a read-only /data/docs mount for a folder source;
  • ollama/ollama:latest and ollama-data when an AI provider points to http://ollama:11434;
  • serenedb/docs-search-mcp:latest on port 7710 when mcp.enabled is true;
  • environment-variable forwarding for provider keys written as ${ENV_VAR}.

serene-search.config.json

This is JSON (comments are not allowed), has schema version 1, and is read from /etc/serene/config.json by default. The following reference covers every field in the current SereneSearchConfig type, including manual-only tuning fields that the wizard does not expose.

Config reference

“Omitted” means that the generator leaves the field out. An “effective default” is supplied by the backend when the field is absent.

Top level

FieldTypeGenerated/effective defaultPurpose
versionliteral 11Config schema version; required.
projectstringomittedDisplay name returned by the health endpoint.
sourceSourceGit source selected initiallyWhere documents are pulled from; required.
contentContentConfiggeneratedParsing, filtering, and result URL mapping.
searchSearchTypeConfig{ "type": "hybrid" }Retrieval and ranking.
aiAiConfig{ "enabled": false }Embeddings and Ask AI providers.
syncSyncConfiggeneratedRefresh scheduling and snapshots.
serverServerConfig{ "port": 7700 }Search backend HTTP settings.
serenedbSereneDBConfighost serenedb, port 7890, table serene_docs_sectionsSereneDB target metadata and indexed table name.
mcpMcpConfigomitted (disabled)Optional MCP service in generated compose.

Source

source.type is required and selects one of four object shapes.

FieldTypeWizard defaultPurpose
source.type"git" | "folder" | "site" | "bucket""git"Source implementation.
source.url (Git)stringempty; requiredHTTPS clone URL.
source.branchstring"main"Branch to clone and watch.
source.commitstringomittedPin an exact commit. A pin prevents meaningful commit-watch updates.
source.subdirstring | string[]omittedLimit a Git source to directories and/or individual files. The wizard accepts a comma-separated string.
source.path (folder)string"./docs" in compose; /data/docs in downloaded configBackend-visible directory. The generated compose mounts it read-only.
source.url (site)stringempty; required HTTP(S) URLCrawl start URL.
source.depthnumber | "all"2Maximum link depth; "all" removes the limit.
source.sitemapbooleantrueAlso seed the crawl from sitemap.xml.
source.uri (bucket)stringempty; required s3://… URIS3 bucket and optional prefix.
source.endpointstringomittedCustom S3-compatible endpoint, for example R2 or MinIO.
source.regionstringomittedBucket region.

Bucket credentials come from AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in the backend environment. Git credentials likewise belong in the backend environment or its read-only deploy key, never in browser config.

Content

FieldTypeGenerated/effective defaultPurpose
content.extensionsstring[][".md", ".mdx"]File extensions to index. The wizard also offers .html, .rst, .txt, .ipynb, and .pdf. Required and non-empty.
content.excludestring[]["**/node_modules/**"]Glob patterns, or excluded paths for a site crawl.
content.markdown.mode"split" | "whole""split"Create one result section per heading, or one row per file.
content.markdown.depthinteger 1..6omitted; effective 4Deepest heading level that opens a separate section.
content.html.selectorsstring"article, main"CSS selectors that scope HTML extraction.
content.html.tagsstring[]h1h4, p, li, pre, code, tableElements that become indexed sections.
content.html.excludeSelectorsstringomittedCSS selectors removed before extraction.
content.urlMapping.baseUrlstringomittedPublic site prefix or absolute origin, for example /docs or https://docs.example.com.
content.urlMapping.stripPrefixstringomittedSource path prefix removed before joining with baseUrl.
content.urlMapping.stripExtensionsbooleantrueMap quick-start.md to quick-start.
content.urlMapping.indexFilesstring[]["index", "README"]Basenames that map to their directory URL. Matching is case-insensitive.
content.urlMapping.rulesUrlMappingRule[]omittedOrdered per-source-path mapping overrides for a corpus published on multiple sites. First match glob wins; omitted rule fields inherit the shared urlMapping values.
content.urlMapping.rules[].matchstringrequired per ruleGlob matched against the indexed source path, for example docs/**.
content.urlMapping.rules[].baseUrlstringinheritedPublic prefix or absolute origin for matching files.
content.urlMapping.rules[].stripPrefixstringinheritedMatching source prefix removed before joining the file path to baseUrl.

For website sources, pages are always parsed as HTML; the crawl URL and HTML selectors define the corpus. For file-based sources, make baseUrl and stripPrefix match the URLs your documentation site actually serves, otherwise a result may point to the source path instead of the published page. The setup widget exposes these ordered rules under Content → Per-path public sites, so a single exported corpus can map blog/** to a blog domain and the remaining ** files to a docs domain.

FieldTypeGenerated/effective defaultPurpose
search.type"fulltext" | "hybrid""hybrid"BM25 only, or BM25 plus vector similarity. Hybrid requires ai.embeddings.
search.vectorDistanceThresholdnumber from 0 to 2omitted; no cut-offDrop semantic candidates above a model-specific cosine distance. The source suggests about 0.45 for nomic-embed-text and 0.8 for all-minilm; calibrate on your corpus.
search.pins{ match: string, url: string }[]omittedPin a URL when a case-insensitive query pattern matches; * is a wildcard.
search.rrf.vectorWeightnumber0.7Weight of the vector branch during reciprocal-rank fusion.
search.rrf.knumber60RRF rank constant.
search.rrf.windownumber50Maximum candidates taken from each branch before fusion.
search.stemmingbooleantrueEnable Snowball stemming.
search.synonymsstringomittedSolr-format rules, one per line: db, database or k8s => kubernetes. Applied to indexed text and queries.
search.stopwordsstring[]["the", "a", "an", "this", "these", "those"]Terms removed during indexing and querying. Supplying an array replaces the built-in list.
search.sectionsSearchSectionConfig[]omittedOrdered UI result groups. First matching section owns a result; the section matching the current browser location is rendered first.
search.sections[].idstringrequired; unique in wizardStable machine-readable group key.
search.sections[].labelstringrequiredVisible heading above the group.
search.sections[].match.urlsstring[]omittedGlobs matched against the final result URL and current browser URL.
search.sections[].match.pathsstring[]omittedGlobs matched against the final URL pathname and indexed source path. At least one URL or path pattern is required.

Contextual result sections

Sections affect presentation order, not retrieval scores. The widget makes a stable partition of the backend result list: relevance order is preserved inside each section, empty sections are omitted, and hits matching no configured rule appear under Other results. Section headings appear only when the current result set contains at least two non-empty groups; a single-section result list stays visually ungrouped. If sections are absent, the previous ungrouped built-in UI and API behavior remain unchanged.

Patterns support * within one path segment and ** across segments. Rules are tested in config order, so put nested or narrow paths before a broad domain rule.

For separate documentation and blog origins:

{
"search": {
"type": "fulltext",
"sections": [
{
"id": "docs",
"label": "Docs",
"match": {
"urls": ["https://docs.example.com/**"],
"paths": ["docs/**"]
}
},
{
"id": "blog",
"label": "Blog",
"match": {
"urls": ["https://blog.example.com/**"],
"paths": ["blog/**"]
}
}
]
}
}

For nested areas inside Docs, list the specific groups before the general Docs fallback:

{
"search": {
"type": "fulltext",
"sections": [
{
"id": "installation",
"label": "Installation",
"match": {
"paths": ["/installation/**", "docs/installation/**"]
}
},
{
"id": "sql",
"label": "SQL",
"match": {
"paths": ["/sql/**", "docs/sql/**"]
}
},
{
"id": "docs",
"label": "Docs",
"match": {
"urls": ["https://docs.example.com/**"]
}
}
]
}
}

The setup wizard exposes the same label, id, URL-glob, and path-glob fields on its Search step. The downloaded serene-search.config.json, backend health response, React component, headless hook, and script-tag bundle all use this single schema.

AI and providers

ai.answers powers the Ask AI tab. ai.embeddings vectorizes indexed sections and hybrid queries. They may use different providers.

FieldTypeGenerated/effective defaultPurpose
ai.enabledbooleanfalseShow and enable Ask AI. It does not control hybrid embeddings.
ai.answersAiProvideromitted; added when Ask AI is enabledChat-completions provider for cited answers. Required when ai.enabled is true.
ai.embeddingsAiProvideromitted initially; required for hybridEmbeddings provider used at index and query time.
ai.systemPromptstringAnswer from the indexed docs only. Cite sources. If unsure, say so. when Ask AI is enabledSystem instruction for cited answers.
kind"openai" | "ollama""openai" in the wizardProvider protocol. openai means any OpenAI-compatible API.
baseUrlstringOpenAI: https://api.openai.com/v1; Ollama: http://ollama:11434Provider API root. The Ollama default causes compose to add an Ollama container.
apiKeystringomittedLiteral secret or an exact ${ENV_VAR} reference expanded by the backend. Prefer the latter.
modelstringanswers: gpt-4o-mini; embeddings: text-embedding-3-smallModel name. Ollama defaults are llama3.2 for answers and nomic-embed-text for embeddings.

The backend expands strings of the exact form ${ENV_VAR} anywhere in the JSON. The compose generator automatically forwards provider apiKey variables written in that form.

Sync

FieldTypeGenerated/effective defaultPurpose
sync.mode"commits" | "poll" | "webhook"Git: "commits"; other sources: "poll"Watch a Git ref, poll the source, or wait for POST /v1/reindex.
sync.intervalstring"1h"Commit-check or polling interval. The parser accepts positive integer values ending in s, m, h, or d; the wizard offers 15m, 1h, 6h, and 24h.
sync.snapshotsbooleantrueHash content, skip unchanged sections, and prune deleted ones.

Server, SereneDB, and MCP

FieldTypeGenerated/effective defaultPurpose
server.portnumber7700Port written into generated compose and suggested backend URL. When changing it manually, also set the backend PORT environment variable to the same value.
serenedb.hoststring"serenedb"Declared in the config schema and emitted by the generator. The current backend reads the actual host from SERENEDB_HOST.
serenedb.portnumber7890Declared/emitted value; the current backend reads SERENEDB_PORT.
serenedb.databasestringomitted; runtime "postgres"Schema field; the current backend reads SERENEDB_DATABASE.
serenedb.userstringomitted; runtime "postgres"Schema field; the current backend reads SERENEDB_USER.
serenedb.passwordstringomittedSchema field; the current backend reads SERENEDB_PASSWORD. Keep it out of JSON.
serenedb.tablestring"serene_docs_sections"Base name for indexed sections and related search tables. This is the SereneDB setting currently applied from JSON.
mcp.enabledbooleanomitted/falseAdd the optional MCP container on port 7710 to generated compose.

The backend also supports SERENE_SEARCH_CONFIG (default /etc/serene/config.json), SERENE_SEARCH_STATE (default /var/lib/serene-search), SERENE_SEARCH_TOKEN, SERENEDB_POOL_MAX (default 20), SERENEDB_POOL_CONNECT_TIMEOUT_MS (default 10000), and SERENEDB_STATEMENT_TIMEOUT_MS (default 30000).

Manual config example

This valid example exercises the current nested provider schema and the manual relevance controls. Replace its URLs and models for your environment.

{
"version": 1,
"project": "Acme documentation",
"source": {
"type": "git",
"url": "https://github.com/acme/docs",
"branch": "main",
"subdir": ["docs", "blog", "guides/faq.md"]
},
"content": {
"extensions": [".md", ".mdx", ".html"],
"exclude": ["**/node_modules/**", "**/CHANGELOG.md"],
"markdown": {
"mode": "split",
"depth": 4
},
"html": {
"selectors": "article, main .content",
"tags": ["h1", "h2", "h3", "h4", "p", "li", "pre", "code", "table"],
"excludeSelectors": "nav, .badge, pre.language-plaintext"
},
"urlMapping": {
"stripExtensions": true,
"indexFiles": ["index", "README"],
"rules": [
{
"match": "docs/**",
"baseUrl": "https://docs.example.com",
"stripPrefix": "docs/"
},
{
"match": "blog/**",
"baseUrl": "https://blog.example.com",
"stripPrefix": "blog/"
}
]
}
},
"search": {
"type": "hybrid",
"vectorDistanceThreshold": 0.45,
"pins": [
{ "match": "install*", "url": "https://docs.example.com/quick-start" }
],
"rrf": {
"vectorWeight": 0.7,
"k": 60,
"window": 50
},
"stemming": true,
"synonyms": "db, database\nk8s => kubernetes",
"stopwords": ["the", "a", "an", "this", "these", "those"],
"sections": [
{
"id": "docs",
"label": "Docs",
"match": {
"urls": ["https://docs.example.com/**"],
"paths": ["docs/**"]
}
},
{
"id": "blog",
"label": "Blog",
"match": {
"urls": ["https://blog.example.com/**"],
"paths": ["blog/**"]
}
}
]
},
"ai": {
"enabled": true,
"answers": {
"kind": "openai",
"baseUrl": "https://api.openai.com/v1",
"apiKey": "${OPENAI_API_KEY}",
"model": "gpt-4o-mini"
},
"embeddings": {
"kind": "ollama",
"baseUrl": "http://ollama:11434",
"model": "nomic-embed-text"
},
"systemPrompt": "Answer from the indexed docs only. Cite sources. If unsure, say so."
},
"sync": {
"mode": "commits",
"interval": "1h",
"snapshots": true
},
"server": {
"port": 7700
},
"serenedb": {
"host": "serenedb",
"port": 7890,
"table": "serene_docs_sections"
},
"mcp": {
"enabled": true
}
}

With the file saved next to the copied compose file:

export OPENAI_API_KEY="replace-me"
docker compose up -d
curl http://localhost:7700/v1/health

Operations and security

  • GET /v1/health, POST /v1/search, POST /v1/ask, and section reads are public endpoints intended for the browser widget.
  • PUT /v1/config, POST /v1/sync, and POST /v1/reindex are administrative and use SERENE_SEARCH_TOKEN.
  • The backend stores a config pushed by the wizard under SERENE_SEARCH_STATE if no mounted config file takes precedence.
  • API keys and bucket credentials stay in backend environment variables. Never expose the admin token or provider keys in a public frontend bundle.
  • If a relevance setting changes the effective schema or analyzer, the backend rebuilds the corresponding SereneDB search structures during sync.

Return to Apps & Clients, or continue with the SereneDB quick start.