diff --git a/docs/tutorials/python/search.md b/docs/tutorials/python/search.md
new file mode 100644
index 000000000..81197e8f6
--- /dev/null
+++ b/docs/tutorials/python/search.md
@@ -0,0 +1,453 @@
+# Search Indexes
+
+A [SearchIndex][synapseclient.models.SearchIndex] is a Synapse entity whose content is
+defined by a Synapse SQL query. Synapse builds an OpenSearch index from the rows that
+query returns, which gives you full-text search, relevance ranking, faceting, and
+autocomplete over a table or view.
+
+This is a different way of asking questions than a
+[Table](table.md) or a [Materialized View](materializedview.md). A table is queried with
+Synapse SQL and answers "which rows match these exact conditions?". A search index is
+queried with the
+[OpenSearch Query DSL](https://docs.opensearch.org/latest/query-dsl/) and answers
+"which rows are most relevant to this text?" — matching word stems, ignoring
+punctuation and case, ranking the best matches first, and counting how many rows fall
+into each category. It is what you would put behind a search box.
+
+This tutorial will walk you through creating a search index and querying it with the
+Synapse Python client.
+
+## Tutorial Purpose
+In this tutorial, you will:
+
+1. Log in, get your project, and create a table to index
+2. Set up the index — create a SearchIndex, and optionally tune matching with synonyms
+ and analyzers
+3. Use the search index
+ 1. Run a full-text search
+ 2. Highlight where the match happened
+ 3. Combine scored clauses with unscored filters, and sort the results
+ 4. Count facets with aggregations
+ 5. Power a type-ahead box with autocomplete
+ 6. Paginate through results
+
+## Prerequisites
+* This tutorial assumes that you have a Synapse project.
+* Pandas must also be installed as shown in the [installation documentation](../installation.md).
+
+## 1. Log in, get your project, and create a table to index
+
+A search index is always defined over an existing table-like entity, so we first create
+a small table of study summaries to search over.
+
+You will want to replace `"My uniquely named project about Alzheimer's Disease"` with
+the name of your project.
+
+```python
+--8<-- "docs/tutorials/python/tutorial_scripts/search.py:setup"
+```
+
+## 2. Setup
+
+!!! warning "Restricted to Sage Bionetworks employees"
+ Everything in this section — creating a SearchIndex, and the synonym and analyzer
+ resources that configure one — is restricted to Sage Bionetworks employees. If that
+ is not you, read on for how indexes are built and configured, then pick up at
+ step 3 against an index someone has already created and shared with you.
+
+### 2.1 Create a SearchIndex entity
+
+The `defining_sql` decides which rows and columns are indexed. It must reference exactly
+one table-like entity — unlike a Materialized View, JOIN and UNION across several
+entities are not supported.
+
+If you need to search across several tables, build a
+[Materialized View](materializedview.md) first and index that.
+
+Any of these can be the source, whichever one the SQL selects from:
+
+* a [Table][synapseclient.models.Table]
+* an [EntityView][synapseclient.models.EntityView]
+* a [DatasetCollection][synapseclient.models.DatasetCollection]
+* a [MaterializedView][synapseclient.models.MaterializedView]
+
+The SQL can pin a specific version of the source (`SELECT * FROM syn12345.7`), select a
+subset of columns, and carry `WHERE`, `ORDER BY`, and `LIMIT` clauses — only those rows
+and columns end up in the index.
+
+Storing the search index entity returns as soon as Synapse has accepted it, but the OpenSearch index
+behind it is built in the background.
+
+```python
+--8<-- "docs/tutorials/python/tutorial_scripts/search.py:create_index"
+```
+
+
+ Creating the index should look like:
+
+```
+Created SearchIndex with ID: syn68123456
+Index syn68123456 is queryable with 6 rows
+```
+
+
+**Note**: The index tracks its source. When rows in the underlying table change, the
+index is updated in the background — you do not need to re-store the SearchIndex.
+
+### 2.2 Advanced: Tune matching with synonyms and analyzers
+
+!!! warning "Permanent"
+ The REST API has no delete endpoint for any of the resources below. Once created, a
+ SynonymSet, TextAnalyzer, ColumnAnalyzerOverride, or SearchConfiguration cannot be
+ removed, and its owning Organization can no longer be deleted either. Choose names
+ deliberately.
+
+Everything in this tutorial relies on how each column was analyzed when the index was built: how text is split into tokens, which tokens are dropped, and how they are normalized. There are four `Organization`-scoped resources that let you control that:
+
+* [SynonymSet][synapseclient.models.SynonymSet] — terms that should be treated as equivalent, so someone searching `AD` finds abstracts that say "Alzheimer's disease"
+* [TextAnalyzer][synapseclient.models.TextAnalyzer] — a named OpenSearch analyzer: a tokenizer plus a chain of token filters, which may reference a SynonymSet
+* [ColumnAnalyzerOverride][synapseclient.models.ColumnAnalyzerOverride] — a reusable bundle assigning specific analyzers to specific columns
+* [SearchConfiguration][synapseclient.models.SearchConfiguration] — bundles a default analyzer with any column overrides; this is what a SearchIndex actually points at
+
+Each resource belongs to an [Organization][synapseclient.models.Organization] and is
+referenced from another resource by its qualified name,
+`{organization_name}-{name}`, written as `{"$ref": "my.org-my_analyzer"}`.
+
+
+Note where the synonym filter goes below. The analyzer declares both a `default` chain,
+used when rows are indexed, and a `default_search` chain, used when a query is analyzed.
+Putting the synonyms only in `default_search` expands the incoming query instead of
+storing every synonym for every row.
+
+!!! tip "Write your synonyms in lowercase"
+ Token filters run in the order they are listed, and `lowercase` comes before the
+ synonym filter in the chain below. By the time a search term reaches the synonym
+ filter it has already been lowercased, so an entry written as `AD => Alzheimer's
+ disease` will never be matched and never expand. Lowercase every entry in the
+ SynonymSet — `ad => alzheimer's disease` — and the abbreviation still works no
+ matter how the person typed it.
+
+```python
+--8<-- "docs/tutorials/python/tutorial_scripts/search.py:search_configuration"
+```
+
+A SearchIndex resolves its configuration when the index is built, so the configuration
+has to exist before the index that uses it — that is why this comes before you run any
+queries. Either point the index straight at a configuration with
+`search_configuration_id`, or bind a configuration to the parent folder or project — an
+index with no `search_configuration_id` of its own walks up the entity hierarchy and
+uses the first [SearchConfigBinding][synapseclient.models.SearchConfigBinding] it finds,
+falling back to the platform defaults.
+
+```python
+--8<-- "docs/tutorials/python/tutorial_scripts/search.py:apply_search_configuration"
+```
+
+
+ Searching the abbreviation against the new index should look like:
+```
+Created SearchIndex syn68123457 using config 4321
+Index syn68123457 is queryable with 6 rows
+Bound configuration 4321 to syn12345678
+Abstracts matching the abbreviation 'AD':
+total_hits=3, returned=3
+ {'study_name': 'ROSMAP Cortex Proteomics', 'diagnosis': "Alzheimer's Disease"}
+ {'study_name': 'MSBB RNA Sequencing', 'diagnosis': "Alzheimer's Disease"}
+ {'study_name': 'Mayo Clinic Whole Genome', 'diagnosis': "Alzheimer's Disease"}
+```
+
+
+## 3. Using a search index
+
+Everything from here on is querying an index that already exists, which does not require
+any special permissions — read access to the SearchIndex entity is enough.
+
+### 3.1 Run a full-text search
+
+A [`match`](https://docs.opensearch.org/latest/query-dsl/full-text/match/) clause is the
+workhorse of full-text search: the text you pass is analyzed the same way the column was
+analyzed, so `"alzheimer"` matches `"Alzheimer's disease"`. Every clause kind Synapse
+accepts is listed on [Query][synapseclient.models.search_dsl.Query].
+
+By default a hit carries every indexed column. `source` narrows that down, and
+`response_parts` asks for extras beyond the hits themselves — here the total hit count
+and the columns each hit carries.
+
+Adding `fuzziness` to a `match` clause buys typo tolerance: the term someone typed will
+still match a term in the index that is a few single-character edits away. `"AUTO"`
+scales the allowance with term length, and `prefix_length` pins the first few characters
+so unrelated short words don't start matching each other. Both options are available on
+`match`, `match_bool_prefix`, and `multi_match`.
+
+```python
+--8<-- "docs/tutorials/python/tutorial_scripts/search.py:full_text_search"
+```
+
+
+ The results of your searches should look like:
+
+```
+Abstracts mentioning Alzheimer's:
+columns: ['study_name', 'diagnosis']
+total_hits=3, returned=3
+ {'study_name': 'ROSMAP Cortex Proteomics', 'diagnosis': "Alzheimer's Disease"}
+ {'study_name': 'MSBB RNA Sequencing', 'diagnosis': "Alzheimer's Disease"}
+ {'study_name': 'Mayo Clinic Whole Genome', 'diagnosis': "Alzheimer's Disease"}
+
+Anything mentioning tau:
+total_hits=1, returned=1
+ {'study_name': 'MCI Plasma Biomarkers'}
+
+Misspelling 'sequencng' still finds:
+total_hits=3, returned=3
+ {'study_name': 'MSBB RNA Sequencing'}
+ {'study_name': 'Mayo Clinic Whole Genome'}
+ {'study_name': 'Healthy Aging Single Cell Atlas'}
+```
+
+
+Hits come back ranked by relevance, and a score can be returned
+[`hit.score`][synapseclient.models.SearchHit].
+
+### 3.2 Highlight where the match happened
+
+A result list is much easier to read when it shows the matching text in context.
+`highlight` returns short fragments of the matched columns with the matching terms
+wrapped in `` tags.
+
+```python
+--8<-- "docs/tutorials/python/tutorial_scripts/search.py:columns_and_highlights"
+```
+
+
+ The result of your highlighted search should look like:
+
+```
+Studies that sequenced something:
+ {'study_name': 'MSBB RNA Sequencing', 'assay': 'rnaSeq'}
+ abstract: ['Bulk RNA sequencing across four brain regions in a cohort']
+ {'study_name': 'Mayo Clinic Whole Genome', 'assay': 'wholeGenomeSeq'}
+ abstract: ['Whole genome sequencing of temporal cortex samples from']
+ {'study_name': 'Healthy Aging Single Cell Atlas', 'assay': 'snrnaSeq'}
+ abstract: ['Single nucleus RNA sequencing of hippocampus from']
+```
+
+
+**Note**: Highlighting, like relevance scoring, depends on the column being indexed as
+analyzed text. Step 2.2 covers how to control that with a
+[SearchConfiguration][synapseclient.models.SearchConfiguration].
+
+### 3.3 Combine scored clauses with unscored filters, and sort the results
+
+A [`bool`](https://docs.opensearch.org/latest/query-dsl/compound/bool/) clause is how
+you build a real search request out of several conditions:
+
+* `must` clauses have to match and **do** contribute to the relevance score
+* `filter` and `must_not` clauses have to match (or not match) but **do not** affect
+ the score — use these for hard constraints like a numeric cutoff
+* `should` clauses boost the rows that match them without excluding the rows that don't
+
+Passing `sort` replaces relevance ranking with an ordering of your choosing. Only column
+and `_score` sorts are accepted.
+
+```python
+--8<-- "docs/tutorials/python/tutorial_scripts/search.py:filters_and_sorting"
+```
+
+
+ The result of your filtered search should look like:
+
+```
+Sequencing studies with at least 200 participants, largest first:
+total_hits=2, returned=2
+ {'study_name': 'Mayo Clinic Whole Genome', 'participant_count': '350'}
+ {'study_name': 'MSBB RNA Sequencing', 'participant_count': '300'}
+```
+
+
+### 3.4 Count facets with aggregations
+
+Aggregations answer "how many rows are there of each kind?" — the counts you see next to
+the checkboxes in a faceted search UI. A
+[`terms`](https://docs.opensearch.org/latest/aggregations/bucket/terms/) aggregation
+produces one bucket per distinct value of a column; metric aggregations like `avg` and
+`stats` summarize a numeric column. Results come back on `aggregation_results` as the
+raw OpenSearch response, with field references rewritten back to your column names.
+
+`post_filter` is what keeps a facet list usable: it narrows the hits *after* the
+aggregations have been computed, so selecting one diagnosis does not make the other
+diagnosis counts disappear.
+
+```python
+--8<-- "docs/tutorials/python/tutorial_scripts/search.py:aggregations"
+```
+
+
+ The result of your faceted search should look like:
+
+```
+Hits after the post filter:
+total_hits=3, returned=3
+ {'study_name': 'ROSMAP Cortex Proteomics', 'diagnosis': "Alzheimer's Disease"}
+ {'study_name': 'MSBB RNA Sequencing', 'diagnosis': "Alzheimer's Disease"}
+ {'study_name': 'Mayo Clinic Whole Genome', 'diagnosis': "Alzheimer's Disease"}
+
+Facet counts across all studies:
+{
+ "by_diagnosis": {
+ "doc_count_error_upper_bound": 0,
+ "sum_other_doc_count": 0,
+ "buckets": [
+ {
+ "key": "Alzheimer's Disease",
+ "doc_count": 3
+ },
+ {
+ "key": "Cognitively Normal",
+ "doc_count": 1
+ },
+ {
+ "key": "Mild Cognitive Impairment",
+ "doc_count": 1
+ },
+ {
+ "key": "Parkinson's Disease",
+ "doc_count": 1
+ }
+ ]
+ },
+ "mean_cohort_size": {
+ "value": 261.6666666666667
+ }
+}
+```
+
+
+### 3.5 Power a type-ahead box with autocomplete
+
+[`autocomplete()`][synapseclient.models.SearchIndex.autocomplete] is a separate,
+synchronous endpoint meant for search-as-you-type: it returns its hits directly instead
+of going through the asynchronous job service, so it is fast enough to call on every
+keystroke. In exchange, it only accepts prefix-style clauses — `prefix`,
+`match_phrase_prefix`, or `match_bool_prefix` — and returns at most 8 hits.
+
+```python
+--8<-- "docs/tutorials/python/tutorial_scripts/search.py:autocomplete"
+```
+
+
+ The result of your autocomplete request should look like:
+```
+Suggestions for 'Mayo Cl':
+ ['Mayo Clinic Whole Genome']
+```
+
+
+### 3.6 Paginated results
+
+A query returns at most 100 hits at a time (25 by default), so anything larger requires
+paging. There are two mechanisms, and they answer different questions — pick by what you are trying to do:
+
+* **`from_` and `size`** jump to an arbitrary position, the way numbered pages in a UI
+ do. Reach for this when you want *the 504th result*.
+* **`search_after`** picks up exactly where the previous page ended. Reach for this when
+ you want to *enumerate every result*. Each response carries `next_search_after`; pass
+ it back unchanged on the next request and leave `from_` unset.
+
+!!! warning "Offset paging stops at 10,000 hits"
+ OpenSearch caps `from_ + size` at its result window, 10,000 by default. Offset
+ paging therefore cannot reach past the 10,000th hit, and a sweep that might run
+ deeper than that has to use `search_after`.
+
+#### Offset paging with `from_` and `size`
+
+Simple, and extracts the results in pages. The cost grows with depth — the server
+collects and discards every hit before the offset — which is both why it is capped at
+10,000 and why it is the wrong tool for sweeping a large index.
+
+```python
+--8<-- "docs/tutorials/python/tutorial_scripts/search.py:pagination_offset"
+```
+
+
+ The result of paging through your index should look like:
+```
+Page starting at offset 0:
+total_hits=6, returned=2
+ {'study_name': 'ROSMAP Cortex Proteomics', 'participant_count': '400'}
+ {'study_name': 'Mayo Clinic Whole Genome', 'participant_count': '350'}
+Page starting at offset 2:
+total_hits=6, returned=2
+ {'study_name': 'MSBB RNA Sequencing', 'participant_count': '300'}
+ {'study_name': 'MCI Plasma Biomarkers', 'participant_count': '220'}
+Page starting at offset 4:
+total_hits=6, returned=2
+ {'study_name': 'Parkinson Comparative Cohort', 'participant_count': '180'}
+ {'study_name': 'Healthy Aging Single Cell Atlas', 'participant_count': '120'}
+```
+
+
+#### Cursor paging with `search_after`
+
+This is the solution if you need every row, and the only option once you are paging past
+the 10,000-hit result window. It has no depth penalty: each request resumes from the
+cursor instead of counting up from the start.
+
+The catch is that `search_after` is a position in a sort order, so the `sort` has to
+place every row unambiguously. If two rows tie on every sort column, a page boundary
+landing between them can skip or repeat rows. Sort on something unique, or append a
+unique column as a final tie-breaker.
+
+```python
+--8<-- "docs/tutorials/python/tutorial_scripts/search.py:pagination_cursor"
+```
+
+
+ The result of walking your index should look like:
+```
+Page 0:
+total_hits=6, returned=2
+ {'study_name': 'ROSMAP Cortex Proteomics', 'participant_count': '400'}
+ {'study_name': 'Mayo Clinic Whole Genome', 'participant_count': '350'}
+Page 1:
+total_hits=6, returned=2
+ {'study_name': 'MSBB RNA Sequencing', 'participant_count': '300'}
+ {'study_name': 'MCI Plasma Biomarkers', 'participant_count': '220'}
+Page 2:
+total_hits=6, returned=2
+ {'study_name': 'Parkinson Comparative Cohort', 'participant_count': '180'}
+ {'study_name': 'Healthy Aging Single Cell Atlas', 'participant_count': '120'}
+```
+
+
+**Note**: Each page is a separate asynchronous job either way, not a cheap follow-up
+GET, so ask for the largest `size` you can use rather than walking a big index in small
+pages.
+
+## Source Code for this Tutorial
+
+
+ Click to show me
+
+```python
+--8<-- "docs/tutorials/python/tutorial_scripts/search.py"
+```
+
+
+## References
+- [SearchIndex][synapseclient.models.SearchIndex]
+- [SearchQuery][synapseclient.models.SearchQuery]
+- [SearchQueryPart][synapseclient.models.SearchQueryPart]
+- [SearchIndexQuery][synapseclient.models.SearchIndexQuery]
+- [SearchHit][synapseclient.models.SearchHit]
+- [Query][synapseclient.models.search_dsl.Query]
+- [SearchConfiguration][synapseclient.models.SearchConfiguration]
+- [TextAnalyzer][synapseclient.models.TextAnalyzer]
+- [SynonymSet][synapseclient.models.SynonymSet]
+- [ColumnAnalyzerOverride][synapseclient.models.ColumnAnalyzerOverride]
+- [SearchConfigBinding][synapseclient.models.SearchConfigBinding]
+- [Organization][synapseclient.models.Organization]
+- [Table][synapseclient.models.Table]
+- [syn.login][synapseclient.Synapse.login]
+- [OpenSearch query DSL](https://docs.opensearch.org/latest/query-dsl/)
+- [OpenSearch aggregations](https://docs.opensearch.org/latest/aggregations/)
diff --git a/docs/tutorials/python/tutorial_scripts/search.py b/docs/tutorials/python/tutorial_scripts/search.py
new file mode 100644
index 000000000..4fbcf37d6
--- /dev/null
+++ b/docs/tutorials/python/tutorial_scripts/search.py
@@ -0,0 +1,540 @@
+"""Here is where you'll find the code for the SearchIndex tutorial."""
+
+# --8<-- [start:setup]
+import json
+
+import pandas as pd
+
+from synapseclient import Synapse
+from synapseclient.models import (
+ Column,
+ ColumnType,
+ Project,
+ SearchIndex,
+ SearchQuery,
+ SearchQueryPart,
+ Table,
+)
+from synapseclient.models.search_dsl import (
+ Aggregation,
+ AvgAggregation,
+ BoolQuery,
+ Highlight,
+ HighlightField,
+ MatchBoolPrefixFieldOptions,
+ MatchFieldOptions,
+ MatchPhraseFieldOptions,
+ MultiMatchQuery,
+ Query,
+ RangeFieldOptions,
+ SourceFilter,
+ TermsAggregation,
+)
+
+# Initialize Synapse client
+syn = Synapse()
+syn.login()
+
+# Get the project where we want to create the search index
+project = Project(name="My uniquely named project about Alzheimer's Disease").get()
+project_id = project.id
+print(f"Got project with ID: {project_id}")
+
+# Create the table that will be indexed
+table = Table(
+ name="Study Summaries",
+ parent_id=project_id,
+ columns=[
+ Column(name="study_name", column_type=ColumnType.STRING),
+ Column(name="abstract", column_type=ColumnType.LARGETEXT),
+ Column(name="diagnosis", column_type=ColumnType.STRING),
+ Column(name="assay", column_type=ColumnType.STRING),
+ Column(name="participant_count", column_type=ColumnType.INTEGER),
+ ],
+).store()
+print(f"Created table with ID: {table.id}")
+
+# Add the rows we are going to search over
+studies = pd.DataFrame(
+ [
+ {
+ "study_name": "ROSMAP Cortex Proteomics",
+ "abstract": "Quantitative proteomics of dorsolateral prefrontal cortex "
+ "from donors with Alzheimer's disease and cognitively normal controls.",
+ "diagnosis": "Alzheimer's Disease",
+ "assay": "TMT quantitation",
+ "participant_count": 400,
+ },
+ {
+ "study_name": "MSBB RNA Sequencing",
+ "abstract": "Bulk RNA sequencing across four brain regions in a cohort "
+ "spanning the full range of Alzheimer's disease neuropathology.",
+ "diagnosis": "Alzheimer's Disease",
+ "assay": "rnaSeq",
+ "participant_count": 300,
+ },
+ {
+ "study_name": "Mayo Clinic Whole Genome",
+ "abstract": "Whole genome sequencing of temporal cortex samples from "
+ "donors with Alzheimer's disease, progressive supranuclear palsy, "
+ "and controls.",
+ "diagnosis": "Alzheimer's Disease",
+ "assay": "wholeGenomeSeq",
+ "participant_count": 350,
+ },
+ {
+ "study_name": "Healthy Aging Single Cell Atlas",
+ "abstract": "Single nucleus RNA sequencing of hippocampus from "
+ "cognitively normal aged donors, establishing a baseline atlas.",
+ "diagnosis": "Cognitively Normal",
+ "assay": "snrnaSeq",
+ "participant_count": 120,
+ },
+ {
+ "study_name": "MCI Plasma Biomarkers",
+ "abstract": "Plasma biomarker panel measuring phosphorylated tau and "
+ "neurofilament light chain in mild cognitive impairment.",
+ "diagnosis": "Mild Cognitive Impairment",
+ "assay": "immunoassay",
+ "participant_count": 220,
+ },
+ {
+ "study_name": "Parkinson Comparative Cohort",
+ "abstract": "Comparative transcriptomic profiling of substantia nigra "
+ "in Parkinson disease versus age-matched controls.",
+ "diagnosis": "Parkinson's Disease",
+ "assay": "rnaSeq",
+ "participant_count": 180,
+ },
+ ]
+)
+table.upsert_rows(values=studies, primary_keys=["study_name"])
+print(f"Stored {len(studies)} rows in {table.id}")
+# --8<-- [end:setup]
+
+# --8<-- [start:create_index]
+# Create a SearchIndex over a single table and wait for it to build.
+index = SearchIndex(
+ name="Study Summaries Search Index",
+ description="Full text search over the study summary table",
+ parent_id=project_id,
+ # The defining SQL must reference exactly one table-like entity
+ defining_sql=f"SELECT * FROM {table.id}",
+)
+index = index.store()
+print(f"Created SearchIndex with ID: {index.id}")
+
+# --8<-- [end:create_index]
+
+
+# --8<-- [start:search_configuration]
+def create_search_configuration() -> str:
+ """
+ Example: Teach the index that "AD" means "Alzheimer's disease" by building a
+ SynonymSet, wrapping it in a TextAnalyzer, and bundling that analyzer into a
+ SearchConfiguration.
+
+ These resources belong to an Organization, and creating them is restricted
+ to Sage Bionetworks employees. None of them can be deleted once created.
+ """
+ from synapseclient.models import (
+ ColumnAnalyzerOverride,
+ ColumnAnalyzerOverrideEntry,
+ Organization,
+ SearchConfiguration,
+ SynonymSet,
+ TextAnalyzer,
+ )
+
+ organization_name = "my.uniquely.named.organization"
+ organization = Organization(name=organization_name).store()
+ print(f"Using organization: {organization.id} ({organization.name})")
+
+ # Comma-separated entries are interchangeable in both directions; entries
+ # written with "=>" expand the left side to the right side only.
+ #
+ # Keep every entry lowercase. The `lowercase` filter runs before the synonym
+ # filter in the chain below, so a search term is already lowercased by the
+ # time the synonyms are applied -- an entry written "AD => ..." would never
+ # match and never expand.
+ synonyms = SynonymSet(
+ organization_name=organization_name,
+ name="ad_synonyms",
+ description="Abbreviations used across Alzheimer's disease studies",
+ definition={
+ "type": "synonym_graph",
+ "synonyms": [
+ "rna sequencing, rna-seq, rnaseq",
+ "ad => alzheimer's disease, alzheimers disease",
+ "mci => mild cognitive impairment",
+ ],
+ },
+ ).store()
+ print(f"Created SynonymSet: {synonyms.id} ({synonyms.qualified_name})")
+
+ # The synonym filter is applied in `default_search` only, so synonyms expand
+ # the incoming query rather than bloating the stored index.
+ analyzer = TextAnalyzer(
+ organization_name=organization_name,
+ name="ad_synonym_analyzer",
+ description="English analyzer that expands AD abbreviations at search time",
+ settings={
+ "filter": {
+ "english_stop": {"type": "stop", "stopwords": "_english_"},
+ "english_stemmer": {"type": "stemmer", "language": "english"},
+ # A $ref resolves to the SynonymSet by its qualified name
+ "ad_synonyms": {"$ref": synonyms.qualified_name},
+ },
+ "analyzer": {
+ "default": {
+ "type": "custom",
+ "tokenizer": "standard",
+ "filter": ["lowercase", "english_stop", "english_stemmer"],
+ },
+ "default_search": {
+ "type": "custom",
+ "tokenizer": "standard",
+ "filter": [
+ "lowercase",
+ "ad_synonyms",
+ "english_stop",
+ "english_stemmer",
+ ],
+ },
+ },
+ },
+ ).store()
+ print(f"Created TextAnalyzer: {analyzer.id} ({analyzer.qualified_name})")
+
+ # Columns not named here fall back to the configuration's default analyzer
+ overrides = ColumnAnalyzerOverride(
+ organization_name=organization_name,
+ name="study_column_overrides",
+ description="Treat the diagnosis column as a single exact value",
+ overrides=[
+ ColumnAnalyzerOverrideEntry(
+ column_name="diagnosis",
+ analyzer={"analyzer": {"default": {"type": "keyword"}}},
+ ),
+ ],
+ ).store()
+ print(f"Created ColumnAnalyzerOverride: {overrides.id}")
+
+ configuration = SearchConfiguration(
+ organization_name=organization_name,
+ name="study_search_config",
+ description="Analyzer settings for the study summary search index",
+ default_analyzer={"$ref": analyzer.qualified_name},
+ column_analyzer_overrides=[{"$ref": overrides.qualified_name}],
+ ).store()
+ print(f"Created SearchConfiguration: {configuration.id}")
+ return configuration.id
+
+
+# --8<-- [end:search_configuration]
+
+
+# --8<-- [start:apply_search_configuration]
+def create_index_with_configuration(search_configuration_id: str) -> SearchIndex:
+ """
+ Example: Build an index that uses a specific SearchConfiguration, and bind
+ the same configuration to the project so later indexes inherit it.
+ """
+ from synapseclient.models import SearchConfigBinding
+
+ index = SearchIndex(
+ name="Study Summaries Search Index With Synonyms",
+ parent_id=project_id,
+ defining_sql=f"SELECT * FROM {table.id}",
+ search_configuration_id=search_configuration_id,
+ ).store()
+ print(f"Created SearchIndex {index.id} using config {search_configuration_id}")
+
+ # Any index created under this project without its own
+ # search_configuration_id now inherits this configuration
+ binding = SearchConfigBinding(
+ object_id=project_id,
+ search_configuration_id=search_configuration_id,
+ ).store()
+ print(f"Bound configuration {binding.search_configuration_id} to {project_id}")
+
+ # "AD" now matches the abstracts that spell out "Alzheimer's disease"
+ results = index.query(
+ search_query=SearchQuery(
+ query=Query(match={"abstract": MatchFieldOptions(query="AD")}),
+ source=SourceFilter(includes=["study_name", "diagnosis"]),
+ size=10,
+ ),
+ response_parts=[SearchQueryPart.HITS, SearchQueryPart.TOTAL_HITS],
+ )
+ print("Abstracts matching the abbreviation 'AD':")
+ print(f"total_hits={results.total_hits}, returned={len(results.hits)}")
+ for hit in results.hits:
+ fields = {field.name: field.value for field in hit.fields}
+ print(f" {fields}")
+ return index
+
+
+# --8<-- [end:apply_search_configuration]
+
+
+# --8<-- [start:full_text_search]
+# Find every study whose abstract mentions Alzheimer's disease, then
+# search across several columns at once.
+results = index.query(
+ search_query=SearchQuery(
+ query=Query(match={"abstract": MatchFieldOptions(query="alzheimer")}),
+ # Every indexed column comes back on each hit unless a source filter
+ # narrows them down, and the abstracts are long
+ source=SourceFilter(includes=["study_name", "diagnosis"]),
+ size=10,
+ ),
+ response_parts=[
+ SearchQueryPart.HITS,
+ SearchQueryPart.TOTAL_HITS,
+ SearchQueryPart.SELECT_COLUMNS,
+ ],
+)
+print("Abstracts mentioning Alzheimer's:")
+print(f"columns: {[column.name for column in results.select_columns]}")
+print(f"total_hits={results.total_hits}, returned={len(results.hits)}")
+for hit in results.hits:
+ fields = {field.name: field.value for field in hit.fields}
+ print(f" {fields}")
+
+# A multi_match clause runs the same text across several columns, so the
+# person searching does not need to know which column holds the term.
+results = index.query(
+ search_query=SearchQuery(
+ query=Query(
+ multi_match=MultiMatchQuery(
+ query="tau",
+ # ^2 boosts a match in the study name over one in the abstract
+ fields=["study_name^2", "abstract"],
+ )
+ ),
+ source=SourceFilter(includes=["study_name"]),
+ size=10,
+ ),
+ response_parts=[SearchQueryPart.HITS, SearchQueryPart.TOTAL_HITS],
+)
+print("\nAnything mentioning tau:")
+print(f"total_hits={results.total_hits}, returned={len(results.hits)}")
+for hit in results.hits:
+ fields = {field.name: field.value for field in hit.fields}
+ print(f" {fields}")
+
+# People misspell things. `fuzziness` tolerates a number of single-character
+# edits -- insert, delete, substitute, or transpose -- between what was typed
+# and what is in the index.
+results = index.query(
+ search_query=SearchQuery(
+ query=Query(
+ match={
+ "abstract": MatchFieldOptions(
+ # "sequencing" with a missing "i"
+ query="sequencng",
+ # "AUTO" scales the allowance with term length: 0 edits for
+ # very short terms, 1 for medium, 2 for long ones
+ fuzziness="AUTO",
+ # The first 3 characters still have to be exact. Without
+ # this, short unrelated words start matching each other
+ prefix_length=3,
+ )
+ }
+ ),
+ source=SourceFilter(includes=["study_name"]),
+ size=10,
+ ),
+ response_parts=[SearchQueryPart.HITS, SearchQueryPart.TOTAL_HITS],
+)
+print("\nMisspelling 'sequencng' still finds:")
+print(f"total_hits={results.total_hits}, returned={len(results.hits)}")
+for hit in results.hits:
+ fields = {field.name: field.value for field in hit.fields}
+ print(f" {fields}")
+
+# --8<-- [end:full_text_search]
+
+
+# --8<-- [start:columns_and_highlights]
+# Ask for a snippet of the matching text alongside each hit, so a
+# result list can show why the row matched.
+results = index.query(
+ search_query=SearchQuery(
+ query=Query(match={"abstract": MatchFieldOptions(query="sequencing")}),
+ source=SourceFilter(includes=["study_name", "assay"]),
+ highlight=Highlight(fields={"abstract": HighlightField(number_of_fragments=1)}),
+ size=10,
+ ),
+ response_parts=[SearchQueryPart.HITS, SearchQueryPart.TOTAL_HITS],
+)
+print("Studies that sequenced something:")
+for hit in results.hits:
+ fields = {field.name: field.value for field in hit.fields}
+ print(f" {fields}")
+ for highlight in hit.highlights:
+ print(f" {highlight.name}: {highlight.snippets}")
+
+
+# --8<-- [end:columns_and_highlights]
+
+
+# --8<-- [start:filters_and_sorting]
+# Combine a scored clause with unscored filters using a bool query,
+# then order the results by a numeric column instead of by relevance.
+
+results = index.query(
+ search_query=SearchQuery(
+ query=Query(
+ bool=BoolQuery(
+ # Scored: how well the abstract matches drives relevance
+ must=[Query(match={"abstract": MatchFieldOptions(query="sequencing")})],
+ # Unscored: a hard cutoff on cohort size
+ filter=[Query(range={"participant_count": RangeFieldOptions(gte=200)})],
+ # Unscored: drop a diagnosis we are not interested in
+ must_not=[
+ Query(
+ match_phrase={
+ "diagnosis": MatchPhraseFieldOptions(
+ query="Parkinson's Disease"
+ )
+ }
+ )
+ ],
+ )
+ ),
+ source=SourceFilter(includes=["study_name", "participant_count"]),
+ sort=[{"participant_count": "desc"}],
+ size=10,
+ ),
+ response_parts=[SearchQueryPart.HITS, SearchQueryPart.TOTAL_HITS],
+)
+print("Sequencing studies with at least 200 participants, largest first:")
+print(f"total_hits={results.total_hits}, returned={len(results.hits)}")
+for hit in results.hits:
+ fields = {field.name: field.value for field in hit.fields}
+ print(f" {fields}")
+
+# --8<-- [end:filters_and_sorting]
+
+
+# --8<-- [start:aggregations]
+# Count how many studies fall under each diagnosis and average their
+# cohort sizes, while the hit list itself shows only one diagnosis.
+
+results = index.query(
+ search_query=SearchQuery(
+ query=Query(match_all={}),
+ aggregations={
+ "by_diagnosis": Aggregation(
+ terms=TermsAggregation(field="diagnosis", size=10)
+ ),
+ "mean_cohort_size": Aggregation(
+ avg=AvgAggregation(field="participant_count")
+ ),
+ },
+ # post_filter narrows the hits but not the aggregations, so the facet
+ # counts still show every option a person could pick next
+ post_filter=Query(
+ match_phrase={
+ "diagnosis": MatchPhraseFieldOptions(query="Alzheimer's Disease")
+ }
+ ),
+ source=SourceFilter(includes=["study_name", "diagnosis"]),
+ size=10,
+ ),
+ response_parts=[SearchQueryPart.HITS, SearchQueryPart.TOTAL_HITS],
+)
+print("Hits after the post filter:")
+print(f"total_hits={results.total_hits}, returned={len(results.hits)}")
+for hit in results.hits:
+ fields = {field.name: field.value for field in hit.fields}
+ print(f" {fields}")
+print("\nFacet counts across all studies:")
+print(json.dumps(results.aggregation_results, indent=2))
+
+# --8<-- [end:aggregations]
+
+
+# --8<-- [start:autocomplete]
+# Back a type-ahead box with the autocomplete endpoint, which returns
+# its results directly instead of running as an asynchronous job.
+hits = index.autocomplete(
+ query=Query(
+ match_bool_prefix={"study_name": MatchBoolPrefixFieldOptions(query="Mayo Cl")}
+ ),
+ source=SourceFilter(includes=["study_name"]),
+)
+print("Suggestions for 'Mayo Cl':")
+for hit in hits:
+ print(f" {[field.value for field in hit.fields]}")
+
+
+# --8<-- [end:autocomplete]
+
+
+# --8<-- [start:pagination_offset]
+# Walk every row in the index two hits at a time with a growing offset.
+page_size = 2
+offset = 0
+while True:
+ results = index.query(
+ search_query=SearchQuery(
+ query=Query(match_all={}),
+ source=SourceFilter(includes=["study_name", "participant_count"]),
+ sort=[{"participant_count": "desc"}],
+ from_=offset,
+ size=page_size,
+ ),
+ response_parts=[SearchQueryPart.HITS, SearchQueryPart.TOTAL_HITS],
+ )
+ print(f"Page starting at offset {offset}:")
+ print(f"total_hits={results.total_hits}, returned={len(results.hits)}")
+ for hit in results.hits:
+ fields = {field.name: field.value for field in hit.fields}
+ print(f" {fields}")
+
+ offset += page_size
+ if offset >= results.total_hits:
+ break
+
+# --8<-- [end:pagination_offset]
+
+
+# --8<-- [start:pagination_cursor]
+# The same walk, using the search_after cursor the server hands back instead of
+# a growing offset.
+page_size = 2
+search_after = None
+page = 0
+while True:
+ results = index.query(
+ search_query=SearchQuery(
+ query=Query(match_all={}),
+ source=SourceFilter(includes=["study_name", "participant_count"]),
+ # search_after walks a sort order, so the sort has to put every row
+ # in a definite position. participant_count is unique in this table;
+ # on real data append a unique column to break ties, or a page
+ # boundary can skip or repeat rows.
+ sort=[{"participant_count": "desc"}],
+ # None on the first request, then the cursor from the previous one
+ search_after=search_after,
+ size=page_size,
+ ),
+ response_parts=[SearchQueryPart.HITS, SearchQueryPart.TOTAL_HITS],
+ )
+ print(f"Page {page}:")
+ print(f"total_hits={results.total_hits}, returned={len(results.hits)}")
+ for hit in results.hits:
+ fields = {field.name: field.value for field in hit.fields}
+ print(f" {fields}")
+
+ # The cursor is opaque -- pass it back unchanged. It goes None on the last
+ # page, which is what ends the walk.
+ search_after = results.next_search_after
+ if not search_after or not results.hits:
+ break
+ page += 1
+
+# --8<-- [end:pagination_cursor]
diff --git a/mkdocs.yml b/mkdocs.yml
index 60aab2d64..d759fa3b8 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -39,6 +39,7 @@ nav:
- Dataset: tutorials/python/dataset.md
- Dataset Collection: tutorials/python/dataset_collection.md
- Materialized View: tutorials/python/materializedview.md
+ - Search Index: tutorials/python/search.md
- Submission View: tutorials/python/submissionview.md
- Sharing Settings: tutorials/python/sharing_settings.md
- Wiki: tutorials/python/wiki.md